Merge origin/master into codex/status-bar-token-metrics

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
This commit is contained in:
Hypatia May
2026-07-28 16:16:04 +08:00
238 changed files with 3141 additions and 1660 deletions

View File

@@ -11,10 +11,21 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
## Reactive read and contract-currency discipline
How live data reaches render code, and what may cross a business boundary:
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
## Export discipline (client plugin packages)

View File

@@ -42,6 +42,10 @@
cursor: pointer;
}
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.chevron {
flex: none;
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: fbb3a142b9efa50045f127d430bd2be2849f01f2
README.zh.md: b5bd3c458ed462e01dfae5bcd7399dde5552dc8d
README.md: 935bb4908fc54fc629574963e28dc0ddcfb89a6c
README.zh.md: 85d444ee5d973bb989232ecaf00f2312d3195c8d

View File

@@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).

View File

@@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 已知限制与暂缓事项
- **`loader.unload` 是 stub抛出 not-implemented**完整链路fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage在 staged 状态下被移除的 Session其 scope 会冻结保留,直到 stage 转向其他 Session而非直到真实观察者数量降为零。解析`provideInfo()``binding()``scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage在 staged 状态下被移除的 Session其 scope 会冻结保留,直到 stage 转向其他 Session而非直到真实观察者数量降为零。解析`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。

View File

@@ -151,6 +151,13 @@ export class SessionsService {
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */
private readonly manager: SessionManager
/**
* Atomic current-session provide projection: selection changes and
* provider-roster changes publish through this one source (the renderer
* host's `sessions.provide` feed), so a roster change under a stable
* current id republishes the bundle instead of stranding mounted entries.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* Persisted selection cell (the durable half of `list.current`). Private on
@@ -167,6 +174,10 @@ export class SessionsService {
private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
private readonly currentProvideInfoListeners = new Set<() => void>()
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
@@ -198,7 +209,11 @@ export class SessionsService {
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
// The current-provide projection follows the same current writes.
this.list.subscribe(() => {
this.followCurrent()
this.updateCurrentProvideInfo()
})
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
@@ -206,6 +221,14 @@ export class SessionsService {
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfo = this.materializeMaybeProvideInfo()
this.currentProvideInfoSnapshot = this.maybeInfo
this.currentProvideInfo = {
getSnapshot: () => this.currentProvideInfoSnapshot,
subscribe: (fn) => {
this.currentProvideInfoListeners.add(fn)
return () => { this.currentProvideInfoListeners.delete(fn) }
},
}
rootCtx.reflect.provide('sessions', this, undefined)
}
@@ -238,6 +261,30 @@ export class SessionsService {
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
this.updateCurrentProvideInfo()
}
/**
* Re-derive the current selection's provide bundle and publish it when it
* changed. Bundles are identity-stable per (scope, roster)
* materialization, so an identity compare is exact; synchronous notify —
* both call sites (list.subscribe, provide()) already sit behind their own
* batching or registration edges.
*/
private updateCurrentProvideInfo(): void {
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
if (next === this.currentProvideInfoSnapshot) return
this.currentProvideInfoSnapshot = next
for (const fn of [...this.currentProvideInfoListeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
}
/** Build the static no-session kit and reject duplicate declared names. */
@@ -404,25 +451,21 @@ export class SessionsService {
}
/**
* Resolve the render-layer standard-props bundle (SessionProvider's feed
* through the renderer host; ctx never enters the render layer). Pure
* resolution — render-safe: SessionProvider calls this during render, so no
* staging, no window side effects (StrictMode double-invokes and concurrent
* discarded passes must stay free).
* @param id - session id.
* @returns the provide info, or undefined for a session neither listed nor already scoped.
* Resolve one session's render-layer standard-props bundle (ctx never
* enters the render layer; the renderer subscribes to
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
* no staging, no window side effects (StrictMode double-invokes and
* concurrent discarded passes must stay free).
*/
provideInfo(id: string): SessionProvideInfo | undefined {
private provideInfo(id: string): SessionProvideInfo | undefined {
return this.resolve(id as SessionId)?.provideInfo
}
/**
* Resolve the current-session-optional standard kit. Unknown or absent ids
* return the static no-session projection rather than removing hook props.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
}

View File

@@ -246,13 +246,6 @@ export class SlotsService extends Service {
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
const current = {
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
}
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: {
list: sessions.list,
current,
provideInfo: id => sessions.provideInfo(id),
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
provideInfo: sessions.currentProvideInfo,
},
workspaces: { list: workspaces.list },
}

View File

@@ -79,7 +79,8 @@ describe('scope tree', () => {
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
b.svc.open(sid('s1'))
expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
@@ -183,24 +184,82 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
})
describe('cell (render-layer session kit)', () => {
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const info = b.svc.provideInfo('s1')
expect(info).toBeDefined()
expect(info?.sessionId).toBe('s1')
b.svc.open(sid('s1'))
const info = b.svc.currentProvideInfo.getSnapshot()
expect(info.sessionId).toBe('s1')
// The bundle carries bare observables; hook binding happens in React.
expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
expect(b.svc.provideInfo('s1')).toBe(info)
expect(b.svc.provideInfo('ghost')).toBeUndefined()
expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
// Re-staging the same id republishes nothing: identity holds.
b.svc.open(sid('s1'))
expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
})
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const absent = b.svc.currentProvideInfo.getSnapshot()
expect(absent.sessionId).toBeUndefined()
expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
const notified = vi.fn()
b.svc.currentProvideInfo.subscribe(notified)
b.svc.open(sid('s1'))
const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
expect(s1Bundle.sessionId).toBe('s1')
expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
expect(notified).toHaveBeenCalledTimes(1)
b.svc.open(sid('s2'))
const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
expect(s2Bundle.sessionId).toBe('s2')
expect(s2Bundle).not.toBe(s1Bundle)
expect(notified).toHaveBeenCalledTimes(2)
b.svc.clear()
await Promise.resolve() // clearSelection projects through the manager notifier
expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined()
})
it('a provider roster change under a stable current id republishes the bundle', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
const before = b.svc.currentProvideInfo.getSnapshot()
const notified = vi.fn()
b.svc.currentProvideInfo.subscribe(notified)
const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
const dispose = b.svc.provide({
hooks: ['extra'],
props: ['marker'],
resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
})
const added = b.svc.currentProvideInfo.getSnapshot()
expect(added).not.toBe(before)
expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
expect(added.hooks['extra']).toBe(source)
expect(notified).toHaveBeenCalledTimes(1)
dispose()
const removed = b.svc.currentProvideInfo.getSnapshot()
expect(removed).not.toBe(added)
expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
expect(notified).toHaveBeenCalledTimes(2)
})
it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const notified = vi.fn()
const off = b.svc.currentProvideInfo.subscribe(notified)
off()
b.svc.open(sid('s1'))
expect(notified).not.toHaveBeenCalled()
})
it('binding() is pure resolution: no staging, no deferred sweep', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged
b.svc.provideInfo('s2') // resolution only — must NOT move the stage
b.svc.binding(sid('s2'))
b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
@@ -211,7 +270,6 @@ describe('cell (render-layer session kit)', () => {
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.provideInfo('s1')
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
b.svc.open(sid('s1'))

View File

@@ -97,18 +97,13 @@ function fakeWorkspaces() {
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
}
/** Minimal sessions face for the host seam (list observable + provide bundle). */
/** Minimal sessions face for the host seam (list observable + current provide projection). */
function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
return {
list: { getSnapshot: () => state, subscribe: () => () => undefined },
provideInfo: (id: string) => (id === 'known'
? {
sessionId: id,
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
props: {},
}
: undefined),
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
}
}
@@ -232,13 +227,11 @@ describe('host face', () => {
expect(host.entriesOf('t.host')).toHaveLength(0)
})
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
it('exposes the session list and the atomic current provide projection', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
expect(host.sessions.current.getSnapshot()).toBeUndefined()
expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
})
it('exposes the independent Workspace list source', async () => {

View File

@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
'conversation.input.model': { kind: 'single', scope: 'session' },
},
inject: (sessionId: SessionId): ComposerBarInjected => {
const shell = inputHub.shell(sessionId)
return {
keyboard: inputHub.keyboard(sessionId),
keyboard: shell,
stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},
}, InputBar)

View File

@@ -9,18 +9,6 @@
color: var(--dsw-alias-label-primary);
}
.pulse {
display: inline-block;
width: 8px;
height: 14px;
background: var(--dsw-alias-state-business-primary);
animation: pulse 1s infinite ease-in-out;
}
@keyframes pulse {
50% { opacity: 0.2; }
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
.stopped {
align-self: flex-start;

View File

@@ -2,8 +2,8 @@
// reasoning as the figma Think summary row (expand = indented gray text),
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial
// (pulse marker).
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
blocks: readonly AssistantBlock[]
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
interrupted?: boolean | undefined
}
@@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
return (
<ToolRow
variant="think"
icon={<IconThinkOutline14 />}
icon={<IconThinkOutline14 size={14} />}
title="Think"
summary={firstLine(text)}
body={text}
@@ -58,7 +58,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
}
})}
{streaming && <span className={css.pulse} />}
{interrupted && <span className={css.stopped}></span>}
</div>
)

View File

@@ -1,5 +1,6 @@
/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma);
tool rows inside a group gap 10. Input padding cap rides the skeleton. */
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
runs) via the column gap and between consecutive tool rows via the group
gap. Input padding cap rides the skeleton. */
.root {
position: relative;
@@ -30,7 +31,7 @@
.toolGroup {
display: flex;
flex-direction: column;
gap: 10px;
gap: 16px;
}
.callRow {
@@ -51,6 +52,35 @@
border-left: 1px solid var(--dsw-alias-border-l2);
}
/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
right with a stepped trail — flat keyframe holds, no tweening. Phase
offsets come from per-rect animation-delay (index * -250ms) set inline
by the component. */
.turnDots {
align-self: flex-start;
flex: none;
display: flex;
align-items: center;
/* One message line box: the dots center inside the text line height. */
height: 26px;
/* Same pin as StateDot: ongoing blue has no alias token (business-primary
is the 500 step, not this 450). */
color: var(--dsw-static-deepseek-450);
}
.turnDotCell {
fill: currentColor;
opacity: 0.15;
animation: dsh-turn-dots-chase 1s infinite;
}
@keyframes dsh-turn-dots-chase {
0%, 24.9% { opacity: 1; }
25%, 49.9% { opacity: 0.6; }
50%, 74.9% { opacity: 0.35; }
75%, 100% { opacity: 0.15; }
}
.hint {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;

View File

@@ -49,19 +49,20 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: {
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected, cwd }: {
renderSlot: RenderToolRow
node: CodeSubCall
onOpenDetails: OpenDetails
selected: boolean
cwd: string | undefined
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const seq = settled ? node.seq : node.time
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node,
callId: node.callId, toolName, block: node, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
}), [node, toolName, seq, onOpenDetails])
}), [node, toolName, seq, cwd, onOpenDetails])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -77,7 +78,9 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s
* GenericToolCard at this render site. A `run_code` call additionally
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: {
const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd,
}: {
renderSlot: RenderToolRow
callId: string
toolName: string
@@ -91,11 +94,13 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
subCalls?: readonly CodeSubCall[] | undefined
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
selectedCallId?: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
}) {
const owner = useMemo(() => ({
callId, toolName, block,
callId, toolName, block, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, onOpenDetails])
}), [callId, toolName, block, seq, cwd, onOpenDetails])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -111,6 +116,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
node={node}
onOpenDetails={onOpenDetails}
selected={node.callId === selectedCallId}
cwd={cwd}
/>
))}
</div>
@@ -119,8 +125,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
)
})
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: {
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
@@ -128,6 +134,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
selectedCallId: string | undefined
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
}) {
return (
<div className={css.toolGroup}>
@@ -143,12 +151,47 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
selected={node.callId === selectedCallId}
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
/>
))}
</div>
)
})
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
* 2px cell, same blue) chasing left to right with a stepped trail — flat
* keyframe holds, no tweening, no rotation. Phase offsets come from
* per-rect animation-delay. */
const LOADER_CELLS = [0, 5, 10, 15] as const
function TurnDots() {
return (
/* The wrapper is a 26px line box (message line height) so the loader
occupies one text line and centers the dots inside it. */
<div className={css.turnDots} aria-hidden="true">
<svg
width="17.5"
height="2.5"
viewBox="0 0 17.5 2.5"
shapeRendering="crispEdges"
>
{LOADER_CELLS.map((x, index) => (
<rect
key={x}
className={css.turnDotCell}
x={x}
y="0"
width="2.5"
height="2.5"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
/>
))}
</svg>
</div>
)
}
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: {
@@ -167,8 +210,11 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const pending = useSession(s => s.pending)
@@ -268,6 +314,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
/>
)
}
@@ -309,11 +356,15 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
/>
))}
</div>
)}
{pending.map(item => <PendingCard key={item.key} item={item} />)}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}
</div>
</div>
<StatsLine useSession={useSession} />

