Merge remote-tracking branch 'origin/master' into worktree/multimodal-ui
This commit is contained in:
@@ -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: 7c835deb58db149710495f97a2553c3de58d99da
|
||||
README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09
|
||||
README.md: 69634d4ca577e9fa5c508a5fb2b50333290154b1
|
||||
README.zh.md: 9e03cc1903b5e9dc1d13e07bf8394a6e7aee9209
|
||||
|
||||
@@ -33,6 +33,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
|
||||
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.
|
||||
|
||||
## Pending queue projection
|
||||
|
||||
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
|
||||
|
||||
@@ -33,6 +33,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。
|
||||
|
||||
## 待处理队列投影
|
||||
|
||||
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。
|
||||
|
||||
@@ -324,8 +324,9 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
||||
* - `engaging`: a first prompt was attempted, but no accepted turn or other
|
||||
* authoritative activity signal has arrived — the UI keeps the composer
|
||||
* visible through admission and error frames.
|
||||
* - `active`: the session is non-blank beyond its pending first prompt, is
|
||||
* running, or owns a pending interaction — the ordinary conversation view.
|
||||
* - `active`: the session is non-blank beyond its pending first prompt,
|
||||
* contains visible non-command Chat content, is running, or owns a pending
|
||||
* interaction — the ordinary conversation view.
|
||||
*
|
||||
* A failed first prompt stays `engaging` (composer + error strip — retry
|
||||
* semantics; returning to the hero would discard the error context).
|
||||
|
||||
@@ -741,7 +741,8 @@ export class Session implements SessionFace {
|
||||
? null
|
||||
: { address: this.address, parentAvailable: this.parentAvailable },
|
||||
composerPhase: derivePhase(
|
||||
(!this.blankBit && !this.firstPromptPendingTurn)
|
||||
hasVisibleConversationContent(chat)
|
||||
|| (!this.blankBit && !this.firstPromptPendingTurn)
|
||||
|| this.running
|
||||
|| this.pendingCache.value.length > 0,
|
||||
this.promptAttempted,
|
||||
@@ -774,13 +775,18 @@ function conversationInput(entry: HistoryEntry): ConversationEventInput {
|
||||
return { event: entry.event, view: entry.view }
|
||||
}
|
||||
|
||||
/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */
|
||||
function hasVisibleConversationContent(chat: ChatSnapshot): boolean {
|
||||
return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command')
|
||||
}
|
||||
|
||||
/**
|
||||
* The composerPhase judgment — the single site that knows the predicate
|
||||
* (consumers switch on the result, never re-derive). A failed first prompt
|
||||
* stays engaging until an authoritative accepted-turn, running, or pending
|
||||
* signal arrives (retry semantics — see ComposerPhase).
|
||||
* @param hasContent - authoritative non-blank activity beyond a pending first
|
||||
* prompt, a running turn, or a pending interaction.
|
||||
* prompt, visible non-command Chat content, a running turn, or a pending interaction.
|
||||
* @param promptAttempted - a prompt was initiated on this session object.
|
||||
* @returns the derived phase.
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import type {
|
||||
@@ -132,7 +133,11 @@ const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
|
||||
if (context.state === undefined || context.start === undefined) return null
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'runtime-test-event',
|
||||
kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal'
|
||||
? 'command-input'
|
||||
: context.start.event.type === 'command/run' || context.start.event.type === 'command/done'
|
||||
? 'command'
|
||||
: 'runtime-test-event',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.start.event.seq,
|
||||
@@ -272,6 +277,24 @@ describe('live event path', () => {
|
||||
expect(snapshot.composerPhase).toBe('blank')
|
||||
})
|
||||
|
||||
it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => {
|
||||
const { session } = await opened([])
|
||||
session.handleBlank(true)
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
feed(ev.commandRun(0, 'cmd-goal', 'goal', ' '))
|
||||
feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.'))
|
||||
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
blank: true,
|
||||
composerPhase: 'active',
|
||||
})
|
||||
expect(session.getSnapshot().chat.order.map(
|
||||
key => session.getSnapshot().chat.nodes.get(key)?.kind,
|
||||
)).toContain('command-input')
|
||||
})
|
||||
|
||||
it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
|
||||
@@ -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/ui-goal/README.md
|
||||
README.md: f0446aa0637bc181f7fdc22e5d0d3192e0ac20cf
|
||||
README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108
|
||||
README.md: c79d6f5a68f1b4b40f4b57f5745feeed63a25fcd
|
||||
README.zh.md: c2d000dd8141a989c67f2e8dc6786ed2b5067a6b
|
||||
|
||||
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
|
||||
The plugin separately projects each durable `/goal` `command/run` through its own Conversation Definition. It builds a `command-input` Chat Node before the generic command result Node and registers that Node's keyed renderer as a right-aligned 14px/22px monospace user-style bubble with the localized group name `Command input` / `命令输入` and no timestamp, copy, or branch actions. The visible non-command Node activates fresh Chat; reload reconstructs it from the run, while a history window containing only `command/done` keeps only the generic result row. This projection never creates `user/message` or a model turn.
|
||||
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.remote.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
|
||||
该插件还会通过自有 Conversation Definition 投影每条持久 `/goal` `command/run`。它在通用命令结果 Node 之前构建一个 `command-input` Chat Node,并为该 Node 注册 keyed renderer;renderer 将其呈现为右对齐、使用 14px/22px 等宽字体的用户样式气泡,使用本地化分组名称 `Command input`/`命令输入`,且不含时间戳、复制或分支操作。可见的非命令 Node 会激活新 Chat;重新加载时会根据 run 重建该 Node,而仅包含 `command/done` 的历史窗口只保留通用结果行。该投影绝不会创建 `user/message` 或模型轮次。
|
||||
|
||||
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
@@ -65,6 +66,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
min-width: 0;
|
||||
max-width: min(525px, 82%);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 100%;
|
||||
padding: 10px 16px;
|
||||
overflow-wrap: anywhere;
|
||||
border-radius: 22px;
|
||||
background: var(--dsw-specific-bubble);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-markdown-code);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
30
packages/client/ui-goal/src/client/GoalCommandInputView.tsx
Normal file
30
packages/client/ui-goal/src/client/GoalCommandInputView.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { memo } from 'react'
|
||||
import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { GoalCommandInputData } from './goal-command-input.ts'
|
||||
import css from './GoalCommandInputView.module.css'
|
||||
|
||||
type GoalCommandInputViewProps =
|
||||
PropsRuntime<'conversation.chat.node', 'command-input'>
|
||||
& PropsLocale<'goal'>
|
||||
|
||||
/** Right-aligned `/goal` input bubble without ordinary message actions. */
|
||||
export const GoalCommandInputView = memo(function GoalCommandInputView({
|
||||
node, t,
|
||||
}: GoalCommandInputViewProps) {
|
||||
const data: GoalCommandInputData = node.data
|
||||
return (
|
||||
<div
|
||||
className={css.row}
|
||||
data-command-input=""
|
||||
role="group"
|
||||
aria-label={t('commandInput.aria')}
|
||||
>
|
||||
<div className={css.stack}>
|
||||
<div className={css.bubble}>
|
||||
<MessageText text={data.text} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
71
packages/client/ui-goal/src/client/goal-command-input.ts
Normal file
71
packages/client/ui-goal/src/client/goal-command-input.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import type {
|
||||
ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Goal-owned human command input projected independently of model messages. */
|
||||
export interface GoalCommandInputData {
|
||||
readonly commandId: CommandId
|
||||
readonly text: string
|
||||
readonly time: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Human-entered `/goal` command input. */
|
||||
'command-input': GoalCommandInputData
|
||||
}
|
||||
}
|
||||
|
||||
interface GoalCommandInputState extends GoalCommandInputData {
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the visible command line from its structured durable run.
|
||||
* @param event - `/goal` command run.
|
||||
* @returns command text with trailing parser whitespace removed.
|
||||
*/
|
||||
export function goalCommandText(event: SessionEvent<'command/run'>): string {
|
||||
return `/${event.data.name}${(event.data.args ?? '').trimEnd()}`
|
||||
}
|
||||
|
||||
/** Goal-owned command input projection; the generic command Definition retains the result row. */
|
||||
export const goalCommandInputDefinition: ConversationNodeDefinition<GoalCommandInputState> = {
|
||||
kind: 'goal-command-input',
|
||||
target: 'chat',
|
||||
match: event => event.type === 'command/run' && event.data.name === 'goal'
|
||||
? { id: String(event.data.commandId), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'command/run') {
|
||||
throw new Error('goal-command-input start requires command/run')
|
||||
}
|
||||
return {
|
||||
commandId: match.event.data.commandId,
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
text: goalCommandText(match.event),
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
if (context.state === undefined) return null
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'command-input',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.state.seq - 0.1,
|
||||
location: context.start?.location ?? { kind: 'unresolved' },
|
||||
visibility: 'visible',
|
||||
data: {
|
||||
commandId: context.state.commandId,
|
||||
text: context.state.text,
|
||||
time: context.state.time,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client'
|
||||
import type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
import { GoalDock } from './GoalBar.tsx'
|
||||
import { GoalCommandInputView } from './GoalCommandInputView.tsx'
|
||||
import { goalCommandInputDefinition } from './goal-command-input.ts'
|
||||
import { en, zh, type GoalKey } from './locales.ts'
|
||||
|
||||
export { GoalBar, GoalDock } from './GoalBar.tsx'
|
||||
@@ -35,8 +37,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'goal'
|
||||
|
||||
/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */
|
||||
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale']
|
||||
/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */
|
||||
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents']
|
||||
|
||||
/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */
|
||||
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
|
||||
@@ -68,8 +70,15 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.conversationEvents.register(goalCommandInputDefinition)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
|
||||
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'command-input',
|
||||
locale: NS,
|
||||
}, GoalCommandInputView))
|
||||
|
||||
const sessions = ctx.sessions
|
||||
|
||||
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
|
||||
|
||||
@@ -6,6 +6,7 @@ export const zh = {
|
||||
'phase.paused': '已暂停的目标',
|
||||
'phase.blocked': '受阻的目标',
|
||||
'objective.aria': '目标内容',
|
||||
'commandInput.aria': '命令输入',
|
||||
'action.save': '保存目标',
|
||||
'action.cancel': '取消编辑',
|
||||
'action.pause': '暂停目标',
|
||||
@@ -23,6 +24,7 @@ export const en = {
|
||||
'phase.paused': 'Paused Goal',
|
||||
'phase.blocked': 'Blocked Goal',
|
||||
'objective.aria': 'Goal objective',
|
||||
'commandInput.aria': 'Command input',
|
||||
'action.save': 'Save goal',
|
||||
'action.cancel': 'Cancel edit',
|
||||
'action.pause': 'Pause goal',
|
||||
|
||||
@@ -15,6 +15,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-runtime/src/client/conversation/event-registry.ts'
|
||||
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -52,6 +53,7 @@ async function bench(options: {
|
||||
} = {}) {
|
||||
const ctx = new Context()
|
||||
const calls: { method: string; args: unknown[] }[] = []
|
||||
const conversationEvents = new ConversationEventRegistry(ctx)
|
||||
function answer<T>(method: string, value: T) {
|
||||
return (...args: unknown[]) => {
|
||||
calls.push({ method, args })
|
||||
@@ -85,7 +87,10 @@ async function bench(options: {
|
||||
})
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
|
||||
name: 'root', children: {
|
||||
'conversation.input.dock': { kind: 'list', scope: 'session' },
|
||||
'conversation.chat.node': { kind: 'keyed', scope: 'session' },
|
||||
},
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
ctx.provide('sessions', {
|
||||
@@ -103,6 +108,7 @@ async function bench(options: {
|
||||
ctx,
|
||||
fiber,
|
||||
calls,
|
||||
definitions: () => conversationEvents.entries(),
|
||||
remountGoals: () => { activeGoals = goals('remounted-goals') },
|
||||
unmountGoals: () => { activeGoals = undefined },
|
||||
entry: () => {
|
||||
@@ -114,15 +120,19 @@ async function bench(options: {
|
||||
inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined,
|
||||
}
|
||||
},
|
||||
chatEntry: () => ctx.slots.entries('conversation.chat.node')[0],
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
expect(b.definitions().map(definition => definition.kind)).toEqual(['goal-command-input'])
|
||||
expect(b.chatEntry()?.options).toMatchObject({ key: 'command-input' })
|
||||
expect(b.chatEntry()?.locale).toBe('goal')
|
||||
})
|
||||
|
||||
it('verbs read the CAS ref from the current projected value at call time', async () => {
|
||||
@@ -199,8 +209,12 @@ describe('ui-goal browser plugin', () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toBeDefined()
|
||||
expect(b.chatEntry()).toBeDefined()
|
||||
expect(b.definitions()).toHaveLength(1)
|
||||
await b.fiber.dispose()
|
||||
expect(b.entry()).toBeUndefined()
|
||||
expect(b.chatEntry()).toBeUndefined()
|
||||
expect(b.definitions()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
134
packages/client/ui-goal/tests/goal-command-input.spec.tsx
Normal file
134
packages/client/ui-goal/tests/goal-command-input.spec.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatSnapshot, ConversationEventInput,
|
||||
ConversationNodeDefinition, ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts'
|
||||
import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx'
|
||||
import {
|
||||
goalCommandInputDefinition, goalCommandText,
|
||||
} from '../src/client/goal-command-input.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
class TestEventDefinitions {
|
||||
entries(): readonly ConversationNodeDefinition[] {
|
||||
return [commandDefinition, goalCommandInputDefinition]
|
||||
}
|
||||
|
||||
fallbackEntry(): undefined {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[] {
|
||||
return [chatViewDefinition]
|
||||
}
|
||||
}
|
||||
|
||||
function entry(seq: number, type: string, data: unknown): ConversationEventInput {
|
||||
return {
|
||||
event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'],
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(entries: readonly ConversationEventInput[], hasMore = false): ChatSnapshot {
|
||||
const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
assembler.replaceWindow(entries, hasMore)
|
||||
assembler.flush()
|
||||
const value = assembler.snapshot('chat') as ChatSnapshot | undefined
|
||||
if (value === undefined) throw new Error('chat view was not registered')
|
||||
return value
|
||||
}
|
||||
|
||||
function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
|
||||
return value.nodes.values().find(candidate => candidate.kind === kind)
|
||||
}
|
||||
|
||||
describe('goal command input projection', () => {
|
||||
it('builds a separate input Node before the generic command result and restores it on replay', () => {
|
||||
const run = entry(1, 'command/run', {
|
||||
commandId: 'command-goal', name: 'goal', args: ' ', source: { kind: 'user' },
|
||||
})
|
||||
const done = entry(2, 'command/done', {
|
||||
commandId: 'command-goal', kind: 'success', text: 'No goal is currently set.',
|
||||
})
|
||||
const value = snapshot([run, done])
|
||||
|
||||
expect(value.order.map(key => value.nodes.get(key)?.kind)).toEqual(['command-input', 'command'])
|
||||
expect(node(value, 'command-input')).toMatchObject({
|
||||
anchorSeq: 0.9,
|
||||
data: { commandId: 'command-goal', text: '/goal' },
|
||||
})
|
||||
expect(node(value, 'command')?.data).toMatchObject({
|
||||
name: 'goal', args: ' ', outcome: { kind: 'success', text: 'No goal is currently set.' },
|
||||
})
|
||||
|
||||
const doneOnly = snapshot([done], true)
|
||||
expect(node(doneOnly, 'command-input')).toBeUndefined()
|
||||
expect(node(doneOnly, 'command')?.data).toMatchObject({ name: null, args: null })
|
||||
})
|
||||
|
||||
it('ignores other commands and preserves internal multiline arguments', () => {
|
||||
const plan = entry(1, 'command/run', {
|
||||
commandId: 'command-plan', name: 'plan', args: '', source: { kind: 'user' },
|
||||
})
|
||||
const goal = entry(2, 'command/run', {
|
||||
commandId: 'command-goal', name: 'goal', args: '\nfirst line\nsecond line \n', source: { kind: 'user' },
|
||||
})
|
||||
|
||||
expect(goalCommandInputDefinition.match(plan.event)).toBeNull()
|
||||
expect(goalCommandText(goal.event as SessionEvent<'command/run'>))
|
||||
.toBe('/goal\nfirst line\nsecond line')
|
||||
})
|
||||
|
||||
it('keeps the Definition total across required interface and window fallback paths', () => {
|
||||
const run = entry(3, 'command/run', {
|
||||
commandId: 'command-goal', name: 'goal', source: { kind: 'user' },
|
||||
})
|
||||
const match = {
|
||||
...run,
|
||||
role: 'start' as const,
|
||||
location: { kind: 'session' as const },
|
||||
}
|
||||
const state = goalCommandInputDefinition.start({} as never, match, {} as never)
|
||||
|
||||
expect(state.text).toBe('/goal')
|
||||
expect(goalCommandInputDefinition.update({ state } as never, match)).toBe(state)
|
||||
expect(goalCommandInputDefinition.buildViewNode!({ state: undefined } as never)).toBeNull()
|
||||
expect(goalCommandInputDefinition.buildViewNode!({
|
||||
key: 'goal-command-input', id: 'command-goal', state, start: undefined,
|
||||
} as never)).toMatchObject({ location: { kind: 'unresolved' } })
|
||||
|
||||
const done = entry(4, 'command/done', { commandId: 'command-goal', kind: 'success' })
|
||||
expect(() => goalCommandInputDefinition.start({} as never, {
|
||||
...done, role: 'start', location: { kind: 'session' },
|
||||
} as never, {} as never)).toThrow('goal-command-input start requires command/run')
|
||||
})
|
||||
|
||||
it('renders the user-style command bubble without ordinary message actions', () => {
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const props = {
|
||||
node: {
|
||||
key: 'goal-command-input:one',
|
||||
data: { commandId: 'command-goal', text: '/goal ship it', time: 1_700_000_000_000 },
|
||||
},
|
||||
t,
|
||||
} as unknown as Parameters<typeof GoalCommandInputView>[0]
|
||||
const view = render(<GoalCommandInputView {...props} />)
|
||||
const bubble = view.getByRole('group', { name: '命令输入' })
|
||||
|
||||
expect(bubble.textContent).toBe('/goal ship it')
|
||||
expect(within(bubble).queryByRole('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
|
||||
@@ -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/ui-theme/README.md
|
||||
README.md: cab9961a6d703d600a856e71339cda7062d20d62
|
||||
README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739
|
||||
README.md: df4d5e0370962bf6f2a8ac0a7b88d669225dc5c5
|
||||
README.zh.md: e0f614645a16b44e374e450bb8dd1e3c5805ea42
|
||||
|
||||
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
|
||||
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
|
||||
|
||||
When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `<body>` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeService and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates.
|
||||
|
||||
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||
|
||||
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
|
||||
|
||||
当主机组合包含 HTTP 服务器时,主机侧紧接 `<body>` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeService 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。
|
||||
|
||||
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||
|
||||
滚动条重新绑定约定:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-theme",
|
||||
"description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row",
|
||||
"description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeService for light/dark/system state; --dsw-* token styles and Appearance settings row",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
@@ -48,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
@@ -58,6 +59,7 @@
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
|
||||
40
packages/client/ui-theme/src/boot-theme.ts
Normal file
40
packages/client/ui-theme/src/boot-theme.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Host-rendered theme bootstrap for the browser's pre-plugin interval. Each
|
||||
* index response embeds the current durable built-in preference; the browser
|
||||
* resolves only `system`, then writes the same DOM fields ui-layout's
|
||||
* ThemePresenter owns after the client plugin tree activates.
|
||||
*/
|
||||
|
||||
import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts'
|
||||
|
||||
/** Build the inline script for one schema-validated built-in preference. */
|
||||
function bootThemeScript(preference: ThemePreference): string {
|
||||
return `<script>(() => {
|
||||
const preference = ${JSON.stringify(preference)}
|
||||
const systemDark = preference === 'system'
|
||||
&& typeof matchMedia !== 'undefined'
|
||||
&& matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const dark = preference === 'dark' || systemDark
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
|
||||
document.body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
})()</script>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the theme bootstrap immediately after the opening body tag, before
|
||||
* the shell mount and module script. Body-less fragments receive it at the
|
||||
* end, where the HTML parser has already synthesized a body.
|
||||
* @param html - Raw application index HTML.
|
||||
* @param preference - Current Host-backed built-in preference.
|
||||
* @returns HTML containing the theme bootstrap.
|
||||
*/
|
||||
export function injectBootTheme(
|
||||
html: string,
|
||||
preference: ThemePreference = DEFAULT_PREFERENCE,
|
||||
): string {
|
||||
const script = bootThemeScript(preference)
|
||||
const body = /<body(?:\s[^>]*)?>/i.exec(html)
|
||||
if (body === null) return `${html}${script}`
|
||||
const at = body.index + body[0].length
|
||||
return `${html.slice(0, at)}${script}${html.slice(at)}`
|
||||
}
|
||||
@@ -1,23 +1,43 @@
|
||||
/** Host registration for the browser theme preference. */
|
||||
/** Host registration for the browser theme preference and pre-plugin palette. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts'
|
||||
import { injectBootTheme } from './boot-theme.ts'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
const THEME_NAMESPACE = settingsNamespace(THEME_SETTINGS_NAMESPACE)
|
||||
|
||||
/** Read the registered preference or use the schema default without a settings provider. */
|
||||
function readPreference(ctx: Context): ThemePreference {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) return DEFAULT_PREFERENCE
|
||||
const section = settings.get(THEME_NAMESPACE) as ThemeSettings | undefined
|
||||
if (section === undefined) return DEFAULT_PREFERENCE
|
||||
return section.preference
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the durable theme section when a settings provider exists.
|
||||
* @param ctx - Host context whose optional settings service owns the section.
|
||||
* Register the durable theme section and initial-theme index transform when
|
||||
* their optional Host services are composed.
|
||||
* @param ctx - Host context that may acquire settings and HTTP services.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(THEME_SETTINGS_NAMESPACE),
|
||||
ThemeSettingsSchema,
|
||||
settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema)
|
||||
})
|
||||
ctx.inject(['httpServer'], (httpCtx) => {
|
||||
httpCtx.effect(
|
||||
() => httpCtx.httpServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))),
|
||||
'client-ui-theme: initial theme bootstrap',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
71
packages/client/ui-theme/tests/boot-theme.spec.ts
Normal file
71
packages/client/ui-theme/tests/boot-theme.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Host index injection and the resulting pre-plugin browser theme. */
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { injectBootTheme } from '../src/boot-theme.ts'
|
||||
import type { ThemePreference } from '../src/theme-settings.ts'
|
||||
|
||||
const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
||||
|
||||
function mockSystemDark(matches: boolean): void {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
|
||||
}
|
||||
|
||||
function executeBootstrap(
|
||||
preference?: ThemePreference,
|
||||
html = '<html><body><div id="root"></div><script type="module"></script></body></html>',
|
||||
): string {
|
||||
const injected = injectBootTheme(html, preference)
|
||||
const source = /<script>([\s\S]*?)<\/script>/.exec(injected)?.[1]
|
||||
if (source === undefined) throw new Error('theme bootstrap script missing')
|
||||
runInNewContext(source, { document, matchMedia: globalThis.matchMedia })
|
||||
return injected
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
document.body.removeAttribute(DARK_ATTRIBUTE)
|
||||
})
|
||||
|
||||
describe('theme boot index transform', () => {
|
||||
it('runs immediately inside the body before the shell mount', () => {
|
||||
mockSystemDark(false)
|
||||
const html = executeBootstrap('dark', '<html><body class="app"><div id="root"></div></body></html>')
|
||||
expect(html.indexOf('<script>')).toBeGreaterThan(html.indexOf('<body class="app">'))
|
||||
expect(html.indexOf('<script>')).toBeLessThan(html.indexOf('<div id="root">'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets durable light override a dark OS and clears stale dark state', () => {
|
||||
document.body.setAttribute(DARK_ATTRIBUTE, '')
|
||||
mockSystemDark(true)
|
||||
executeBootstrap('light')
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, 'dark', true],
|
||||
[false, 'light', false],
|
||||
] as const)('resolves system=%s to %s', (matches, colorScheme, dark) => {
|
||||
mockSystemDark(matches)
|
||||
executeBootstrap('system')
|
||||
expect(document.documentElement.style.colorScheme).toBe(colorScheme)
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(dark)
|
||||
})
|
||||
|
||||
it('defaults to system and falls back to light when matchMedia is unavailable', () => {
|
||||
vi.stubGlobal('matchMedia', undefined)
|
||||
executeBootstrap()
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
it('appends the script to a body-less fragment', () => {
|
||||
const html = injectBootTheme('<main>loading</main>', 'dark')
|
||||
expect(html.startsWith('<main>loading</main><script>')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
|
||||
@@ -27,4 +28,38 @@ describe('ui-theme host', () => {
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
|
||||
it('renders the current durable preference and disposes the index transform', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
let disposed = false
|
||||
ctx.provide('httpServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => { disposed = true }
|
||||
},
|
||||
} as HttpServerService)
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark' })
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "dark"')
|
||||
await fiber.dispose()
|
||||
expect(disposed).toBe(true)
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
})
|
||||
|
||||
it('uses the system preference when only an HTTP server exists', async () => {
|
||||
const ctx = new Context()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
ctx.provide('httpServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => undefined
|
||||
},
|
||||
} as HttpServerService)
|
||||
await ctx.plugin({ apply }).await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('invariant companion', () => {
|
||||
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half waits for an optional settings provider', () => {
|
||||
it('node-half waits for optional Host services', () => {
|
||||
nodeApply(new Context())
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user