• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
@@ -18,13 +18,26 @@ import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** The built-in render-tree root hole (seeded by SlotCore): rendered only by the shell, occupied by a layout entry. */
|
||||
/**
|
||||
* The built-in render-tree root hole (seeded by SlotCore): the one slot the
|
||||
* shell itself renders, and the ancestor of every other seat. OCCUPIED by
|
||||
* ui-layout's AppFrame, which declares the sidebar, conversation, details,
|
||||
* and shell.overlay seats inside it.
|
||||
*
|
||||
* DO NOT register here. This is a single slot, so a second entry does not
|
||||
* sit beside the frame — it shadows it, and a dynamically registered entry
|
||||
* is assigned a lower priority than the shipped one, which makes it the
|
||||
* winner: the page would render your component alone, with every seat the
|
||||
* frame declares gone. For a surface of your own that floats over the whole
|
||||
* app, register into `shell.overlay` instead (a list slot: additive, and
|
||||
* click-through until your entry opts into pointer events).
|
||||
*/
|
||||
'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps }
|
||||
}
|
||||
}
|
||||
@@ -274,6 +287,43 @@ export class SlotRegistry extends Service {
|
||||
return this._core.entries(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shadowing winners per cell for a key: the first live (non-abdicated)
|
||||
* entry of each cell in priority order — what outlets render; chain keys
|
||||
* pass through unchanged (election consumes every entry). The raw
|
||||
* {@link SlotsService.entries} view stays the inspection surface. Fresh
|
||||
* array per call, not a uSES getSnapshot source.
|
||||
* @param key - SlotMap key.
|
||||
* @returns the winning entry per occupied cell.
|
||||
*/
|
||||
entriesOfSlot(key: keyof SlotMap & string): readonly StoredEntry[] {
|
||||
return this._core.entriesOfSlot(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current JSON-safe Slot declaration tree for read-only inspection.
|
||||
* @param root - exact live Slot root; omitted returns all roots.
|
||||
* @returns selected Slot trees.
|
||||
*/
|
||||
snapshot(root?: string): LiveSlotNode[] {
|
||||
return this._core.snapshot(root)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe entry boundary crashes (every render-time entry failure the
|
||||
* boundaries contain, abdicating or not) — the supervision seam for
|
||||
* plugins mirroring contribution health. Fires synchronously per report,
|
||||
* after the registry mutated for abdicating crashes. Callers own the
|
||||
* disposer (wire it through ctx.effect for fiber-lifetime cleanup, as with
|
||||
* {@link SlotsService.subscribe}).
|
||||
* @param fn - called with the slot key, the crashed entry, the crash
|
||||
* cause, and `abdicated`: whether the crash retired the entry from its cell.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
onEntryError(fn: (key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void): () => void {
|
||||
return this._core.onEntryError(fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a declared spec (register-declared or the built-in 'root').
|
||||
* @param key - SlotMap key.
|
||||
@@ -353,6 +403,8 @@ export class SlotRegistry extends Service {
|
||||
subscribe: (key, fn) => this._core.subscribe(key, fn),
|
||||
getVersion: key => this._core.getVersion(key),
|
||||
entriesOf: key => this._core.entries(key),
|
||||
entriesOfSlot: key => this._core.entriesOfSlot(key),
|
||||
reportEntryError: (key, entry, error, info) => { this._core.reportEntryError(key, entry, error, info) },
|
||||
specOf: key => this._core.specDynamic(key),
|
||||
isLive: entry => this._core.isLive(entry),
|
||||
storeOf: (entry, scopeKey) =>
|
||||
|
||||
@@ -33,16 +33,32 @@ export interface ComposerAttachment {
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* Strict-session body inside the resident conversation scrollport. It
|
||||
* owns the per-session draft mirror and active view ring.
|
||||
* The entire body of one session: taking this seat means rendering that
|
||||
* session's conversation yourself. The occupant also owns the per-session
|
||||
* draft mirror and the active view ring, so a replacement inherits both
|
||||
* duties and an empty one leaves a blank session pane — nothing here
|
||||
* degrades gracefully. To ADD rather than replace, take a seat inside the
|
||||
* flow instead: `conversation.view` for a whole tab, the input regions for
|
||||
* composer chrome.
|
||||
*/
|
||||
'conversation.session': { kind: 'single'; scope: 'session' }
|
||||
/** Strict-session header above the resident conversation scrollport. */
|
||||
/**
|
||||
* The strip above the session's scrollport: title, view tabs, and the
|
||||
* action row. Taking this seat means rendering all three yourself, and it
|
||||
* also collapses `conversation.session.header.actions` — that additive
|
||||
* seat is declared by whoever occupies this one, so replacing the header
|
||||
* takes every action entry down with it.
|
||||
*/
|
||||
'conversation.session.header': { kind: 'single'; scope: 'session' }
|
||||
/**
|
||||
* Session-header actions contributed by feature plugins. Entries render
|
||||
* by ascending `order`; negative values are reserved for static session
|
||||
* context that precedes interactive actions.
|
||||
* One button in the session header's action row — the additive way to put
|
||||
* a per-session control beside the title without replacing the header.
|
||||
* Entries render by ascending `order`; negative values are reserved for
|
||||
* static session context that precedes interactive actions. The owner
|
||||
* passes nothing: everything a control needs comes from the framework
|
||||
* session kit (`sessionId`, `useSession`, `useInput`, `inputActions`) and
|
||||
* from the registrant's own inject face, so an empty owner share means
|
||||
* self-sufficient, not starved.
|
||||
*/
|
||||
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
|
||||
/**
|
||||
@@ -95,7 +111,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
scope: 'session'
|
||||
owner: AssistantActionOwnerProps
|
||||
}
|
||||
/** Selected Tool call output inside the details panel. */
|
||||
/**
|
||||
* The body of the details panel for the tool call the user selected —
|
||||
* one occupant, so taking it means rendering every tool's output, not just
|
||||
* the ones you know. The owner passes a frozen `block` whose two lifecycle
|
||||
* forms must both be handled: branch on `'kind' in block` (a settled
|
||||
* `ToolResultNode` has it, a still-running call does not), and treat
|
||||
* `cwd` as display-only, for shortening workspace-rooted paths.
|
||||
* A per-tool renderer belongs in the keyed `tool.call.toolview` seat
|
||||
* instead; this one is the whole panel.
|
||||
*/
|
||||
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
|
||||
/**
|
||||
* The composer takeover chain: entries are selector-routed replacements
|
||||
@@ -124,15 +149,41 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
// ui-input-trigger, so the type arrives transitively). The runtime declaration
|
||||
// (children table in apply.ts) stays here with the other input slots.
|
||||
/**
|
||||
* Stacked strip above the input (queue rows / GoalBar / attachments;
|
||||
* entries coexist in fixed order).
|
||||
* A full-width row of its own, stacked above the composer card — the seat
|
||||
* for anything that needs a line to itself (queue rows, a todo strip, a
|
||||
* goal bar). Pick this over the three seats below when your content wraps
|
||||
* or carries prose; pick `conversation.composer.dock` for an ambient
|
||||
* readout under the card, and `conversation.input.left` /
|
||||
* `.right` for a small control INSIDE the card's tool row.
|
||||
* Read only `session`/`input` off the owner share ({@link InputZone}) —
|
||||
* both are point-in-time snapshots re-rendered for you, never subscribe.
|
||||
*/
|
||||
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
|
||||
/**
|
||||
* The band under the composer card, inside the bar's width column — the
|
||||
* seat for an ambient readout about the conversation (the shipped stats
|
||||
* line lives here). Same {@link InputZone} owner share as the other
|
||||
* regions. Anything the user must click belongs in the tool row instead
|
||||
* (`conversation.input.left` / `.right`); anything needing its own line
|
||||
* above the card belongs in `conversation.input.dock`.
|
||||
*/
|
||||
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/** Tool-row left region inside the input card (existing chrome stays in place beside entries). */
|
||||
/**
|
||||
* The left end of the tool row INSIDE the composer card, after the
|
||||
* resident chrome (access mode, plan, attach) — the seat for a small
|
||||
* always-visible control. Entries sit beside that chrome, never replace
|
||||
* it. Same {@link InputZone} owner share; use `.right` for a control that
|
||||
* belongs next to the send button, and the docks for anything taller than
|
||||
* one row.
|
||||
*/
|
||||
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/** Tool-row right region inside the input card. */
|
||||
/**
|
||||
* The right end of the same tool row, before the primary send button —
|
||||
* the seat for a control the user reaches on the way to sending (the
|
||||
* model select sits in its own named seat just left of here). Same
|
||||
* {@link InputZone} owner share and the same one-row height budget as
|
||||
* `conversation.input.left`.
|
||||
*/
|
||||
'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/**
|
||||
* The default composer body: a single slot rendered as the composer
|
||||
@@ -149,15 +200,23 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
*/
|
||||
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
|
||||
/**
|
||||
* The Plan-mode status seat in the composer tool row (left group,
|
||||
* right of the access-mode control). Declared by the composer-bar
|
||||
* entry; empty until a plan plugin registers (no placeholder
|
||||
* fallback).
|
||||
* The named plan-status seat in the composer tool row, immediately right
|
||||
* of the access-mode control — one occupant, so taking it means rendering
|
||||
* the plan affordance yourself. The owner passes only `locked` (see
|
||||
* {@link InputControlOwnerProps}): honour it by refusing interaction, and
|
||||
* take everything else from the framework session kit or your own inject.
|
||||
* Unoccupied, the seat renders nothing at all — the bar paints no
|
||||
* placeholder, so an absent plan plugin costs no layout.
|
||||
*/
|
||||
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
|
||||
/**
|
||||
* The model-select seat in the composer tool row (right group). Same
|
||||
* empty-until-registered contract as the plan seat.
|
||||
* The named model-select seat at the right end of the composer tool row,
|
||||
* left of the send button — one occupant, so taking it means rendering the
|
||||
* whole model affordance yourself. Same `locked`-only owner share and same
|
||||
* renders-nothing-while-empty contract as the plan seat. Note the composer
|
||||
* deliberately keeps this seat LIVE while it refuses text for a
|
||||
* model-related block: every such block is one the user clears by picking
|
||||
* a model here.
|
||||
*/
|
||||
'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
|
||||
}
|
||||
|
||||
@@ -10,8 +10,16 @@ import type { InputTriggerController } from './controller.ts'
|
||||
|
||||
/** The `ctx.inputTriggers` service face. */
|
||||
export interface InputTriggerServiceContract {
|
||||
/** Register one trigger source; effect disposer. Duplicate (trigger, name) throws. */
|
||||
/**
|
||||
* Register one trigger source; duplicate trigger/name pairs throw.
|
||||
* @param src - source that discovers and resolves slash or reference candidates.
|
||||
* @returns effect disposer removing this source.
|
||||
*/
|
||||
registerSource(src: InputTriggerSource): () => void
|
||||
/** Resolve the per-session controller for one session scope (lazy; dies with the scope). */
|
||||
/**
|
||||
* Resolve the lazy controller owned by one session scope.
|
||||
* @param actx - session-scoped Client context.
|
||||
* @returns controller that dies with that scope.
|
||||
*/
|
||||
sessionOf(actx: ClientContext): InputTriggerController
|
||||
}
|
||||
@@ -36,11 +36,51 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
// there); these four are the frame's children, declared by the same
|
||||
// register() call that contributes AppFrame. Session owners never pass
|
||||
// sessionId: the framework injects it as a standard prop.
|
||||
/**
|
||||
* The whole left column. OCCUPIED by ui-sidebar's SidebarRoot, which
|
||||
* declares the workspace and settings seats inside it — registering here
|
||||
* replaces the navigation column outright rather than adding to it, and
|
||||
* the seats it declares disappear with it. To add something to the
|
||||
* sidebar, register into one of those inner seats instead.
|
||||
*
|
||||
* The occupant receives the frame's live column state (collapsed, width)
|
||||
* and is expected to render the compact control rail while collapsed.
|
||||
*/
|
||||
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
|
||||
// Current-session-optional: the occupant owns both the no-session hero
|
||||
// and live conversation states without changing its React identity.
|
||||
/**
|
||||
* The whole center column, across both the no-session hero and a live
|
||||
* conversation. OCCUPIED by ui-conversation's ConversationRoot, which
|
||||
* declares the session body, composer, and input seats inside it —
|
||||
* registering here replaces the entire conversation surface (and removes
|
||||
* every seat it declares) rather than adding to it.
|
||||
*
|
||||
* Current-session-optional: the occupant owns both states without
|
||||
* changing its React identity, so it keeps its own state across a session
|
||||
* switch. It receives no owner props; session facts arrive through the
|
||||
* framework hooks of the `session-maybe` scope.
|
||||
*/
|
||||
'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps }
|
||||
/**
|
||||
* The right details column, shown when the layout opens it. OCCUPIED by
|
||||
* ui-conversation's DetailsPanel, which declares the tool-details seat
|
||||
* inside it — registering here replaces the column and takes that seat
|
||||
* with it. Absent an occupant the column renders nothing.
|
||||
*
|
||||
* No owner props: the framework injects the session id and hooks for the
|
||||
* `session` scope, and `ctx.layout` owns whether the column is open.
|
||||
*/
|
||||
'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps }
|
||||
/**
|
||||
* Frame-wide floating layer, above every column and outside their scroll
|
||||
* containers. Deliberately generic and unowned by any feature: a badge, a
|
||||
* toast stack or a status pill all belong here, and entries order among
|
||||
* themselves. The layer itself is click-through — entries opt back into
|
||||
* pointer events — so an occupant never blocks the app underneath.
|
||||
*
|
||||
* This is the additive seat for a frame-wide surface of your own: a fresh
|
||||
* `id` is added beside the shipped entries instead of replacing them.
|
||||
*/
|
||||
'shell.overlay': { kind: 'list'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +123,7 @@ export function apply(ctx: ClientContext): void {
|
||||
'sidebar': { kind: 'single', scope: 'root' },
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'shell.overlay': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
// Exclusive store: the factory itself — the framework instantiates per
|
||||
// entry and delivers useStore/actions to AppFrame as standard props.
|
||||
|
||||
@@ -601,6 +601,24 @@ export const IconCodeOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_cordis_plugin_outline_14 */
|
||||
export const IconCordisPluginOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_1840_45990)">
|
||||
<path
|
||||
d="M3.03426 5.66661L1.70084 7.00003L3.0315 8.33069L2.14762 9.21457L-0.0669245 7.00003L2.15038 4.78273L3.03426 5.66661ZM7 14.067L4.77924 11.8462L5.66313 10.9623L7 12.2992L8.33342 10.9658L9.2173 11.8496L7 14.067ZM11.8489 9.21803L10.965 8.33414L12.2992 7.00003L10.9623 5.66316L11.8462 4.77927L14.0669 7.00003L11.8489 9.21803ZM8.33066 3.03153L7 1.70087L5.66589 3.03498L4.782 2.1511L7 -0.0668945L9.21454 2.14765L8.33066 3.03153Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect x="5.98535" y="5.98535" width="2.02942" height="2.02942" fill="currentColor" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1840_45990">
|
||||
<rect width="14" height="14" fill="currentColor" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_api_outline (figma extract) */
|
||||
export const IconApiOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none">
|
||||
|
||||
@@ -74,14 +74,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
*/
|
||||
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
|
||||
/**
|
||||
* One preference row inside the General section, contributed by the
|
||||
* feature plugin that owns the preference (locale → Language, ui-theme →
|
||||
* Appearance, ui-conversation → Composer Enter). Options: `id` (row key),
|
||||
* `order` (row position). Rows draw their own internals; the section
|
||||
* column only stacks them. Declared at runtime by ui-settings-general's
|
||||
* General entry — the type lives here with every other settings slot type,
|
||||
* because this package is the settings domain's base layer and every
|
||||
* registrant already depends on it for `ctx.settingsScope`.
|
||||
* One preference row inside the General section — the additive seat for a
|
||||
* single setting that needs no page of its own (a whole page is
|
||||
* `settings.section`), contributed by the feature plugin that owns the
|
||||
* preference (locale → Language, ui-theme → Appearance, ui-conversation →
|
||||
* Composer Enter). Options: `id` (row key), `order` (row position). The
|
||||
* section column only stacks rows, so a row draws its own internals,
|
||||
* including its label: nothing projects a `label` here and the owner passes
|
||||
* no props at all — copy, current value, and the write path are all yours,
|
||||
* through your own inject face and `host.call`. Declared at runtime by
|
||||
* ui-settings-general's General entry; the type lives here with every other
|
||||
* settings slot type, because this package is the settings domain's base
|
||||
* layer and every registrant already depends on it for `ctx.settingsScope`.
|
||||
*/
|
||||
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
|
||||
}
|
||||
|
||||
@@ -227,39 +227,34 @@
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Footer seats: Settings fills the left side and additive actions sit on the
|
||||
right. Each occupant owns its button geometry and hover chrome. */
|
||||
/* Footer seats: additive actions stack above Settings. Each occupant owns its
|
||||
button geometry and hover chrome. */
|
||||
.footArea {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settingsArea {
|
||||
flex: 1;
|
||||
.settingsArea,
|
||||
.footerActions {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* The 56px rail cannot hold two controls side by side. Keep both reachable in
|
||||
the same footer, stacked in their original order. */
|
||||
.collapsed .footArea {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.collapsed .settingsArea,
|
||||
.collapsed .footerActions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -177,14 +177,14 @@ export function SidebarRoot({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer: Settings stays on the left; optional actions sit beside it. */}
|
||||
{/* Footer actions stack above Settings in both sidebar widths. */}
|
||||
<div className={css.footArea}>
|
||||
<div className={css.settingsArea}>
|
||||
{renderSlot('sidebar.settings', { wide })}
|
||||
</div>
|
||||
<div className={css.footerActions}>
|
||||
{renderSlot('sidebar.footer.action', { wide })}
|
||||
</div>
|
||||
<div className={css.settingsArea}>
|
||||
{renderSlot('sidebar.settings', { wide })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* owns column geometry (fold state machine, brand row, New Session);
|
||||
* everything between the section header and the list bottom is the
|
||||
* `sidebar.workspaces` registrant's (ui-workspace), and the foot is the
|
||||
* `sidebar.settings` registrant's (ui-settings).
|
||||
* `sidebar.settings` registrant's (ui-settings), followed by optional footer
|
||||
* actions in `sidebar.footer.action`.
|
||||
*/
|
||||
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
|
||||
@@ -27,6 +28,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* The sidebar passes only its column state — it holds no settings state.
|
||||
*/
|
||||
'sidebar.settings': { kind: 'single'; scope: 'root'; owner: SidebarSettingsOwnerProps }
|
||||
/**
|
||||
* Optional actions beside Settings at the sidebar foot. Declared by this
|
||||
* package's 'sidebar' entry; each action receives only the column state.
|
||||
*/
|
||||
'sidebar.footer.action': { kind: 'list'; scope: 'root'; owner: SidebarFooterActionOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,21 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
|
||||
export type ThemeTokens = Record<string, string>
|
||||
|
||||
/**
|
||||
* One override-layer token value: both palette modes are mandatory (repeat
|
||||
* the same value when the token is scheme-invariant) so an override never
|
||||
* goes illegible when the user switches to the other scheme.
|
||||
*/
|
||||
export interface ThemeTokenModes {
|
||||
/** Value applied while the light base palette is active. */
|
||||
light: string
|
||||
/** Value applied while the dark base palette is active. */
|
||||
dark: string
|
||||
}
|
||||
|
||||
/** Override-layer dictionary: token names to per-mode value pairs. */
|
||||
export type ThemeTokenOverrides = Record<string, ThemeTokenModes>
|
||||
|
||||
/** One selectable theme: id, dark/light semantics, and alias-token overrides. */
|
||||
export interface ThemeDefinition {
|
||||
/** Theme id (the setTheme argument for concrete themes). */
|
||||
@@ -59,7 +74,11 @@ export interface ThemeDefinition {
|
||||
export interface ThemeSnapshot {
|
||||
/** The persisted preference (may be `system`). */
|
||||
preference: ThemePreference
|
||||
/** The resolved active theme (`system` resolved via prefers-color-scheme). */
|
||||
/**
|
||||
* The resolved active theme (`system` resolved via prefers-color-scheme)
|
||||
* with override layers folded into its tokens (seq order, later layers win
|
||||
* per-token; each value picked for the active color scheme).
|
||||
*/
|
||||
active: ThemeDefinition
|
||||
/** Registered themes in registration order. */
|
||||
themes: readonly ThemeDefinition[]
|
||||
@@ -67,6 +86,20 @@ export interface ThemeSnapshot {
|
||||
revision: number
|
||||
}
|
||||
|
||||
/** One theme token exposed to pre-definition Cordis inspection. */
|
||||
export interface ThemeTokenInspection {
|
||||
/** Token name accepted by {@link ThemeService.overrideTokens}. */
|
||||
name: string
|
||||
/** Intended visual role. */
|
||||
description: string
|
||||
/** CSS value category. */
|
||||
valueType: string
|
||||
/** Whether override layers must supply both palette modes. */
|
||||
requiresLightAndDark: boolean
|
||||
/** CSS custom property consumed by UI styles. */
|
||||
cssVariable?: string
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
theme: ThemeRuntime
|
||||
@@ -87,11 +120,29 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
|
||||
Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }),
|
||||
])
|
||||
|
||||
const BUILTIN_INSPECT_TOKENS: readonly ThemeTokenInspection[] = Object.freeze([
|
||||
{ name: '--dsw-alias-bg-base', description: 'Application base background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-base' },
|
||||
{ name: '--dsw-alias-bg-layer-1', description: 'Primary raised surface background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-layer-1' },
|
||||
{ name: '--dsw-alias-bg-layer-2', description: 'Secondary nested surface background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-layer-2' },
|
||||
{ name: '--dsw-alias-bg-overlay', description: 'Overlay and popover background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-overlay' },
|
||||
{ name: '--dsw-alias-border-l1', description: 'Primary subtle border.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-border-l1' },
|
||||
{ name: '--dsw-alias-border-l2', description: 'Secondary stronger border.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-border-l2' },
|
||||
{ name: '--dsw-alias-brand-primary', description: 'Primary brand accent.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-brand-primary' },
|
||||
{ name: '--dsw-alias-label-primary', description: 'Primary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-primary' },
|
||||
{ name: '--dsw-alias-label-secondary', description: 'Secondary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-secondary' },
|
||||
{ name: '--dsw-alias-state-error-primary', description: 'Primary error state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-error-primary' },
|
||||
{ name: '--dsw-alias-state-success-primary', description: 'Primary success state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-success-primary' },
|
||||
{ name: '--dsw-alias-state-warn-primary', description: 'Primary warning state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-warn-primary' },
|
||||
{ name: '--dsw-specific-sidebar-fill', description: 'Sidebar column and title-row background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-specific-sidebar-fill' },
|
||||
])
|
||||
|
||||
/**
|
||||
* Theme registry and preference owner. `light`/`dark` are built in (the base
|
||||
* stylesheets carry both palettes); third-party themes register alias-layer
|
||||
* overrides. Reads go through {@link getTheme}; writes only through
|
||||
* {@link setTheme}; continuous sync only through the `theme/change` event.
|
||||
* overrides. Reads go through {@link getTheme}; preference writes only
|
||||
* through {@link setTheme}; continuous sync only through the `theme/change`
|
||||
* event. {@link overrideTokens} stacks partial token layers over the active
|
||||
* theme without touching the registry.
|
||||
* The service holds the `prefers-color-scheme` media query (environment
|
||||
* sensing, not presentation) and re-emits when the OS scheme flips while the
|
||||
* preference is `system`.
|
||||
@@ -104,6 +155,9 @@ export class ThemeRuntime {
|
||||
private revision = 0
|
||||
private snapshot: ThemeSnapshot
|
||||
private readonly media: MediaQueryList | undefined
|
||||
/** Override layers by source; seq (monotonic) is the stacking order. */
|
||||
private readonly overrides = new Map<string, { seq: number; tokens: ThemeTokenOverrides }>()
|
||||
private overrideSeq = 0
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it; the
|
||||
@@ -140,6 +194,25 @@ export class ThemeRuntime {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current token directory without reading DOM or computed styles.
|
||||
* @returns stable JSON-safe token descriptions, including registered and override-only names.
|
||||
*/
|
||||
exportInspectTokens(): ThemeTokenInspection[] {
|
||||
const tokens = new Map(BUILTIN_INSPECT_TOKENS.map(token => [token.name, token]))
|
||||
for (const theme of this.themes) {
|
||||
for (const name of Object.keys(theme.tokens)) {
|
||||
if (!tokens.has(name)) tokens.set(name, dynamicToken(name))
|
||||
}
|
||||
}
|
||||
for (const layer of this.overrides.values()) {
|
||||
for (const name of Object.keys(layer.tokens)) {
|
||||
if (!tokens.has(name)) tokens.set(name, dynamicToken(name))
|
||||
}
|
||||
}
|
||||
return [...tokens.values()].map(token => ({ ...token })).sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the theme preference — the only user preference write entry.
|
||||
* Built-in preferences are written through the settings scope and every
|
||||
@@ -189,6 +262,33 @@ export class ThemeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack a token override layer on top of the active theme — the token-level
|
||||
* analogue of slot shading: the base theme stays untouched, layers compose
|
||||
* in seq order with later layers winning per-token, and removing a layer
|
||||
* restores whatever it covered. Calling again with the same source replaces
|
||||
* that source's whole layer and restacks it on top (effect re-registration
|
||||
* semantics). Emits `theme/change` with the recomposed snapshot.
|
||||
* @param source - layer identity; one layer per source (dynamic packages
|
||||
* pass their package id — the façade pins it, so it also names the layer's
|
||||
* origin for inspection).
|
||||
* @param tokens - token-name → `{ light, dark }` value pairs. Validated at
|
||||
* runtime (model-authored callers reach this boundary with untyped JS);
|
||||
* a bare string value throws a teaching error.
|
||||
* @returns disposer removing exactly the layer this call created; a no-op
|
||||
* once the source has re-overridden (the newer layer is not torn down).
|
||||
*/
|
||||
overrideTokens(source: string, tokens: ThemeTokenOverrides): () => void {
|
||||
const layer = { seq: this.overrideSeq++, tokens: validateOverrides(source, tokens) }
|
||||
this.overrides.set(source, layer)
|
||||
this.publish()
|
||||
return () => {
|
||||
if (this.overrides.get(source) !== layer) return
|
||||
this.overrides.delete(source)
|
||||
this.publish()
|
||||
}
|
||||
}
|
||||
|
||||
private buildSnapshot(): ThemeSnapshot {
|
||||
const resolvedId = this.preference === 'system'
|
||||
? (this.media?.matches === true ? 'dark' : 'light')
|
||||
@@ -200,12 +300,29 @@ export class ThemeRuntime {
|
||||
if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`)
|
||||
return Object.freeze({
|
||||
preference: this.preference,
|
||||
active,
|
||||
active: this.composeActive(active),
|
||||
themes: Object.freeze([...this.themes]),
|
||||
revision: this.revision,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the override layers into the active definition: seq order, later
|
||||
* layers win per-token, each value picked for the active color scheme (the
|
||||
* presenter consumes the composed snapshot and needs no override awareness).
|
||||
* Without layers the registered definition passes through by identity.
|
||||
*/
|
||||
private composeActive(active: ThemeDefinition): ThemeDefinition {
|
||||
if (this.overrides.size === 0) return active
|
||||
const tokens: ThemeTokens = { ...active.tokens }
|
||||
for (const layer of [...this.overrides.values()].sort((a, b) => a.seq - b.seq)) {
|
||||
for (const [name, modes] of Object.entries(layer.tokens)) {
|
||||
tokens[name] = modes[active.colorScheme]
|
||||
}
|
||||
}
|
||||
return Object.freeze({ ...active, tokens: Object.freeze(tokens) })
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
this.revision += 1
|
||||
this.snapshot = this.buildSnapshot()
|
||||
@@ -213,6 +330,44 @@ export class ThemeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime shape check for one override layer (model-authored callers pass
|
||||
* untyped JS through the dynamic-package façade, so the static type cannot
|
||||
* enforce the pair shape there). Returns a defensive per-token copy so later
|
||||
* caller mutation cannot reach the stored layer.
|
||||
*/
|
||||
function validateOverrides(source: string, tokens: ThemeTokenOverrides): ThemeTokenOverrides {
|
||||
const validated: ThemeTokenOverrides = {}
|
||||
for (const [name, value] of Object.entries<unknown>(tokens)) {
|
||||
if (typeof value === 'string') {
|
||||
throw new TypeError(
|
||||
`theme override "${name}" from "${source}" is a bare string — pass { light: ${JSON.stringify(value)}, dark: ${JSON.stringify(value)} } `
|
||||
+ '(repeat the value when it is the same in both palettes); a single value goes illegible when the user switches color scheme',
|
||||
)
|
||||
}
|
||||
if (typeof value !== 'object' || value === null
|
||||
|| typeof (value as { light?: unknown }).light !== 'string'
|
||||
|| typeof (value as { dark?: unknown }).dark !== 'string') {
|
||||
throw new TypeError(
|
||||
`theme override "${name}" from "${source}" must map to a { light, dark } pair of strings — one value per color scheme`,
|
||||
)
|
||||
}
|
||||
const modes = value as ThemeTokenModes
|
||||
validated[name] = { light: modes.light, dark: modes.dark }
|
||||
}
|
||||
return validated
|
||||
}
|
||||
|
||||
function dynamicToken(name: string): ThemeTokenInspection {
|
||||
return {
|
||||
name,
|
||||
description: 'Theme token registered by the current Client composition.',
|
||||
valueType: 'CSS value',
|
||||
requiresLightAndDark: true,
|
||||
...(name.startsWith('--') ? { cssVariable: name } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Required services: settings transport plus slots/locale for the Appearance
|
||||
* row. `remote` carries the forwarded settings invalidation that
|
||||
|
||||
@@ -6,7 +6,20 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** Keyed atomic Tool call view, dispatched by the wire Tool name. */
|
||||
/**
|
||||
* Keyed atomic Tool call view, dispatched by the wire Tool name. Register
|
||||
* with `key: '<tool name>'` to own how one tool's calls render inside a
|
||||
* turn — the key domain is open (any wire tool name, including a tool your
|
||||
* own package registered), so there is no compile-time key set to pick
|
||||
* from and a typo simply never renders.
|
||||
*
|
||||
* A key the shipped composition already covers is replaced, not shared;
|
||||
* an unclaimed key falls back to the generic tool row, so registering is
|
||||
* additive for your own tool and a takeover for a shipped one. The owner
|
||||
* passes the call's identity, its frozen running-or-settled node, and the
|
||||
* expansion state (see ToolCallOwnerProps), so the view stays a pure
|
||||
* function of what the turn already knows.
|
||||
*/
|
||||
'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,15 @@ export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
|
||||
write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
|
||||
}
|
||||
|
||||
/** Known tool name -> variant. */
|
||||
/**
|
||||
* Known tool name -> variant.
|
||||
*
|
||||
* `cordis_define` is deliberately absent: ui-cordis registers a keyed
|
||||
* `tool.call.toolview` entry for it, and a keyed hit REPLACES the generic row
|
||||
* (this table is only reached through GenericToolCard, the dispatch fallback in
|
||||
* ToolCallTree). An entry here would be unreachable, and a second title for the
|
||||
* same call would be a second answer to a question the card already owns.
|
||||
*/
|
||||
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
|
||||
bash: 'bash',
|
||||
// The PowerShell twin is a shell tool: the bash row family (icon, colors)
|
||||
@@ -39,16 +47,24 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
|
||||
write: 'write',
|
||||
edit: 'edit',
|
||||
run_code: 'code',
|
||||
cordis_inspect: 'read',
|
||||
cordis_mount: 'code',
|
||||
cordis_unmount: 'others',
|
||||
cordis_package_inspect: 'read',
|
||||
cordis_runtime_inspect: 'read',
|
||||
// The three run-control verbs take one package id and produce a receipt, so
|
||||
// the generic row is the decided intent, not an unclassified default: there is
|
||||
// no program to show (that is `cordis_define`'s card) and no file to open. The
|
||||
// id lands in the summary slot, and the titles below name the act.
|
||||
cordis_run: 'others',
|
||||
cordis_stop: 'others',
|
||||
cordis_undefine: 'others',
|
||||
}
|
||||
|
||||
/** Tool-owned titles that refine a generic row variant without replacing it. */
|
||||
const TOOL_TITLES: Record<string, string> = {
|
||||
cordis_inspect: 'Inspect',
|
||||
cordis_mount: 'Mount temporary Plugin',
|
||||
cordis_unmount: 'Unmount temporary Plugin',
|
||||
cordis_package_inspect: 'Inspect',
|
||||
cordis_runtime_inspect: 'Inspect',
|
||||
cordis_run: 'Run dynamic package',
|
||||
cordis_stop: 'Stop dynamic package',
|
||||
cordis_undefine: 'Discard dynamic package',
|
||||
pwsh: 'Pwsh',
|
||||
}
|
||||
|
||||
|
||||
@@ -216,24 +216,23 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
|
||||
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
|
||||
const parent = 'call-cordis'
|
||||
const code = 'return { name: "audit", apply(ctx) {} }'
|
||||
const subCalls = [
|
||||
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
|
||||
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
|
||||
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
|
||||
subCall(11, parent, 1, 'cordis_runtime_inspect', { what: 'temporary' }, '## Dynamic Packages'),
|
||||
subCall(12, parent, 2, 'cordis_run', { id: 'dyn-2' }, 'Dynamic package dyn-2 is running'),
|
||||
subCall(13, parent, 3, 'cordis_undefine', { id: 'dyn-2' }, 'Dynamic package dyn-2 was discarded.'),
|
||||
]
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
|
||||
const view = mountApp(b.slots)
|
||||
const nest = view.container.querySelector('[data-subcalls]')!
|
||||
|
||||
expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
|
||||
const mounted = nest.querySelector('[data-variant="code"]')
|
||||
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
|
||||
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
// Each run-control verb names its act and shows the package id; without the
|
||||
// owned titles all three would read "Tool call · cordis_run · dyn-2".
|
||||
expect(nest.querySelector('[data-tool="cordis_runtime_inspect"]')?.textContent).toContain('Inspect')
|
||||
expect(nest.querySelector('[data-tool="cordis_run"]')?.textContent).toContain('Run dynamic packagedyn-2')
|
||||
expect(nest.querySelector('[data-tool="cordis_undefine"]')?.textContent).toContain('Discard dynamic packagedyn-2')
|
||||
// None of them is a code row: the program belongs to cordis_define, whose
|
||||
// own keyed card renders it (the next case covers the code row itself).
|
||||
expect(nest.querySelector('[data-variant="code"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
|
||||
|
||||
@@ -40,12 +40,44 @@ describe('tool-call-model', () => {
|
||||
expect(classifyTool('grep')).toBe('search')
|
||||
expect(classifyTool('write')).toBe('write')
|
||||
expect(classifyTool('edit')).toBe('edit')
|
||||
expect(classifyTool('cordis_inspect')).toBe('read')
|
||||
expect(classifyTool('cordis_mount')).toBe('code')
|
||||
expect(classifyTool('cordis_unmount')).toBe('others')
|
||||
expect(classifyTool('cordis_runtime_inspect')).toBe('read')
|
||||
// The v3 run-control verbs: `others` is the decided intent, not an
|
||||
// unclassified default (there is no program to show and no file to open).
|
||||
expect(classifyTool('cordis_run')).toBe('others')
|
||||
expect(classifyTool('cordis_stop')).toBe('others')
|
||||
expect(classifyTool('cordis_undefine')).toBe('others')
|
||||
expect(classifyTool('todo_write')).toBe('others')
|
||||
})
|
||||
|
||||
it('names each cordis verb instead of leaving it a bare tool call', () => {
|
||||
// Every define/run pair the model makes puts a row in the flow, so the
|
||||
// generic "Tool call · cordis_run · dyn-1" fallback is user-visible slop.
|
||||
const titleOf = (name: string) => toolRowModel(name, running({ name, argsRaw: '{"id":"dyn-1"}' }))
|
||||
expect(titleOf('cordis_run').title).toBe('Run dynamic package')
|
||||
expect(titleOf('cordis_stop').title).toBe('Stop dynamic package')
|
||||
expect(titleOf('cordis_undefine').title).toBe('Discard dynamic package')
|
||||
// An owned title takes the tool name out of the summary slot, leaving the
|
||||
// package id as the only mutable text.
|
||||
expect(titleOf('cordis_run').summary).toBe('dyn-1')
|
||||
})
|
||||
|
||||
it('leaves cordis_define to its own keyed toolview', () => {
|
||||
// ui-cordis registers a keyed `tool.call.toolview` entry for cordis_define,
|
||||
// and a keyed hit replaces the generic row (this model is only reached
|
||||
// through the dispatch fallback). A mapping here would be unreachable, and a
|
||||
// title here would be a second answer to what the card already renders.
|
||||
const model = toolRowModel('cordis_define', running({ name: 'cordis_define', argsRaw: '{"name":"clock"}' }))
|
||||
expect(model.variant).toBe('others')
|
||||
expect(model.title).toBe('Tool call')
|
||||
})
|
||||
|
||||
it('has dropped the v2 mount verbs that no longer exist', () => {
|
||||
// Keeping them would be a mapping for a tool nothing can call.
|
||||
expect(classifyTool('cordis_mount')).toBe('others')
|
||||
expect(toolRowModel('cordis_mount', running({ name: 'cordis_mount', argsRaw: '{}' })).title).toBe('Tool call')
|
||||
expect(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).title).toBe('Tool call')
|
||||
})
|
||||
|
||||
it('gives the pwsh shell row the bash family treatment with its own title', () => {
|
||||
const m = toolRowModel('pwsh', running())
|
||||
expect(m.variant).toBe('bash')
|
||||
@@ -141,28 +173,27 @@ describe('tool-call-model', () => {
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
expect(toolRowModel('cordis_runtime_inspect', running({
|
||||
name: 'cordis_runtime_inspect',
|
||||
argsRaw: '{"what":"api","name":"tools"}',
|
||||
}))).toMatchObject({
|
||||
variant: 'read',
|
||||
title: 'Inspect',
|
||||
summary: 'api',
|
||||
})
|
||||
expect(toolRowModel('cordis_mount', running({
|
||||
name: 'cordis_mount',
|
||||
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}',
|
||||
}))).toMatchObject({
|
||||
variant: 'code',
|
||||
title: 'Mount temporary Plugin',
|
||||
summary: 'return { name: "audit", apply(ctx) {} }',
|
||||
body: 'return { name: "audit", apply(ctx) {} }',
|
||||
})
|
||||
expect(toolRowModel('cordis_unmount', result({
|
||||
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
|
||||
expect(toolRowModel('cordis_run', running({
|
||||
name: 'cordis_run',
|
||||
argsRaw: '{"id":"dyn-2"}',
|
||||
}))).toMatchObject({
|
||||
variant: 'others',
|
||||
title: 'Unmount temporary Plugin',
|
||||
title: 'Run dynamic package',
|
||||
summary: 'dyn-2',
|
||||
})
|
||||
expect(toolRowModel('cordis_undefine', result({
|
||||
call: { name: 'cordis_undefine', argsRaw: '{"id":"dyn-2"}' },
|
||||
}))).toMatchObject({
|
||||
variant: 'others',
|
||||
title: 'Discard dynamic package',
|
||||
summary: 'dyn-2',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// the declaration then land through slots.inject when the chat entry appears.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent } from '@testing-library/react'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -106,22 +106,25 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
})
|
||||
|
||||
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
|
||||
const code = 'return { name: "audit", apply(ctx) {} }'
|
||||
const b = await bench([
|
||||
toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'),
|
||||
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
|
||||
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
|
||||
toolResult(3, 'cordis-1', 'cordis_runtime_inspect', '{"what":"api","name":"tools"}'),
|
||||
toolResult(4, 'cordis-2', 'cordis_run', '{"id":"dyn-2"}'),
|
||||
toolResult(5, 'cordis-3', 'cordis_stop', '{"id":"dyn-2"}'),
|
||||
toolResult(6, 'cordis-4', 'cordis_undefine', '{"id":"dyn-2"}'),
|
||||
])
|
||||
const view = b.runtime.renderRoot()
|
||||
|
||||
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
|
||||
const mounted = view.container.querySelector('[data-variant="code"]')
|
||||
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
|
||||
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
// Every one of these rows is user-visible on each model define/run, so each
|
||||
// names its act and carries the package id rather than falling back to the
|
||||
// generic "Tool call · <name> · <id>" row.
|
||||
const rowText = (name: string) => view.container.querySelector(`[data-tool="${name}"]`)?.textContent
|
||||
expect(rowText('cordis_runtime_inspect')).toContain('Inspect')
|
||||
expect(rowText('cordis_run')).toContain('Run dynamic packagedyn-2')
|
||||
expect(rowText('cordis_stop')).toContain('Stop dynamic packagedyn-2')
|
||||
expect(rowText('cordis_undefine')).toContain('Discard dynamic packagedyn-2')
|
||||
// No run-control verb is a code row; the program is cordis_define's, and its
|
||||
// own keyed card owns that rendering.
|
||||
expect(view.container.querySelector('[data-variant="code"]')).toBeNull()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user