View File

@@ -13,20 +13,20 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t
import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx'
/** Variant leading icons (figma table). */
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
think: <IconThinkOutline14 />,
search: <IconSearchOutline16 />,
read: <IconBrowseOutline16 />,
bash: <IconApiOutline14 size={16} />,
write: <IconEditOutline16 />,
edit: <IconEditOutline16 />,
code: <IconCodeOutline16 />,
others: <IconSparkle16 />,
think: <IconThinkOutline14 size={14} />,
search: <IconSearchOutline16 size={14} />,
read: <IconBrowseOutline16 size={14} />,
bash: <IconApiOutline14 size={14} />,
write: <IconEditOutline16 size={14} />,
edit: <IconEditOutline16 size={14} />,
code: <IconCodeOutline16 size={14} />,
others: <IconSparkle16 size={14} />,
}
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block)
export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block, cwd)
return (
<ToolRow
variant={model.variant}

View File

@@ -7,22 +7,48 @@
}
.row {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
theme background at 60% — glides over the row content from off-left to
off-right, washing glyphs and icon toward the background as it passes.
ease-out with a 10% end hold gives each pass a beat before the next. */
.root[data-state='running'] .row::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-tool-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-tool-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
/* Clickable rows keep only the cursor affordance — no hover fill. */
.row[data-clickable] {
cursor: pointer;
border-radius: 6px;
}
.row[data-clickable]:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading {
position: relative; /* .chevronHover overlay anchor */
flex: none;
width: 16px;
height: 16px;
@@ -65,11 +91,36 @@ button.leading {
color: var(--dsw-alias-label-secondary);
}
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
color: var(--dsw-alias-label-secondary);
}
.sep {

View File

@@ -1,8 +1,10 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron when expanded) + title +
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
// no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner.
// TODO(ux): converge every chat-tab tool row on in-place expansion for its
// expandable content, retiring the details-panel handoff where feasible.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -28,11 +30,11 @@ export interface ToolRowProps {
onOpenDetails?: (() => void) | undefined
}
/** Leading-slot state substitution: the tool icon yields to the state semantic
* (running = blue ring, error = red, interrupted = amber halo; ok = icon). */
/** Leading-slot state substitution: the tool icon yields to the terminal state
* semantic (error = red, interrupted = amber halo). Running keeps the icon —
* the row sweep (CSS on data-state) carries the in-flight signal. */
function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return icon
@@ -66,6 +68,19 @@ export function ToolRow({
event.preventDefault()
toggleExpand()
}
// Expandable rows preview the toggle on hover: the tool icon yields to a
// down chevron (CSS swap on .row:hover); state dots still take precedence.
const collapsedIcon = expandable
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
: leadingFor(state, collapsedIcon)
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
@@ -84,11 +99,11 @@ export function ToolRow({
aria-expanded={open}
onClick={toggleFromLeading}
>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
{leading}
</button>
) : (
<span className={css.leading}>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
{leading}
</span>
)}
<span className={css.title}>{title}</span>

View File

@@ -12,6 +12,16 @@ export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
/** An assistant node that renders nothing: only tool-call heads (rows render
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
* always render (the 已停止 marker). */
function rendersNothing(node: ConversationNode): boolean {
return node.kind === 'assistant' && node.interrupted !== true
&& node.blocks.every(b => b.kind === 'tool-call'
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
@@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
const items: ChatFlowItem[] = []
let group: ToolResultNode[] | null = null
for (const node of nodes) {
if (rendersNothing(node)) continue
if (node.kind === 'tool-result') {
if (group === null) {
group = [node]

View File

@@ -1,11 +1,11 @@
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type {
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -143,6 +143,8 @@ export interface ToolRowOwnerProps {
toolName: string
/** Frozen call slice: the running call or the settled result node. */
block: ToolCallBlock
/** Session workspace root; path summaries display relative to it. */
cwd?: string | undefined
/** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails: () => void
}
@@ -220,6 +222,13 @@ export interface ComposerBarInjected {
keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */
stop: () => void
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
}
}
/**
@@ -231,11 +240,11 @@ export interface InputControlOwnerProps {
locked: boolean
}
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& ComposerBarInjected
& InjectFace<ComposerBarInjected>
/**
* Composer chain currency: what ConversationRoot dispatches at its
@@ -300,6 +309,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
export interface EmptyWorkspaceOwnerProps {
open: boolean
anchorRef?: RefObject<HTMLElement>
/** Currently active workspace (renders a trailing check in the picker list). */
selectedId?: WorkspaceId | undefined
onPick: (workspaceId: WorkspaceId) => void
onClose: () => void
}

View File

@@ -101,6 +101,14 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
others: [],
}
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
function relativizeToCwd(text: string, cwd: string | undefined): string {
if (cwd === undefined || cwd === '') return text
const root = cwd.replace(/[/\\]+$/, '')
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
return text
}
function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
const parsed = parseArgs(argsRaw)
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
@@ -130,16 +138,17 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
* Derive the full row model from a frozen call slice.
* @param toolName - wire tool name (dispatch-supplied; survives windowless results).
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
* @returns the row model.
*/
export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel {
export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel {
const variant = classifyTool(toolName)
const done = 'kind' in block
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
const state: ToolRowState = !done ? 'running'
: block.error?.code === 'interrupted' ? 'stopped'
: block.isError ? 'error' : 'ok'
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw)
const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd)
const toolTitle = TOOL_TITLES[toolName]
// Others keeps the static "Tool call" title (figma literal); the real tool
// name rides the mutable summary slot unless the tool owns a specific title.

View File

@@ -77,8 +77,6 @@ export interface InputNotice {
* satisfies it structurally.
*/
export interface ComposerKeyboard {
/** Latest surfaced notice store (null after none). */
readonly notices: SnapshotStore<InputNotice | null>
/** Live machine state for event-handler reads (render reads go through useInput). */
readonly snapshot: InputState
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void
/** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */
lexicon(): ReadonlyMap<'/' | '@', readonly string[]>
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */

View File

@@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput {
}
/**
* Hot plain-text reference lexicons for the decoration scan (decision 21).
* @returns the controller's per-trigger aggregation; empty Map without a pipeline.
* Hot plain-text reference lexicon source for the decoration scan
* (decision 21): delegates to the controller's aggregated store. Stable
* identity per shell; without a pipeline the snapshot is the empty Map and
* subscribers never fire.
*/
lexicon(): ReadonlyMap<'/' | '@', readonly string[]> {
return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
}
/**

View File

@@ -54,8 +54,8 @@
border: none;
border-radius: 12px;
background: transparent;
font-size: 13px;
line-height: 16px;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
@@ -72,13 +72,6 @@
cursor: default;
}
.meta {
margin-left: 4px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
.tabs {
display: flex;
@@ -87,7 +80,7 @@
padding-left: 8px;
}
/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */
.tab {
position: relative;
padding: 0 0 11px;
@@ -95,7 +88,7 @@
background: transparent;
font-size: 13px;
line-height: 16px;
font-weight: 510;
font-weight: 500;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
@@ -139,11 +132,45 @@
NOT absolute+transform: a transform would make this box the containing
block for position:fixed descendants (pickers/modals), shrinking them. */
.composerHero {
position: relative; /* .heroGlow positioning context */
align-self: center;
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
gap: 12px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
width: min(776px, calc(100% - 48px));
z-index: 1;
}
/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the
card's resting center sits ~92px above the stack bottom (32 foot pad +
half of the ~120px two-row card); width tracks the card (glow asset 1051
vs design card 776) so blur scales in userSpace with it. z-index -1 keeps
it behind the in-flow hero content inside this stacking context. */
.heroGlow {
position: absolute;
left: 50%;
bottom: 92px;
z-index: -1;
width: calc(100% * 1051 / 776);
aspect-ratio: 1051 / 468;
transform: translate(-50%, 50%);
pointer-events: none;
}
.heroWorkspaceRow {
display: flex;
align-items: center;
min-width: 0;
padding-left: 8px;
}
.root[data-phase='hero'] {
justify-content: center;
}
/* Settling (session replaying, hero/docked unknown): keep the composer
mounted but invisible so no wrong layout flashes before the phase lands. */
.root[data-phase='settling'] .composerStack {
visibility: hidden;
}

View File

@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { DisabledInputBar } from './DisabledInputBar.tsx'
import css from './ConversationRoot.module.css'
@@ -36,33 +36,53 @@ export function ConversationRoot({
workspace => workspace.workspaceId === pendingWorkspaceId,
)
// Clear the pending pick once the session lands in it, or when the picked
// workspace disappears from a ready list (deleted from the sidebar).
useEffect(() => {
if (pendingWorkspaceId !== undefined
&& sessionWorkspace?.workspaceId === pendingWorkspaceId) {
if (pendingWorkspaceId === undefined) return
if (sessionWorkspace?.workspaceId === pendingWorkspaceId
|| (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
setPendingWorkspaceId(undefined)
}
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId])
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
// While a session is still replaying (loading + blank) the hero/docked
// choice is unknowable — render the composer hidden instead of flashing
// the centered hero and snapping to the docked bar (or vice versa).
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
// Flow optimization — worth a close PR review for code/boundary issues.
// The chip is a selector; label resolution walks the flow top-down:
// 1. a just-picked workspace (pending) → its title;
// 2. cold start, no session yet → placeholder ("Choose workspace");
// 3. the blank session's workspace is in the list → its title;
// 4. list still loading → cwd folder name bridges so the title does not
// flash on refresh (empty cwd → placeholder);
// 5. list ready but no owning workspace (deleted from the sidebar) →
// placeholder, never the deleted folder's name via cwd.
const chipTitle = pendingWorkspace?.title
?? (sessionId === undefined
? undefined
: sessionWorkspace?.title
?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
? undefined
: workspaceLabel(cwd)))
const heroWorkspaceRow = (
<>
<div className={css.heroWorkspaceRow}>
<WorkspaceChip
buttonRef={pickerAnchor}
label={
pendingWorkspace?.title
?? (sessionId === undefined
? workspaceLabel('')
: sessionWorkspace?.title ?? workspaceLabel(cwd ?? ''))
}
label={chipTitle}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
/>
{renderSlot('conversation.hero.workspace', {
open: pickerOpen,
anchorRef: pickerAnchor,
selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId,
onPick: (workspaceId) => {
setPickerOpen(false)
setPendingWorkspaceId(workspaceId)
@@ -72,10 +92,13 @@ export function ConversationRoot({
},
onClose: () => { setPickerOpen(false) },
})}
</>
</div>
)
const inputBar = sessionId === undefined
// The placeholder chip ("Choose workspace") and the inert input travel
// together: a blank session whose workspace vanished (deleted from the
// sidebar) reverts to the same disabled bar as the initial no-session state.
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
? <DisabledInputBar />
: renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
@@ -87,6 +110,7 @@ export function ConversationRoot({
const composerBar = (
<div className={clsx(css.composerStack, hero && css.composerHero)}>
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell />}
{hero && heroWorkspaceRow}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
@@ -96,7 +120,7 @@ export function ConversationRoot({
)
return (
<div className={css.root} data-phase={hero ? 'hero' : 'active'}>
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
{/* Mounted for every real session, hero included: ConversationSession
renders no chrome while blank but owns the draft-persistence mirror
bind — unmounting it in the hero would lose pre-first-send text on

View File

@@ -31,7 +31,6 @@ export function ConversationSession({
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const turns = useSession(s => countTurns(s))
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
@@ -69,7 +68,6 @@ export function ConversationSession({
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
<span className={css.meta}>· {turns} turns</span>
</nav>
</div>
{tabs.length > 1 && (
@@ -95,9 +93,3 @@ export function ConversationSession({
</>
)
}
function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number {
let count = 0
for (const node of snapshot.nodes) if (node.kind === 'user') count += 1
return count
}

View File

@@ -29,7 +29,7 @@ export function DisabledInputBar() {
<div className={css.trailing}>
<button type="button" className={css.primary} aria-label="Send message" disabled>
<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.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
</button>
</div>

View File

@@ -7,20 +7,18 @@
import { useId } from 'react'
import type { ReactNode, RefObject } from 'react'
import {
FishLogo, IconChevronDownOutline14, IconFolderOpen16,
FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import css from './HeroShell.module.css'
/**
* Basename label for the workspace chip / menu rows (the shared derivation);
* empty → the design's "New Workspace" placeholder copy; separator-only
* paths echo the raw cwd.
* @param cwd - workspace directory path ('' for none).
* Basename label for the workspace chip (the shared derivation);
* separator-only paths echo the raw cwd.
* @param cwd - workspace directory path (non-empty).
* @returns chip label.
*/
export function workspaceLabel(cwd: string): string {
if (cwd === '') return 'New Workspace'
const base = workspaceTitleOf(cwd)
return base !== '' ? base : cwd
}
@@ -28,15 +26,17 @@ export function workspaceLabel(cwd: string): string {
/**
* The workspace chip (folder + label + chevron), always interactive: before
* the first message the workspace stays switchable — picking another one
* moves the New Session flow to that workspace's blank session.
* @param props.label - chip label (see {@link workspaceLabel}).
* moves the New Session flow to that workspace's blank session. Without a
* label the chip renders its placeholder state: closed folder + the
* "Choose workspace" call to action.
* @param props.label - chip label (see {@link workspaceLabel}); omitted → placeholder.
* @param props.menuOpen - menu expansion echo.
* @param props.onClick - menu toggle.
* @returns the chip button element.
*/
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
buttonRef?: RefObject<HTMLButtonElement>
label: string
label?: string | undefined
menuOpen?: boolean
onClick?: () => void
}) {
@@ -50,13 +50,49 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
aria-expanded={menuOpen}
onClick={onClick}
>
<IconFolderOpen16 className={css.folder} size={16} />
<span className={css.workspaceLabel}>{label}</span>
{label === undefined
? <IconFolderClose16 className={css.folder} size={16} />
: <IconFolderOpen16 className={css.folder} size={16} />}
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)
}
/**
* The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero
* owner (ConversationRoot), not HeroShell, so it can center on the input
* card; the owner's className supplies all positioning.
* @param props.className - positioning class from the owner.
* @returns the blurred-ellipse svg element.
*/
export function HeroGlow({ className }: { className?: string | undefined }) {
// Stable filter id so multiple hero mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return (
<svg className={className} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.08" />
</g>
</svg>
)
}
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
export interface HeroShellProps {
/** Overlay content after the stack (modals). */
@@ -64,13 +100,12 @@ export interface HeroShellProps {
}
/**
* Render the hero chrome (headline + glow; no composer, no workspace row).
* Render the hero chrome (headline only; no glow, no composer, no workspace
* row — the glow is the owner's {@link HeroGlow}).
* @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree.
*/
export function HeroShell({ children }: HeroShellProps) {
// Stable filter id so multiple hero mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return (
<div className={css.root}>
<div className={css.stack}>
@@ -80,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) {
Let&apos;s start building
</div>
<div className={css.body}>
{/* figma 313:14109: soft ellipse behind workspace + composer; width
tracks the card (glow asset 1051 vs design card 776) so blur
scales in userSpace with it. */}
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
</g>
</svg>
{/* The resident composer (rendered by ConversationRoot at its stable
tree position; the workspace row rides its accessory hole) is
CSS-positioned into this gap during the hero phase — see

View File

@@ -8,8 +8,7 @@
justify-content: center;
height: 100%;
min-width: 0;
padding: 24px;
margin-bottom: -70px;
padding: 0 24px;
}
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
@@ -24,17 +23,15 @@
overflow: visible;
}
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block
keeps 36px below the headline before the flex gap. */
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */
.headline {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding-bottom: 36px;
font-size: 26px;
line-height: 32px;
font-weight: 600;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
@@ -44,8 +41,9 @@
color: var(--dsw-alias-state-business-primary);
}
/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is
centered on this block so it stays under the picker + InputBar together. */
/* Workspace row sits 12px above the input card (figma y80 → y112). The blue
glow lives with the owner (ConversationRoot .heroGlow) so it can center on
the input card. */
.body {
position: relative;
display: flex;
@@ -55,19 +53,7 @@
overflow: visible;
}
/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */
.glow {
position: absolute;
left: 50%;
top: 50%;
z-index: 0;
width: calc(100% * 1051 / 776);
aspect-ratio: 1051 / 468;
transform: translate(-50%, -50%);
pointer-events: none;
}
.body > :not(.glow) {
.body > * {
position: relative;
z-index: 1;
}
@@ -88,7 +74,7 @@
display: inline-flex;
align-items: center;
gap: 4px;
max-width: fit-content;
max-width: min(100%, 360px);
min-height: 28px;
padding: 0 8px;
border: none;

View File

@@ -171,6 +171,10 @@
.input,
.mirror,
.backdrop {
/* Textareas default to content-box (unlike buttons/inputs): without this the
width:100% textarea gains its padding OUTSIDE the card and text runs past
the right padding — and wraps 28px later than the mirror/backdrop layers. */
box-sizing: border-box;
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
metrics or the highlight ranges drift off the glyphs. */
padding: 4px 12px 0 16px;
@@ -306,11 +310,14 @@
border: none;
border-radius: 999px;
background: var(--dsw-alias-button-info-fill);
color: var(--dsw-alias-label-primary-foreground);
/* Static white, not the foreground token: the arrow stays white on the blue
fill in both themes (design 34:10465). */
color: #fff;
cursor: pointer;
transition: background-color 100ms ease;
}
.primary:hover {
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
}
@@ -319,14 +326,6 @@
cursor: default;
}
/* Stop state: same slot, dimmed brand fill — the running-state send-key
replacement is a design gap filled by us (figma gives no stop form). */
.stopping,
.stopping:hover {
background: var(--dsw-alias-button-primary-dimmed);
color: var(--dsw-alias-label-primary);
}
.retry {
margin-left: 8px;
padding: 1px 8px;

View File

@@ -1,11 +1,12 @@
/** The default composer body: the 'conversation.composer.bar' slot entry
* (decision 20). Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject; layout-phase inputs (variant, placeholder,
* through this entry's own inject, whose hooks compartment binds
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -27,15 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
]
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, renderSlot,
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const noticeStore = keyboard.notices
const notice = useSyncExternalStore(
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
@@ -244,7 +242,7 @@ export function InputBar({
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
const deco = deriveDecorations(input, keyboard.lexicon())
const deco = deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
@@ -374,7 +372,7 @@ export function InputBar({
{machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />}
<button
type="button"
className={clsx(css.primary, running && css.stopping)}
className={css.primary}
aria-label={primaryLabel}
title={primaryLabel}
disabled={!running && (empty || disabled || machineBusy)}
@@ -383,11 +381,11 @@ export function InputBar({
>
{running ? (
<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="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
) : (
<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.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
)}
</button>

View File

@@ -92,15 +92,15 @@
color: var(--dsw-alias-state-success-primary);
}
.glyphPending {
color: var(--dsw-alias-label-caption);
}
.glyphProgress {
color: var(--dsw-alias-state-business-primary);
animation: todo-progress-spin 1s linear infinite;
}
.glyphPending {
color: var(--dsw-alias-label-caption);
}
@keyframes todo-progress-spin {
to {
transform: rotate(360deg);

View File

@@ -1,6 +1,8 @@
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
@@ -9,8 +11,27 @@
border-radius: 6px;
}
.root:hover {
background: var(--dsw-alias-interactive-bg-hover);
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-bash-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-bash-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
@@ -39,7 +60,7 @@
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
color: var(--dsw-alias-label-secondary);
}
.sep {

View File

@@ -12,10 +12,10 @@ import css from './bash-sample.module.css'
function leadingFor(state: ToolRowState) {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconApiOutline14 size={16} />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return <IconApiOutline14 size={14} />
}
}

View File

@@ -10,10 +10,6 @@
border-radius: 6px;
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading {
flex: none;
width: 16px;
@@ -29,6 +25,7 @@
flex: none;
font-size: 14px;
line-height: 24px;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary-dimmed);
}

View File

@@ -87,12 +87,13 @@ async function bench() {
}
}
const providers: TestProvider[] = []
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = {
list: listStore,
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
scope: (id: SessionId) => mint(id),
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
scopeOf,
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),

View File

@@ -32,12 +32,13 @@ async function bench() {
current: undefined,
phase: 'ready',
})
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = {
list: listStore,
binding: vi.fn(),
scope: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: vi.fn(() => () => {}),
create: vi.fn(),
open: vi.fn(),

View File

@@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) {
// Provide-channel contributions land in this bundle the way the runtime
// materializes them; the renderer host serves it through provideInfo.
const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
// Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract),
// materialized on first render after the provide contributions landed.
let infoCell: { sessionId: SessionId; hooks: Record<string, unknown>; props: Record<string, unknown> } | undefined
const sessionsFake = {
list,
binding: (id: SessionId) => (id === SID
@@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) {
provideInfo: (id: string) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: undefined),
maybeProvideInfo: (id: string | undefined) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: { hooks: provided.hooks, props: provided.props }),
currentProvideInfo: {
getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props },
subscribe: () => () => {},
},
create: vi.fn(),
open: vi.fn(),
}
@@ -251,7 +255,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the
// same StateDot ring a native in-flight row wears.
// same data-state chrome (row sweep) a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull()
})

View File

@@ -64,6 +64,16 @@ describe('tool-call-model', () => {
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
})
it('displays workspace-rooted paths relative to the session cwd', () => {
const cwd = '/Users/u/ws/'
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md')
// Paths outside the workspace (and non-path summaries) stay verbatim.
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts')
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
})
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
@@ -125,12 +135,12 @@ describe('ToolRow', () => {
expect(view.getByText('List files')).toBeTruthy()
})
it('running and error states replace the icon with a StateDot', () => {
it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
const runningView = render(<ToolRow {...rowProps} state="running" />)
expect(runningView.queryByTestId('tool-icon')).toBeNull()
expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render(<ToolRow {...rowProps} state="error" />)
expect(errorView.queryByTestId('tool-icon')).toBeNull()
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
})
it('non-expandable rows render a passive leading slot', () => {

View File

@@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
const SID = 's1' as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
afterEach(cleanup)
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
@@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) {
subscribe: (fn: () => void) => session.subscribe(fn),
},
})
const provideInfo = (id: string) => {
if (id !== SID) return undefined
if (info === undefined) {
const hooks: Record<string, unknown> = { session }
const props: Record<string, unknown> = {}
for (const provider of providers) {
const c = provider(bindingOf(SID))
Object.assign(hooks, c.hooks ?? {})
Object.assign(props, c.props ?? {})
}
info = { sessionId: SID, hooks, props }
}
return info
}
ctx.provide('sessions', {
list,
binding: bindingOf,
scope: () => actxFake,
provideInfo: (id: string) => {
if (id !== SID) return undefined
if (info === undefined) {
const hooks: Record<string, unknown> = { session }
const props: Record<string, unknown> = {}
for (const provider of providers) {
const c = provider(bindingOf(SID))
Object.assign(hooks, c.hooks ?? {})
Object.assign(props, c.props ?? {})
}
info = { sessionId: SID, hooks, props }
}
return info
},
maybeProvideInfo(id: string | undefined) {
// `this` inside an object-literal method is any under strict lint; the
// fake resolves through its own provideInfo above.
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
provideInfo,
currentProvideInfo: {
getSnapshot: () => provideInfo(SID),
subscribe: () => () => {},
},
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
scopeOf: () => SID,
@@ -254,7 +255,10 @@ describe('registrant load-order seam', () => {
binding: () => undefined,
scope: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {},
create: vi.fn(),
open: vi.fn(),

View File

@@ -131,6 +131,22 @@ describe('chat-flow derivation', () => {
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
})
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
// A tool-call-only step message (and blank text/reasoning) renders nothing:
// it must not split the run into two groups with an empty line between.
const headsOnly: AssistantMessageNode = {
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }],
}
const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')])
expect(flowKeys(items)).toBe('g3')
const group = items[0]!
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
// Interrupted and visible-content nodes still render (已停止 marker / prose).
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
})
describe('ChatView', () => {

View File

@@ -90,7 +90,7 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],

View File

@@ -56,7 +56,11 @@ function bench(over?: BenchOptions) {
// Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only).
...(lex !== undefined
? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable<ShellDeps['slash']> }
? {
slash: (() => ({
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
})) as unknown as NonNullable<ShellDeps['slash']>,
}
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
@@ -87,6 +91,8 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
stop,
renderSlot,
variant: over?.variant ?? 'composer',

View File

@@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',

View File

@@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',
@@ -234,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => {
})
})
describe('scenario: reference decoration lights up when the lexicon settles', () => {
it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => {
let roll: readonly string[] | undefined
let notify: (() => void) | undefined
const b = await scopedBench((slash) => {
slash.registerSource({
trigger: '/', name: 'skill',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
lexicon: () => roll,
subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => {
notify = listener
return () => { notify = undefined }
},
} as never)
})
// Typed before the catalog settled: a plain token, no decoration.
b.type('/deploy now')
expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
// The catalog settles (ui-skill's settle path fires the same notification).
act(() => {
roll = ['deploy']
notify?.()
})
const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
expect(mark?.textContent).toBe('/deploy')
})
})
describe('scenario I: unknown /xyz + enter', () => {
it('adjudication misses in one hop and the whole line rides the default sink', async () => {
const b = await bench()

View File

@@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts'
const sid = (s: string): SessionId => s as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
interface Bench {
slots: SlotsService
chat: ReturnType<typeof createChatStore>
@@ -23,7 +26,10 @@ function bench(): Bench {
ids: [], byId: {}, current: undefined, phase: 'ready',
}),
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {},
})
ctx.provide('workspaces', {

View File

@@ -118,6 +118,8 @@ function mount(
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
useNotices={bindSnapshotSelector(wiring.notices)}
useLexicon={bindSnapshotSelector(wiring.lexicon)}
stop={stop}
renderSlot={(() => null) as InputBarProps['renderSlot']}
{...bar}

View File

@@ -72,9 +72,11 @@
max-height: min(360px, calc(100vh - 96px));
overflow: hidden;
padding: 4px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Surface tokens match the Menu primitive card (ui-primitives
* Menu.module.css) so every dropdown reads as the same material. */
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-input-major);
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
}
@@ -132,7 +134,7 @@
top: 0;
z-index: 1;
padding: 5px 8px 3px;
background: var(--dsw-specific-input-major);
background: var(--dsw-specific-menu);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
@@ -156,11 +158,16 @@
}
.option:hover:not(:disabled),
.option:focus-visible,
.selected {
.option:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Selection marker is the trailing check, not a fill — matches the Menu
* primitive's selected treatment. */
.selected {
background: transparent;
}
.option:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
@@ -201,7 +208,7 @@
display: grid;
place-items: center;
flex: 0 0 18px;
color: var(--dsw-alias-state-business-primary);
color: var(--dsw-alias-label-primary);
}
/* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side

View File

@@ -18,7 +18,7 @@
.button:disabled {
cursor: not-allowed;
color: var(--dsw-alias-label-dimmed);
opacity: 0.4;
}
.md {
@@ -44,10 +44,6 @@
background: var(--dsw-alias-button-primary-hover);
}
.primary:disabled {
background: var(--dsw-alias-button-primary-dimmed);
}
.ghost:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -66,10 +62,6 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.outline:disabled {
border-color: var(--dsw-alias-border-l1);
}
.toolbar {
background: var(--dsw-alias-button-tool-bar-fill);
}

View File

@@ -26,6 +26,7 @@
left: 0;
z-index: 100;
min-width: 218px;
max-width: 360px;
}
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
@@ -50,6 +51,36 @@
right: 0;
}
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
* (24 = 2 × the portal MARGIN in Menu.tsx) and taller content scrolls inside
* .viewport, so a pinned .footer stays visible. Menus with submenu rows skip
* this class — the overflow clip would crop the side card, so they rely on
* staying short. */
.scrollable {
max-height: calc(100vh - 24px);
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
}
.scrollable .viewport {
overflow-y: auto;
}
/* Pinned rows below the scroll region; l2 hairline (l1 is near-invisible on
* the menu surface) mirrors the .separator spacing. */
.footer {
flex: none;
display: flex;
flex-direction: column;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.itemWrap {
position: relative;
}
@@ -78,7 +109,7 @@
}
.item:disabled {
color: var(--dsw-alias-label-dimmed);
opacity: 0.4;
cursor: not-allowed;
}

View File

@@ -5,6 +5,8 @@
// The owner controls `open`; outside-click closing uses one document listener
// active only while open. Submenus open on hover/focus inside the same root.
// Entries also cover non-interactive `label` headings and `danger` rows.
// Lists keep 12px clearance to the viewport's top/bottom edges and scroll
// internally past that; submenu-bearing menus are exempt (see .scrollable).
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
@@ -50,6 +52,9 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
return 'type' in entry && entry.type === 'label'
}
/** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/**
* Render an anchored dropdown menu.
* @param props.open - whether the list is showing (owner-controlled).
@@ -72,17 +77,20 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
* wrapper there races the host's layout effects). Called on open and on every
* scroll/resize; return null to skip placement for that frame.
* @param props.footer - rows pinned below the scrolling items area, separated
* by a hairline; they stay visible while the items above scroll.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
selectedId?: string
footer?: readonly MenuEntry[]
selectedId?: string | undefined
onSelect: (id: string) => void
onClose: () => void
align?: 'start' | 'end'
side?: 'bottom' | 'top'
side?: 'bottom' | 'top' | 'right'
portal?: boolean
closeOnPointerLeave?: boolean
getAnchorRect?: () => DOMRect | null
@@ -109,11 +117,34 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
r = rootRef.current?.getBoundingClientRect() ?? null
}
if (r === null) return
setFixedPos({
...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }),
...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }),
})
const MARGIN = 12
const vw = window.innerWidth
const vh = window.innerHeight
const listEl = listRef.current
const lw = listEl?.offsetWidth ?? 0
const lh = listEl?.offsetHeight ?? 0
let x: number
let y: number
if (side === 'right') {
x = r.right + 4
y = r.top
} else if (align === 'start') {
x = r.left
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
} else {
x = r.right - lw
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
}
if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN)
setFixedPos({ left: x, top: y })
}
// First run measures the hidden pre-render (same commit as `open`), so
// end/top alignment and clamping use real dimensions before anything
// paints — no visible jump from a zero-size first guess.
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
@@ -146,11 +177,77 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
}
}, [open, onClose])
const list = open && (!portal || fixedPos !== null) && (
// The submenu card is absolutely positioned outside the list box; the
// scroll clip would crop it, so only submenu-free menus get the height cap.
const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
const renderEntry = (entry: MenuEntry) => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
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>
)
}
// Portal lists render hidden until placed: the placement effect measures
// this pre-render in the same commit, so the first painted frame is
// already at the final position (with getAnchorRect returning null the
// list simply stays hidden).
const list = open && (
<div
ref={listRef}
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={fixedPos ?? undefined}
className={clsx(css.list, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
// React portals bubble synthetic events through the REACT tree: without
@@ -158,63 +255,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
// (open/toggle) after onSelect.
onClick={(e) => { e.stopPropagation() }}
>
{items.map((entry) => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
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 className={css.viewport} role="presentation">
{items.map(renderEntry)}
</div>
{footer !== undefined && footer.length > 0 && (
<div className={css.footer} role="presentation">
{footer.map(renderEntry)}
</div>
)}
</div>
)

View File

@@ -54,7 +54,7 @@
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 510;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary);
}

View File

@@ -1,7 +1,7 @@
/* Ongoing blue has no alias token (state-business-primary is the 500 step,
* not this 450) — component-level var pinned to the static scale instead. */
.dot,
.ring {
.matrix {
--dsh-state-ongoing: var(--dsw-static-deepseek-450);
}
@@ -42,24 +42,24 @@
color: var(--dsw-alias-state-error-primary);
}
.ring {
/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe
* holds, no tweening — the retro feel), peaking when the chase hits it and
* decaying over the next three cells. Phase offsets come from per-rect
* animation-delay (index * -125ms) set inline by the component. */
.matrix {
flex: none;
color: var(--dsh-state-ongoing);
animation: dsh-state-dot-spin 1s linear infinite;
}
.stopFrom {
stop-color: currentColor;
stop-opacity: 1;
.cell {
fill: currentColor;
opacity: 0.15;
animation: dsh-state-dot-chase 1s infinite;
}
.stopTo {
stop-color: currentColor;
stop-opacity: 0;
}
@keyframes dsh-state-dot-spin {
to {
transform: rotate(360deg);
}
@keyframes dsh-state-dot-chase {
0%, 12.4% { opacity: 1; }
12.5%, 24.9% { opacity: 0.6; }
25%, 37.4% { opacity: 0.35; }
37.5%, 100% { opacity: 0.15; }
}

View File

@@ -1,15 +1,19 @@
// StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182).
// done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid
// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a
// linear gradient, spinning. Colors resolve through --dsw-* tokens only.
// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light
// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only.
import { useId } from 'react'
import clsx from 'clsx'
import css from './StateDot.module.css'
/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */
const MATRIX_CELLS: readonly (readonly [number, number])[] = [
[0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4],
]
/**
* Render a state dot.
* @param props.state - which of the four states to show.
@@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: {
size?: number
className?: string
}) {
const gradientId = useId()
if (state === 'ongoing') {
return (
<svg
className={clsx(css.ring, className)}
className={clsx(css.matrix, className)}
data-state="ongoing"
width={size}
height={size}
viewBox="0 0 10 10"
shapeRendering="crispEdges"
aria-hidden="true"
>
<defs>
{/* Gradient handles from the figma node: (0.1,0) -> (0.85,1). */}
<linearGradient id={gradientId} x1="1" y1="0" x2="8.5" y2="10" gradientUnits="userSpaceOnUse">
<stop className={css.stopFrom} offset="0" />
<stop className={css.stopTo} offset="1" />
</linearGradient>
</defs>
<circle cx="5" cy="5" r="4.5" fill="none" strokeWidth="1" stroke={`url(#${gradientId})`} />
{MATRIX_CELLS.map(([x, y], index) => (
<rect
key={`${x}-${y}`}
className={css.cell}
x={x}
y={y}
width="2"
height="2"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - MATRIX_CELLS.length) * 125}ms` }}
/>
))}
</svg>
)
}

View File

@@ -72,7 +72,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
{cloneElement(children, {
ref: mergedRef,
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}

View File

@@ -271,14 +271,47 @@ describe('Menu', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('portal mode positions from the opposite edges for align=end / side=top', () => {
it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => {
render(
<Menu portal open align="end" side="top" anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
const menu = screen.getByRole('menu')
expect(menu.style.right).not.toBe('')
expect(menu.style.bottom).not.toBe('')
expect(menu.style.left).toBe('')
expect(menu.style.top).toBe('')
expect(menu.style.left).not.toBe('')
expect(menu.style.top).not.toBe('')
expect(menu.style.right).toBe('')
expect(menu.style.bottom).toBe('')
})
it('renders footer rows in a pinned section below the items; they still select', () => {
const onSelect = vi.fn()
render(
<Menu
open
anchor={<span>trigger</span>}
items={items}
footer={[{ id: 'new', label: 'Create new' }]}
onSelect={onSelect}
onClose={() => {}}
/>)
const footerItem = screen.getByRole('menuitem', { name: 'Create new' })
expect((footerItem.closest('div[class*="footer"]'))).not.toBeNull()
expect(screen.getByRole('menuitem', { name: 'Alpha' }).closest('div[class*="footer"]')).toBeNull()
fireEvent.click(footerItem)
expect(onSelect).toHaveBeenCalledWith('new')
})
it('caps the list height for internal scrolling unless a submenu row is present', () => {
const { rerender } = render(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
expect(screen.getByRole('menu').className).toMatch(/scrollable/)
rerender(
<Menu
open
anchor={<span>trigger</span>}
items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]}
onSelect={() => {}}
onClose={() => {}}
/>)
expect(screen.getByRole('menu').className).not.toMatch(/scrollable/)
})
})

View File

@@ -14,16 +14,17 @@ describe('StateDot', () => {
expect(dot.getAttribute('aria-hidden')).toBe('true')
})
it('solid states are spans; ongoing is an svg gradient ring', () => {
it('solid states are spans; ongoing is an svg pixel matrix', () => {
const { container, rerender } = render(<StateDot state="done" />)
expect(container.firstElementChild?.tagName).toBe('SPAN')
rerender(<StateDot state="ongoing" />)
const ring = container.firstElementChild as SVGSVGElement
expect(ring.tagName).toBe('svg')
const circle = ring.querySelector('circle')
expect(circle?.getAttribute('stroke-width')).toBe('1')
expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/)
expect(ring.querySelector('linearGradient')).not.toBeNull()
const matrix = container.firstElementChild as SVGSVGElement
expect(matrix.tagName).toBe('svg')
const cells = matrix.querySelectorAll('rect')
expect(cells).toHaveLength(8)
// Chase phase: every cell carries its own negative animation delay.
const delays = [...cells].map(cell => (cell).style.animationDelay)
expect(new Set(delays).size).toBe(8)
})
it('sizes via the size prop in both shapes', () => {

View File

@@ -81,23 +81,20 @@ describe('Tooltip', () => {
expect(screen.getByRole('tooltip')).toBeTruthy()
})
it('keeps the bubble while either hover or focus is still active', () => {
it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => {
render(
<Tooltip label="Sticky">
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
// Focused AND hovered: leaving with the mouse must not drop the bubble.
// Focused AND hovered: leaving with the mouse drops the bubble at once.
fireEvent.focus(anchor)
fireEvent.mouseEnter(anchor)
fireEvent.mouseLeave(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.blur(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
// Symmetric: blurring while still hovered keeps it, mouseleave ends it.
// Re-entering shows it again; blurring while still hovered keeps it.
fireEvent.mouseEnter(anchor)
fireEvent.focus(anchor)
fireEvent.blur(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.mouseLeave(anchor)

View File

@@ -72,6 +72,10 @@
cursor: pointer;
}
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:disabled {
cursor: default;
}
@@ -80,18 +84,21 @@
flex: none;
}
/* Tool Call mode cubes share an 8px gap. */
/* Tool Call mode cubes share an 8px gap and wrap to one per row when the
panel is too narrow. */
.cubeRow {
display: flex;
align-items: stretch;
gap: 8px;
flex-wrap: wrap;
}
/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset =
* outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */
/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the
* 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10,
* vertical = inner pad 8). */
.modeCube {
box-sizing: border-box;
width: 418px;
flex: 1 1 276px;
display: flex;
flex-direction: column;
justify-content: center;
@@ -101,6 +108,11 @@
border-radius: 16px;
background: transparent;
text-align: left;
cursor: pointer;
}
.modeCube:hover:not(.selected) {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400

View File

@@ -44,7 +44,8 @@
white-space: nowrap;
}
/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */
/* Full-viewport layer (figma Mask 501:29946 #000@24%): mask tokens match the
Modal primitive (--dsw-alias-bg-mask-1 + --dsw-mask-blur). */
.overlay {
position: fixed;
inset: 0;
@@ -58,21 +59,22 @@
position: absolute;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
}
/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow
(figma effects match --dsw-shadow-lv3 exactly). */
/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects
match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */
.panel {
position: relative;
z-index: 1;
display: flex;
width: 1080px;
height: 700px;
width: 800px;
height: 600px;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 48px);
border-radius: 24px;
overflow: hidden;
background: var(--dsw-alias-bg-layer-1);
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
}

View File

@@ -10,7 +10,7 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps } from './contract/slots.ts'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
import css from './SettingsRoot.module.css'
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
@@ -20,7 +20,7 @@ function navIcon(id: string) {
}
type PanelProps = {
rows: ReturnType<SettingsRootComponentProps['sections']>
rows: readonly SettingsSectionRow[]
renderSlot: SettingsRootComponentProps['renderSlot']
onClose: () => void
}
@@ -92,20 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
* @returns the settings shell element tree.
*/
export function SettingsRoot(props: SettingsRootComponentProps) {
const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
const { wide, useSections, renderSlot } = props
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
// The ledger tick keeps the nav rows fresh: registrants re-register with
// freshly localized text on locale change, and the trigger/header/close
// seats re-render through their own outlets' subscriptions.
// State = ledger version: same-version notifications dedupe to no render.
const [, setSectionsRev] = useState(() => sectionsVersion())
useEffect(
() => subscribeSections(() => { setSectionsRev(sectionsVersion()) }),
[subscribeSections, sectionsVersion],
)
const rows = sections()
const rows = useSections(s => s)
return (
<>

View File

@@ -7,7 +7,7 @@
* setting never means editing the shell; copy that belongs to no single
* feature (chrome, the General section) is owned by ui-settings-general.
*/
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
// into every program that sees this contract.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps {
children?: never
}
/** One nav row projected from a settings.section registration's options. */
export interface SettingsSectionRow {
id: string
order: number
label: string
}
/**
* Registrant-private injected share of the settings shell (assembled in
* apply): ledger projections only — the shell reads no locale state.
* apply): the ledger's nav-row projection as a hooks-compartment source —
* the shell reads no locale state and subscribes through the bound hook.
*/
export type SettingsRootInjected = {
/** Read the settings.section ledger version (nav invalidation). */
sectionsVersion: () => number
/** Subscribe to settings.section ledger changes. */
subscribeSections: (listener: () => void) => () => void
/** Project the settings.section ledger into nav rows (id/order/label). */
sections: () => readonly { id: string; order: number; label: string }[]
hooks: {
/** settings.section ledger projected into ordered nav rows. */
sections: HostObservable<readonly SettingsSectionRow[]>
}
}
/**
* Full component props of the settings shell root: the sidebar owner share
* (wide/rail state) plus the declared render shares and the injected face.
* No store is registered — modal open state and active section id are
* component-local viewing state.
* (wide/rail state) plus the declared render shares and the injected face
* (hooks compartment bound to useSections). No store is registered — modal
* open state and active section id are component-local viewing state.
*/
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
& SettingsRootInjected
& InjectFace<SettingsRootInjected>

View File

@@ -10,12 +10,12 @@
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { SettingsRootInjected } from './contract/slots.ts'
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
export type {
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
/**
@@ -32,17 +32,31 @@ export const inject = ['slots']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
// Ledger → nav-row projection as an observable source (uSES contract:
// getSnapshot returns the cached rows until the ledger version moves).
let rowsVersion = -1
let rows: readonly SettingsSectionRow[] = []
const injected = (): SettingsRootInjected => ({
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
sections: () => ctx.slots.entries('settings.section')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
id: e.options.id ?? '',
order: e.options.order ?? 0,
label: e.options.label ?? '',
}))
.sort((a, b) => a.order - b.order),
hooks: {
sections: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.section')
if (version !== rowsVersion) {
rowsVersion = version
rows = ctx.slots.entries('settings.section')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
id: e.options.id ?? '',
order: e.options.order ?? 0,
label: e.options.label ?? '',
}))
.sort((a, b) => a.order - b.order)
}
return rows
},
subscribe: listener => ctx.slots.subscribe('settings.section', listener),
},
},
})
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>

View File

@@ -60,22 +60,25 @@ describe('ui-settings apply', () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(b.slots)
const { sections } = injectedOf(b.slots).hooks
// The shell ships no sections of its own — registrants fill the ledger.
expect(injected.sections()).toEqual([])
expect(sections.getSnapshot()).toEqual([])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
expect(injected.sections()).toEqual([
const rows = sections.getSnapshot()
expect(rows).toEqual([
{ id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' },
])
expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
// Snapshot identity is stable until the ledger moves (uSES contract).
expect(sections.getSnapshot()).toBe(rows)
const listener = vi.fn()
const off = injected.subscribeSections(listener)
const off = sections.subscribe(listener)
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalled()
expect(sections.getSnapshot()).not.toBe(rows)
off()
})

View File

@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
@@ -22,9 +23,9 @@ function mount({
{ id: 'models', order: 10, label: 'Models' },
],
}: { wide?: boolean; rows?: Row[] } = {}) {
// Mutable row store standing in for the ledger; bump() plays a change.
// Mutable row source standing in for the bound useSections hook; bump()
// plays a ledger change through the same observable contract.
let current = rows
let version = 0
const listeners = new Set<() => void>()
const renderSlot = vi.fn(
((key: string, _owner: unknown, opts?: { only?: string }) => {
@@ -38,19 +39,21 @@ function mount({
useSessions: unusedHook,
useWorkspaces: unusedHook,
wide,
sectionsVersion: () => version,
subscribeSections: (listener) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
useSections: (select) => {
const [, force] = useState(0)
useEffect(() => {
const listener = () => { force(n => n + 1) }
listeners.add(listener)
return () => { listeners.delete(listener) }
}, [])
return select(current)
},
sections: () => current,
renderSlot,
}
const view = render(<SettingsRoot {...props} />)
const bump = (next: Row[]) => {
act(() => {
current = next
version += 1
for (const fn of [...listeners]) fn()
})
}

View File

@@ -79,13 +79,19 @@
/* Brand group (figma I133:7632): the full wordmark rides the text ink
(figma-flows ruling: main-screen instance is black; blue is brand
emphasis only). */
emphasis only). A button only in behavior (New Session shortcut): the
pointer cursor is the sole affordance — no hover chrome on the mark. */
.brand {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
overflow: hidden;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.iconButton {

View File

@@ -61,10 +61,17 @@ export function SidebarRoot({
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
>
<div className={css.logoRow}>
{/* Expanded, the wordmark doubles as a New Session shortcut; the
collapsed rail's logo is the expand toggle below instead. */}
{wide && (
<span className={clsx(css.brand, css.wide)}>
<button
type="button"
className={clsx(css.brand, css.wide)}
aria-label="New session"
onClick={() => { startSession() }}
>
<BrandWordmark />
</span>
</button>
)}
{/* Rail resting state is the whale mark; hovering swaps in the panel
icon (the expand affordance, figma sidebar-hover flow). */}

View File

@@ -54,10 +54,13 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
}
describe('SidebarRoot shell', () => {
it('routes New Session and the column toggle', () => {
it('routes New Session (capsule + wordmark) and the column toggle', () => {
const b = mountShell()
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
expect(b.startSession).toHaveBeenCalledWith()
// Expanded, both the wordmark and the capsule start a session.
const starters = screen.getAllByRole('button', { name: 'New session' })
expect(starters).toHaveLength(2)
for (const button of starters) fireEvent.click(button)
expect(b.startSession).toHaveBeenCalledTimes(2)
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce()
})

View File

@@ -44,6 +44,21 @@ export function apply(ctx: ClientContext): void {
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
// the fiber effect below is its teardown boundary.
const fetches = new Map<SessionId, CatalogFetch>()
// Per-session lexicon invalidation listeners (subscribeLexicon consumers).
const lexiconListeners = new Map<SessionId, Set<() => void>>()
const notifyLexicon = (sessionId: SessionId): void => {
for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) {
try {
listener()
} catch (error) {
// Contain listener failures: settlement notifies from an ignored
// promise chain (a throw would surface as an unhandled rejection)
// and one faulty consumer must not starve the others.
console.error('[ui-skill] lexicon listener failed:', error)
}
}
}
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
const existing = fetches.get(sessionId)
@@ -58,7 +73,10 @@ export function apply(ctx: ClientContext): void {
fetches.set(sessionId, entry)
promise.then(
// Settled snapshot backs the synchronous lexicon reads.
(skills) => { entry.settled = skills },
(skills) => {
entry.settled = skills
notifyLexicon(sessionId)
},
// A failed fetch must not poison the key: the next consumer retries.
() => {
if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
@@ -72,6 +90,7 @@ export function apply(ctx: ClientContext): void {
if (entry === undefined) return
fetches.delete(key)
entry.abort.abort()
notifyLexicon(key)
}
const clearAll = (): void => {
@@ -97,6 +116,16 @@ export function apply(ctx: ClientContext): void {
lexicon(session) {
return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
},
subscribeLexicon(session, listener) {
const key = session.sessionId
const listeners = lexiconListeners.get(key) ?? new Set()
listeners.add(listener)
lexiconListeners.set(key, listeners)
return () => {
listeners.delete(listener)
if (listeners.size === 0) lexiconListeners.delete(key)
}
},
onPick({ candidate }) {
// Decision 21: plain-text reference — the literal lands in the draft
// and ships to the model verbatim (trailing space closes the token).

View File

@@ -208,6 +208,33 @@ describe('lexicon', () => {
// Another session's key is independent — cold until its own fetch.
expect(source.lexicon!(proj('s2'))).toBeUndefined()
})
it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => {
const { list } = countingList()
const { ctx, source } = await bench(list)
const s1 = vi.fn()
const s2 = vi.fn()
source.subscribeLexicon!(proj('s1'), s1)
source.subscribeLexicon!(proj('s2'), s2)
await source.candidates(proj('s1'), req(''))
expect(s1).toHaveBeenCalledTimes(1)
expect(s2).not.toHaveBeenCalled()
// Reset invalidates every cached session: each key notifies its own listeners.
await source.candidates(proj('s2'), req(''))
ctx.emit('connection/reset')
expect(s1).toHaveBeenCalledTimes(2)
expect(s2).toHaveBeenCalledTimes(2)
})
it('an unsubscribed lexicon listener stops receiving notifications', async () => {
const { list } = countingList()
const { source } = await bench(list)
const listener = vi.fn()
const off = source.subscribeLexicon!(proj('s1'), listener)
off()
await source.candidates(proj('s1'), req(''))
expect(listener).not.toHaveBeenCalled()
})
})
describe('pick and codec', () => {

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a
README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018
# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
输入触发管线插件:光标处的 `/``@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster并按会话 scope`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track``arbitrate``onSpace``adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed因此投影只含会话身份roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace``matchEnter` 钩子,第一个非 undefined 的应答胜出。
输入触发管线插件:光标处的 `/``@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster并按会话 scope`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track``arbitrate``onSpace``adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace``matchEnter` 钩子,第一个非 undefined 的应答胜出。
分层:`src/core/`T2是纯内核——`detectTrigger``menuReduce``seedGroups``MENU_CLOSED``exactMatch`,零 ReactDOMcordis`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。

View File

@@ -40,18 +40,35 @@ export interface SlashControllerDeps {
export class SlashController {
/** Menu state store (per-session; survives session switches, dies with the scope). */
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
/**
* Aggregated hot reference lexicon, grouped by trigger (decision 21):
* sources implementing the lexicon hook are polled with the session
* projection; undefined answers (roll not hot yet) are skipped; multiple
* sources on one trigger concatenate in registration order. A snapshot
* store because rolls change asynchronously (catalog settles, children
* spawn/exit) — render-side consumers subscribe instead of re-reading a
* mutable answer.
*/
readonly lexicon: SnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>> =
createSnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>>(new Map())
/** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
private hit: TriggerHit | null = null
private fetch: AbortController | null = null
private disposed = false
/** Per-source lexicon unsubscribers (sources without the hook never enter). */
private readonly lexiconOffs = new Map<SlashSource, () => void>()
constructor(private readonly deps: SlashControllerDeps) {
// Scope-birth prewarm: sessions are always agent-backed, so the one-time
// roster warm here replaces the projection-transition watch — there are
// no capability steps to react to.
const projection = this.project()
for (const src of deps.roster.all()) src.warm?.(projection)
for (const src of deps.roster.all()) {
src.warm?.(projection)
this.watchLexicon(src, projection)
}
this.refreshLexicon()
}
/**
@@ -220,6 +237,23 @@ export class SlashController {
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
this.reduce({ type: 'source-failed', generation: state.generation, source: source.name })
}
this.lexiconOffs.get(source)?.()
this.lexiconOffs.delete(source)
this.refreshLexicon()
}
/**
* Admit a source registered after this controller's birth (root registry
* change notification): warm it and fold its roll into the live lexicon —
* the constructor-time prewarm covers only the roster present at scope
* birth.
* @param source - the newly registered source.
*/
sourceAdded(source: SlashSource): void {
const projection = this.project()
source.warm?.(projection)
this.watchLexicon(source, projection)
this.refreshLexicon()
}
/** Scope teardown: close and abort (the service deletes the map entry). */
@@ -228,6 +262,8 @@ export class SlashController {
this.stopFetch()
this.reduce({ type: 'close' })
this.hit = null
for (const off of this.lexiconOffs.values()) off()
this.lexiconOffs.clear()
}
/** The session projection handed to sources (agent-backed identity; constant per scope). */
@@ -248,25 +284,33 @@ export class SlashController {
return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true
}
/**
* Aggregate the sources' plain-text reference lexicons (decision 21),
* grouped by trigger: sources implementing the hook are polled with the
* session projection (onSpace's poll pattern); undefined answers (roll not
* hot yet) are skipped; multiple sources on one trigger concatenate in
* registration order.
* @returns trigger → decorated-name roll for the decoration scan.
*/
lexicon(): ReadonlyMap<TriggerChar, readonly string[]> {
/** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */
private refreshLexicon(): void {
const projection = this.project()
const rolls = new Map<TriggerChar, readonly string[]>()
for (const src of this.deps.roster.all()) {
if (src.lexicon === undefined) continue
const names = src.lexicon(projection)
let names: readonly string[] | undefined
try {
names = src.lexicon(projection)
} catch (error) {
// A faulty source drops silently with a console record (the
// candidate-fetch failure policy); the refresh runs inside
// notification callbacks, where a throw would starve other consumers.
console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error)
continue
}
if (names === undefined) continue
const prev = rolls.get(src.trigger)
rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
}
return rolls
this.lexicon.set(rolls)
}
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
private watchLexicon(source: SlashSource, projection: ClientSessionContext): void {
if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
}
/** Launch the candidate fetch for one hit generation, superseding the previous one. */

View File

@@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract {
}
/**
* Register one trigger source.
* Register one trigger source. Live session controllers are notified so a
* source arriving after scope birth still warms and joins the lexicon.
* @param src - the source; (trigger, name) must be unique — duplicates throw.
* @returns the disposer (callers wrap registration in ctx.effect). Disposal
* while a controller shows the source's menu group drops that group.
@@ -49,6 +50,16 @@ export class SlashService extends Service implements SlashServiceContract {
throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
}
live.sources.push(src)
for (const controller of live.controllers.values()) {
try {
controller.sourceAdded(src)
} catch (error) {
// Contain faulty source callbacks (warm/subscribeLexicon): the
// registration must stand with a usable disposer and the remaining
// controllers must still be notified.
console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error)
}
}
return () => {
const at = live.sources.indexOf(src)
if (at < 0) return

View File

@@ -165,6 +165,16 @@ export interface SlashSource {
* (the render path must stay synchronous and side-effect free).
*/
lexicon?(session: ClientSessionContext): readonly string[] | undefined
/**
* Subscribe to changes of this source's {@link SlashSource.lexicon} answer
* for one session (backing data settled, invalidated, or refreshed). The
* controller re-polls lexicon on each notification; a source whose roll
* never changes after warm omits the hook.
* @param session - stable session projection.
* @param listener - invalidation callback.
* @returns unsubscribe.
*/
subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void
/** Reference codec; required for sources producing insert outcomes. */
readonly codec?: ReferenceCodec
}

View File

@@ -126,6 +126,18 @@ describe('registerSource', () => {
slash.registerSource(deferredSource('/', 'beta').source)
})
it('a source registered after controller birth warms in every live controller', async () => {
const { slash, mint } = await serviceBench()
const ca = slash.sessionOf(mint('a').actx)
const cb = slash.sessionOf(mint('b').actx)
const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
slash.registerSource(late.source)
expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
})
it('HMR shape: dispose of the registering fiber removes the source', async () => {
const { root, slash, mint } = await serviceBench()
const controller = slash.sessionOf(mint('a').actx)
@@ -513,7 +525,7 @@ describe('lexicon', () => {
skill,
lexSource('@', 'subagent', ['worker-1']),
])
const rolls = controller.lexicon()
const rolls = controller.lexicon.getSnapshot()
expect([...rolls.keys()]).toEqual(['/', '@'])
expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
expect(rolls.get('@')).toEqual(['worker-1'])
@@ -522,7 +534,7 @@ describe('lexicon', () => {
it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
expect(controller.lexicon().size).toBe(0)
expect(controller.lexicon.getSnapshot().size).toBe(0)
})
it('two sources on one trigger concatenate in registration order', () => {
@@ -531,10 +543,63 @@ describe('lexicon', () => {
lexSource('/', 'prompt', ['c']),
lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
])
const rolls = controller.lexicon()
const rolls = controller.lexicon.getSnapshot()
expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
expect(rolls.has('@')).toBe(false)
})
it('a source lexicon notification republishes the aggregated store', () => {
let roll: readonly string[] | undefined = undefined
let notify: (() => void) | undefined
const source: SlashSource = {
trigger: '/',
name: 'skill',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
lexicon: () => roll,
subscribeLexicon: (_session, listener) => {
notify = listener
return () => { notify = undefined }
},
}
const { controller } = controllerBench([source])
expect(controller.lexicon.getSnapshot().size).toBe(0)
const seen: number[] = []
controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
roll = ['commit-helper']
notify?.()
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
expect(seen).toEqual([1])
controller.dispose()
expect(notify).toBeUndefined()
})
it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
const { controller, sources } = controllerBench([])
expect(controller.lexicon.getSnapshot().size).toBe(0)
const warm = vi.fn()
const late: SlashSource = {
trigger: '/',
name: 'late',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
warm,
lexicon: () => ['fresh'],
}
sources.push(late)
controller.sourceAdded(late)
expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
})
it('a removed source leaves the aggregated lexicon', () => {
const src = lexSource('/', 'skill', ['gone'])
const { controller, sources } = controllerBench([src])
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
sources.splice(sources.indexOf(src), 1)
controller.sourceRemoved(src)
expect(controller.lexicon.getSnapshot().size).toBe(0)
})
})
describe('arbitrate', () => {

View File

@@ -14,6 +14,7 @@
* consumer merges keys in and the intersection is what keeps them string-typed.
* The rule fires on the empty-map view, not on real redundancy. */
import type { ReactNode } from 'react'
import type { HostObservable } from './renderer.ts'
import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
export * from './store.ts'
@@ -214,11 +215,40 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
*/
export type SlotComponent<P> = (props: P) => ReactNode
/**
* Registrant hooks compartment: bare observable sources (getSnapshot +
* subscribe pairs) supplied under the reserved `hooks` key of an inject
* face. The registrant-private twin of the `sessions.provide` hooks
* compartment: the renderer binds each source into a `use<Name>` selector
* hook, so the sources never reach the component and plugin-private reactive
* facts ride the same subscription machinery as the standard kit instead of
* hand-rolled component subscriptions.
*/
export type HooksSources = Record<string, HostObservable<unknown>>
/**
* Selector-hook share synthesized from a hooks compartment: each source
* `name` becomes a `use<Name>` selector hook over its snapshot type.
*/
export type PropsHooks<HS extends HooksSources> = {
[N in keyof HS & string as `use${Capitalize<N>}`]:
SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
}
/**
* The component-side view of an inject face: the reserved `hooks`
* compartment (when declared) arrives as bound `use<Name>` selector hooks;
* every other member passes through verbatim.
*/
export type InjectFace<I extends object> =
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
/**
* The four-share component props intersection: runtime share (SlotMap) +
* child-render share (children declaration) + store share (declared handle) +
* the registrant's injected business face. Each share derives from its single
* source of truth; components reference this composition, never re-type it.
* the registrant's injected business face (its hooks compartment bound, see
* {@link InjectFace}). Each share derives from its single source of truth;
* components reference this composition, never re-type it.
*/
export type ComposedProps<
K extends keyof SlotMap & string,
@@ -226,7 +256,7 @@ export type ComposedProps<
H,
I extends object,
M = never,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I & MatchedShare<SlotMap[K], M>
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
/**
* Inject factory parameter list, derived from the registration's declaration:

View File

@@ -105,18 +105,14 @@ export interface SlotRendererHost {
sessions: {
/** Session list source backing the useSessions standard hook. */
list: HostObservable<unknown>
/** Current-session source used by SessionProvider. */
current: HostObservable<string | undefined>
/** Resolve a definite session bundle, or undefined when the id is unknown. */
provideInfo(id: string): SessionProvideInfo | undefined
/**
* Resolve the current-session-optional standard props bundle. The result
* always carries the static provider roster, even when `id` is absent or
* cannot resolve to a live session.
* @param id - current session id, when selected.
* @returns the optional provide info.
* Atomic current-session provide projection used by SessionProvider:
* selection changes and provider-roster changes publish through this one
* source, so a stable current id cannot strand mounted entries on an
* obsolete hook/prop schema. Carries the static roster with sessionId
* undefined while no current session resolves.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo
provideInfo: HostObservable<SessionMaybeProvideInfo>
}
/** Workspace-side standard-kit sources. */
workspaces: {

View File

@@ -39,6 +39,10 @@ export function apply(ctx: ClientContext): void {
// The list snapshot is always warm — the full running-children roster.
return childLabels(session, '')
},
subscribeLexicon(_session, listener) {
// The roll derives from the list snapshot, so its change feed IS the list's.
return sessions.list.subscribe(listener)
},
onPick({ candidate }) {
// Decision 21: plain-text reference — the literal lands in the draft
// and ships to the model verbatim (trailing space closes the token).

View File

@@ -32,17 +32,31 @@ function sessionsWith(sessions: SessionSummary[]) {
const byId: Record<string, SessionSummary> = {}
for (const s of sessions) byId[s.id] = s
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
return { list: { getSnapshot: () => snapshot } }
const subs = new Set<() => void>()
return {
list: {
getSnapshot: () => snapshot,
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
},
notify: () => { for (const fn of [...subs]) fn() },
listenerCount: () => subs.size,
}
}
/** Boot the plugin over fake slash/sessions faces; returns the captured source. */
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
async function fullBench(sessions: SessionSummary[]) {
const ctx = new Context()
let captured: SlashSource | undefined
const face = sessionsWith(sessions)
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('sessions', sessionsWith(sessions))
ctx.provide('sessions', face)
await ctx.plugin({ inject: [...inject], apply }).await()
return captured!
return { source: captured!, face }
}
/** Source-only bench for the behavior-contract suites. */
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
return (await fullBench(sessions)).source
}
const FAMILY: SessionSummary[] = [
@@ -113,6 +127,19 @@ describe('lexicon', () => {
expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout'])
expect(source.lexicon!(proj('childless'))).toEqual([])
})
it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => {
const { source, face } = await fullBench(FAMILY)
let notified = 0
const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 })
expect(face.listenerCount()).toBe(1)
face.notify()
expect(notified).toBe(1)
off()
expect(face.listenerCount()).toBe(0)
face.notify()
expect(notified).toBe(1)
})
})
describe('pick and codec', () => {

View File

@@ -20,13 +20,15 @@
display: flex;
align-items: stretch;
gap: 8px;
flex-wrap: wrap;
}
/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered
* icon-over-label column, gap 4). */
* icon-over-label column, gap 4); flexed down from the figma width so all
* three sit on one row in the 800 panel, wrapping when narrower. */
.themeCube {
box-sizing: border-box;
width: 276px;
flex: 1 1 180px;
display: flex;
flex-direction: column;
align-items: center;
@@ -43,6 +45,10 @@
cursor: pointer;
}
.themeCube:hover:not(.selected) {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
* step has no alias-layer name). */
.selected {

View File

@@ -1,3 +1,6 @@
/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as
font-weight: 500 in this UI — non-variable webfonts snap intermediate
weights unpredictably across platforms. */
body {
--dsw-static-amber-100: rgb(254, 245, 231);
--dsw-static-amber-400: rgb(247, 173, 49);
@@ -20,7 +23,7 @@ body {
--dsw-static-deepseek-300: rgb(183, 200, 254);
--dsw-static-deepseek-400: rgb(103, 158, 254);
--dsw-static-deepseek-450: rgb(86, 134, 254);
--dsw-static-deepseek-500: rgb(57, 100, 254);
--dsw-static-deepseek-500: rgb(65, 118, 230);
--dsw-static-deepseek-50: rgb(237, 243, 254);
--dsw-static-deepseek-600: rgb(72, 104, 178);
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
@@ -95,7 +98,7 @@ body[data-ds-dark-theme] {
--dsw-static-deepseek-300: rgb(183, 200, 254);
--dsw-static-deepseek-400: rgb(103, 158, 254);
--dsw-static-deepseek-450: rgb(86, 134, 254);
--dsw-static-deepseek-500: rgb(57, 100, 254);
--dsw-static-deepseek-500: rgb(65, 118, 230);
--dsw-static-deepseek-50: rgb(237, 243, 254);
--dsw-static-deepseek-600: rgb(72, 104, 178);
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
@@ -302,7 +305,7 @@ body[data-ds-dark-theme] {
--dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600);
--dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600);
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-500);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-400);
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800);
--dsw-alias-state-error-primary: var(--dsw-static-red-400);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);

View File

@@ -129,6 +129,7 @@
reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. */
.rail .sectionHeader {
gap: 0;
padding-left: 0;
margin-bottom: 12px;
}

View File

@@ -265,6 +265,7 @@ export function WorkspaceBrowser({
// states; the menu anchors on this button).
const [wsPickerOpen, setWsPickerOpen] = useState(false)
const wsPlusRef = useRef<HTMLButtonElement>(null)
const composingRef = useRef(false)
// Rail search = expand + land in the search box: the flag arms before the
// expand request; once the shell flips wide the input mounts and takes focus.
@@ -358,7 +359,6 @@ export function WorkspaceBrowser({
className={css.iconButton}
aria-label="Create workspace"
onClick={() => {
if (!wide) expandSidebar()
setWsPickerOpen(v => !v)
}}
>
@@ -372,6 +372,8 @@ export function WorkspaceBrowser({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
createOnly
side="right"
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
@@ -459,9 +461,12 @@ export function WorkspaceBrowser({
aria-label="Workspace name"
autoFocus
disabled={renaming}
onFocus={(e) => { e.target.select() }}
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === 'Enter' && !composingRef.current) {
e.preventDefault()
confirmRename()
}

View File

@@ -5,7 +5,7 @@
* slot registration.
*/
import type { RefObject } from 'react'
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -37,6 +37,12 @@ export interface WorkspaceCreateFlowProps {
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
/** Only show create actions (open folder / create new), hide existing workspaces. */
createOnly?: boolean
/** Menu opening direction relative to the anchor. */
side?: 'bottom' | 'top' | 'right'
/** Currently active workspace (trailing check in the picker list). */
selectedId?: WorkspaceId | undefined
}
/**
@@ -52,6 +58,9 @@ export function WorkspaceCreateFlow({
pickDirectory,
onPick,
onClose,
createOnly = false,
side = 'bottom',
selectedId,
}: WorkspaceCreateFlowProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
@@ -65,21 +74,26 @@ export function WorkspaceCreateFlow({
const [modalError, setModalError] = useState<string | null>(null)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const composingRef = useRef(false)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
const items: MenuEntry[] = [
...workspaces.map(workspace => ({
const createEntries: MenuEntry[] = [
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
]
// With workspaces listed, the create actions pin below the scroll region
// (divider + always visible); otherwise they ARE the menu.
const pinCreate = !createOnly && workspaces.length > 0
const items: MenuEntry[] = pinCreate
? workspaces.map(workspace => ({
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
]
}))
: createEntries
const closeModal = (): void => {
if (creating) return
@@ -114,7 +128,7 @@ export function WorkspaceCreateFlow({
}
if (id === CREATE_NEW) {
onClose()
setWorkspaceName('workspace')
setWorkspaceName('')
setModalError(null)
setModalKind('create')
return
@@ -149,8 +163,11 @@ export function WorkspaceCreateFlow({
open={open}
anchor={null}
items={items}
{...pinCreate ? { footer: createEntries } : {}}
selectedId={selectedId}
onSelect={handleSelect}
onClose={onClose}
side={side}
portal
getAnchorRect={getAnchorRect}
/>
@@ -194,12 +211,15 @@ export function WorkspaceCreateFlow({
<input
className={css.modalInput}
value={workspaceName}
placeholder="Workspace name"
aria-label="New workspace name"
autoFocus
disabled={creating}
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
confirmCreate()
}
@@ -225,6 +245,7 @@ export function WorkspacePicker({
open,
anchorRef,
useWorkspaces,
selectedId,
onPick,
onClose,
createWorkspace,
@@ -237,6 +258,7 @@ export function WorkspacePicker({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
selectedId={selectedId}
onPick={onPick}
onClose={onClose}
/>

View File

@@ -263,25 +263,21 @@ describe('WorkspaceBrowser', () => {
}
})
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
const expandSidebar = vi.fn()
const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
rerender(b, { wide: true })
// The picker menu is open (anchored on the ); picking starts a session.
fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
expect(screen.queryByRole('menu')).toBeNull()
// Wide toggle: open and close without expand requests.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('menu')).toBeTruthy()
expect(expandSidebar).not.toHaveBeenCalled()
// createOnly: existing workspaces are not listed, only the create actions.
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
// Toggle: open and close in place.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(expandSidebar).toHaveBeenCalledTimes(1)
// Escape closes the picker through its own onClose.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})

View File

@@ -205,6 +205,8 @@ describe('WorkspacePicker', () => {
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
chooseItem('Create a new workspace')
// The name field starts empty (no prefill); a name is required to submit.
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')

View File

@@ -5,8 +5,8 @@
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined
const args: unknown[] = []
if (info !== undefined) args.push(info.sessionId)
if (actions !== undefined) args.push(actions)
return (inject as (...args: unknown[]) => InjectedProps)(...args)
return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
}
/**
* Bind an inject face's reserved `hooks` compartment (bare observable
* sources, see HooksSources) into `use<Name>` selector hooks — the
* registrant-private twin of the provide-bundle binding in standardKit.
* Runs once per cached inject result; hook identity rides observableHook's
* per-source cache.
*/
function bindInjectHooks(face: InjectedProps): InjectedProps {
const sources = face['hooks']
if (sources === undefined) return face
const { hooks: _hooks, ...rest } = face
const bound: InjectedProps = rest
for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
bound[hookName] = observableHook(source)
}
return bound
}
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {

View File

@@ -90,9 +90,9 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
*/
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost()
const id = observableHook(host.sessions.current)(s => s)
const info = observableHook(host.sessions.provideInfo)(s => s)
return (
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
<BindingContext.Provider value={info}>
{children}
</BindingContext.Provider>
)
@@ -107,17 +107,17 @@ export interface SessionProviderProps {
}
/**
* Framework-wired session area: subscribes to the host's current-session
* source, resolves the session cell, and remounts the body under
* `key={sessionId}` so a session switch rebuilds the session subtree. This
* dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
* branded type at the component boundary.
* Framework-wired session area: subscribes to the host's current provide
* source and remounts the body under `key={sessionId}` so a session switch
* rebuilds the session subtree. This dependency-inverted layer uses plain
* string ids; `PropsRuntime` applies the branded type at the component
* boundary.
*/
export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost()
const id = observableHook(host.sessions.current)(s => s)
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
const info = observableHook(host.sessions.provideInfo)(s => s)
const id = info.sessionId
if (id === undefined) return <>{empty?.() ?? null}</>
return (
<BindingContext.Provider value={info} key={id}>
{children(id)}

View File

@@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
/** Passthrough host over the real core (store/session seats unused here). */
function hostOver(core: SlotCore): SlotRendererHost {
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
return {
subscribe: (key, fn) => core.subscribe(key, fn),
getVersion: key => core.getVersion(key),
@@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost {
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
provideInfo: () => undefined,
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
},
workspaces: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },

View File

@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import { useEffect, type ReactNode } from 'react'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
type RenderOpts, type SessionProvideInfo,
@@ -85,7 +86,9 @@ function makeHost() {
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
const list = observable<{ ids: string[] }>({ ids: [] })
const workspaces = observable<{ ids: string[] }>({ ids: [] })
const current = observable<string | undefined>(undefined)
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} }
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
let currentId: string | undefined
const infos = new Map<string, SessionProvideInfo>()
const bump = (key: string) => {
@@ -123,10 +126,7 @@ function makeHost() {
},
sessions: {
list,
current,
provideInfo: id => infos.get(id),
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: {}, props: {} },
provideInfo: provide,
},
workspaces: { list: workspaces },
}
@@ -134,7 +134,14 @@ function makeHost() {
host,
list,
workspaces,
current,
// Same driver surface as the old current cell: set(id) publishes the
// resolved bundle (or the absent projection) through the provide source.
current: {
set: (id: string | undefined) => {
currentId = id
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
},
},
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
const entry = entryOf(partial)
@@ -161,6 +168,7 @@ function makeHost() {
props: {},
}
infos.set(id, info)
if (currentId === id) provide.set(info)
return info
},
}
@@ -740,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
expect(inject).toHaveBeenCalledWith()
})
it('binds the inject hooks compartment into use<Name> selector hooks (sources never reach the component)', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const badge = observable('cold')
const seen: Record<string, unknown>[] = []
h.add('k.single', {
component: (props: { useBadge?: <S>(sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => {
seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) })
return null
},
inject: () => ({ plain: 'kept', hooks: { badge } }),
})
mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
// The raw compartment is consumed by the binding; the plain member passes through.
expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' })
act(() => { badge.set('hot') })
expect(seen.at(-1)!['read']).toBe('hot')
})
it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)

View File

@@ -9,7 +9,7 @@
import { useEffect, useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, SessionProvider,
type SessionProvideInfo, type SlotRendererHost,
@@ -26,12 +26,14 @@ function observable<T>(initial: T) {
}
/**
* Minimal host: SessionProvider only reads sessions.current/cell, but it must
* Minimal host: SessionProvider only reads sessions.provideInfo, but it must
* render inside the renderer tree (HostContext), so the harness mounts a real
* root entry whose body is the test's render-prop provider.
*/
function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
const current = observable<string | undefined>(undefined)
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
let currentId: string | undefined
const infos = new Map<string, SessionProvideInfo>()
const sessionEntries: StoredEntry[] = []
const rootEntry: StoredEntry = {
@@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
storeOf: () => undefined,
sessions: {
list: observable<unknown>({ ids: [] }),
current,
provideInfo: id => infos.get(id),
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
provideInfo: provide,
},
workspaces: { list: observable<unknown>({ items: [] }) },
}
return {
host,
current,
// Same driver surface as the old current cell: set(id) publishes the
// resolved bundle (or the absent projection) through the provide source.
current: {
set: (id: string | undefined) => {
currentId = id
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
},
},
addSession: (id: string) => {
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
const info: SessionProvideInfo = {
@@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
props: {},
}
infos.set(id, info)
if (currentId === id) provide.set(info)
return info
},
/** Swap one session's bundle in place (roster-change stand-in); republish when current. */
replaceSession: (info: SessionProvideInfo) => {
infos.set(info.sessionId, info)
if (currentId === info.sessionId) provide.set(info)
},
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
}
}
@@ -149,6 +161,28 @@ describe('SessionProvider', () => {
expect(seen.at(-1)!['sessionId']).toBe('s2')
})
it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
const seen: unknown[] = []
const h = makeHost({
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
})
const original = h.addSession('s1')
h.registerSession({
component: (props: { feature?: string }) => {
seen.push(props.feature)
return null
},
options: {},
})
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)).toBeUndefined()
// A provider-roster change rematerializes the bundle; the provide source
// must carry it to already-mounted entries without a selection change.
act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
expect(seen.at(-1)).toBe('now-live')
})
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(

View File

@@ -21,6 +21,7 @@ function makeHost() {
const versions = new Map<string, number>()
const subs = new Map<string, Set<() => void>>()
const live = new Set<StoredEntry>()
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const bump = (key: string) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
for (const fn of [...(subs.get(key) ?? [])]) fn()
@@ -39,9 +40,7 @@ function makeHost() {
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
provideInfo: () => undefined,
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
},
workspaces: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },

View File

@@ -15,6 +15,10 @@ body,
body {
font-family: var(--dsw-font-family);
/* Grayscale antialiasing over subpixel rendering: WebKit/Blink and the
Firefox macOS equivalent; other engines ignore both lines. */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-base);
}