Merge pull request #763 from deepseek-harness/lint-tsx-baseline
chore(lint): bring .tsx files into the eslint, lefthook, and jscpd lanes
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
"minTokens": 60,
|
"minTokens": 60,
|
||||||
"minLines": 6,
|
"minLines": 6,
|
||||||
"mode": "mild",
|
"mode": "mild",
|
||||||
"format": ["typescript"],
|
"format": ["typescript", "tsx"],
|
||||||
"pattern": "**/*.ts",
|
"pattern": "**/*.{ts,tsx}",
|
||||||
"ignore": ["**/tests/**", "**/tsdown.config.ts"],
|
"ignore": ["**/tests/**", "**/tsdown.config.ts"],
|
||||||
"ignorePattern": [
|
"ignorePattern": [
|
||||||
"(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/"
|
"(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/"
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ export default tseslint.config(
|
|||||||
|
|
||||||
// --- our packages: full strictness -------------------------------------
|
// --- our packages: full strictness -------------------------------------
|
||||||
{
|
{
|
||||||
files: ['packages/*/*/src/**/*.ts', 'apps/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],
|
files: [
|
||||||
|
'packages/*/*/src/**/*.{ts,tsx}',
|
||||||
|
'apps/*/src/**/*.{ts,tsx}',
|
||||||
|
'examples/**/*.{ts,tsx}',
|
||||||
|
'scripts/**/*.{ts,tsx}',
|
||||||
|
'website/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
extends: [
|
extends: [
|
||||||
...tseslint.configs.strictTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
],
|
],
|
||||||
@@ -80,7 +86,12 @@ export default tseslint.config(
|
|||||||
|
|
||||||
// --- tests: same rules, minus the friction that fights test ergonomics --
|
// --- tests: same rules, minus the friction that fights test ergonomics --
|
||||||
{
|
{
|
||||||
files: ['packages/*/*/tests/**/*.ts', 'apps/*/tests/**/*.ts', 'examples/*/tests/**/*.ts', 'scripts/**/*.spec.ts'],
|
files: [
|
||||||
|
'packages/*/*/tests/**/*.{ts,tsx}',
|
||||||
|
'apps/*/tests/**/*.{ts,tsx}',
|
||||||
|
'examples/*/tests/**/*.{ts,tsx}',
|
||||||
|
'scripts/**/*.spec.{ts,tsx}',
|
||||||
|
],
|
||||||
extends: [
|
extends: [
|
||||||
...tseslint.configs.strictTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
],
|
],
|
||||||
@@ -117,7 +128,10 @@ export default tseslint.config(
|
|||||||
// Context merges collide), so the shared project service cannot resolve
|
// Context merges collide), so the shared project service cannot resolve
|
||||||
// them — parse these through the client aggregate explicitly.
|
// them — parse these through the client aggregate explicitly.
|
||||||
{
|
{
|
||||||
files: ['packages/client/*/tests/**/*.ts', 'scripts/client-bundle-purity.spec.ts'],
|
files: [
|
||||||
|
'packages/client/*/tests/**/*.{ts,tsx}',
|
||||||
|
'scripts/client-bundle-purity.spec.ts',
|
||||||
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: false,
|
projectService: false,
|
||||||
@@ -129,7 +143,7 @@ export default tseslint.config(
|
|||||||
|
|
||||||
// --- file-local duplication (all owned TypeScript) ---------------------
|
// --- file-local duplication (all owned TypeScript) ---------------------
|
||||||
{
|
{
|
||||||
files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],
|
files: ['packages/**/*.{ts,tsx}', 'apps/**/*.{ts,tsx}', 'examples/**/*.{ts,tsx}', 'scripts/**/*.{ts,tsx}', 'website/**/*.{ts,tsx}'],
|
||||||
plugins: { sonarjs },
|
plugins: { sonarjs },
|
||||||
rules: {
|
rules: {
|
||||||
// Cross-file clones are covered separately by jscpd.
|
// Cross-file clones are covered separately by jscpd.
|
||||||
@@ -146,7 +160,14 @@ export default tseslint.config(
|
|||||||
|
|
||||||
// --- formatting (everything we own) -------------------------------------
|
// --- formatting (everything we own) -------------------------------------
|
||||||
{
|
{
|
||||||
files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'],
|
files: [
|
||||||
|
'packages/**/*.{ts,tsx}',
|
||||||
|
'apps/**/*.{ts,tsx}',
|
||||||
|
'examples/**/*.{ts,tsx}',
|
||||||
|
'scripts/**/*.{ts,tsx}',
|
||||||
|
'website/**/*.{ts,tsx}',
|
||||||
|
'eslint.config.mjs',
|
||||||
|
],
|
||||||
plugins: { '@stylistic': stylistic },
|
plugins: { '@stylistic': stylistic },
|
||||||
rules: {
|
rules: {
|
||||||
'@stylistic/indent': ['error', 2],
|
'@stylistic/indent': ['error', 2],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
pre-commit:
|
pre-commit:
|
||||||
jobs:
|
jobs:
|
||||||
- name: lint (staged)
|
- name: lint (staged)
|
||||||
glob: '*.{ts,mts,cts,mjs}'
|
glob: '*.{ts,tsx,mts,cts,mjs}'
|
||||||
exclude:
|
exclude:
|
||||||
- 'vendor/*/src/**'
|
- 'vendor/*/src/**'
|
||||||
run: node_modules/.bin/eslint --fix {staged_files}
|
run: node_modules/.bin/eslint --fix {staged_files}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rowLabels(): string[] {
|
function rowLabels(): string[] {
|
||||||
return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent!)
|
return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('PopupSelectView', () => {
|
describe('PopupSelectView', () => {
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
|||||||
seq: number
|
seq: number
|
||||||
onOpenDetails: OpenDetails
|
onOpenDetails: OpenDetails
|
||||||
selected: boolean
|
selected: boolean
|
||||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
|
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||||||
|
* parent; running entries settle in place); undefined for ordinary calls. */
|
||||||
subCalls?: readonly CodeSubCall[] | undefined
|
subCalls?: readonly CodeSubCall[] | undefined
|
||||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||||
selectedCallId?: string | undefined
|
selectedCallId?: string | undefined
|
||||||
@@ -103,7 +104,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
|||||||
})}
|
})}
|
||||||
{subCalls !== undefined && subCalls.length > 0 && (
|
{subCalls !== undefined && subCalls.length > 0 && (
|
||||||
<div className={css.subCalls} data-subcalls>
|
<div className={css.subCalls} data-subcalls>
|
||||||
{subCalls.map((node) => (
|
{subCalls.map(node => (
|
||||||
<SubCallRow
|
<SubCallRow
|
||||||
key={node.callId}
|
key={node.callId}
|
||||||
renderSlot={renderSlot}
|
renderSlot={renderSlot}
|
||||||
@@ -130,7 +131,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={css.toolGroup}>
|
<div className={css.toolGroup}>
|
||||||
{results.map((node) => (
|
{results.map(node => (
|
||||||
<CallRow
|
<CallRow
|
||||||
key={node.callId}
|
key={node.callId}
|
||||||
renderSlot={renderSlot}
|
renderSlot={renderSlot}
|
||||||
@@ -154,7 +155,7 @@ function StreamingTail({ useSession, onGrow }: {
|
|||||||
useSession: UseConversation
|
useSession: UseConversation
|
||||||
onGrow: () => void
|
onGrow: () => void
|
||||||
}) {
|
}) {
|
||||||
const partial = useSession((s) => s.partial)
|
const partial = useSession(s => s.partial)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
onGrow()
|
onGrow()
|
||||||
})
|
})
|
||||||
@@ -162,17 +163,20 @@ function StreamingTail({ useSession, onGrow }: {
|
|||||||
return <AssistantMarkdown blocks={partial.blocks} streaming />
|
return <AssistantMarkdown blocks={partial.blocks} streaming />
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
|
/**
|
||||||
|
* The chat view slot entry: pure component over the composed props (tool rows
|
||||||
|
* render through the declared keyed hole's renderSlot share).
|
||||||
|
*/
|
||||||
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
|
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
|
||||||
const nodes = useSession((s) => s.nodes)
|
const nodes = useSession(s => s.nodes)
|
||||||
const runningCalls = useSession((s) => s.runningCalls)
|
const runningCalls = useSession(s => s.runningCalls)
|
||||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
const codeDispatches = useSession(s => s.codeDispatches)
|
||||||
const pending = useSession((s) => s.pending)
|
const pending = useSession(s => s.pending)
|
||||||
const openState = useSession((s) => s.openState)
|
const openState = useSession(s => s.openState)
|
||||||
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||||
const hasMore = useSession((s) => s.hasMore)
|
const hasMore = useSession(s => s.hasMore)
|
||||||
const loadingOlder = useSession((s) => s.loadingOlder)
|
const loadingOlder = useSession(s => s.loadingOlder)
|
||||||
const selectedCallId = useStore((s) => s.selection?.callId)
|
const selectedCallId = useStore(s => s.selection?.callId)
|
||||||
|
|
||||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||||
|
|
||||||
@@ -254,8 +258,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
|||||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||||
if (item.kind === 'tool-group') {
|
if (item.kind === 'tool-group') {
|
||||||
const inGroup = selectedCallId !== undefined
|
const inGroup = selectedCallId !== undefined
|
||||||
&& item.results.some((r) => r.callId === selectedCallId
|
&& item.results.some(r => r.callId === selectedCallId
|
||||||
|| codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true)
|
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
|
||||||
return (
|
return (
|
||||||
<ToolGroup
|
<ToolGroup
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@@ -280,36 +284,36 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
|||||||
<div className={css.root}>
|
<div className={css.root}>
|
||||||
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
||||||
<div className={css.column}>
|
<div className={css.column}>
|
||||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className={css.older}>
|
<div className={css.older}>
|
||||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||||
{loadingOlder ? '加载中…' : '加载更早'}
|
{loadingOlder ? '加载中…' : '加载更早'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{items.map(renderItem)}
|
{items.map(renderItem)}
|
||||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||||
{runningCalls.length > 0 && (
|
{runningCalls.length > 0 && (
|
||||||
<div className={css.toolGroup}>
|
<div className={css.toolGroup}>
|
||||||
{runningCalls.map((call) => (
|
{runningCalls.map(call => (
|
||||||
<CallRow
|
<CallRow
|
||||||
key={call.callId}
|
key={call.callId}
|
||||||
renderSlot={renderSlot}
|
renderSlot={renderSlot}
|
||||||
callId={call.callId}
|
callId={call.callId}
|
||||||
toolName={call.name}
|
toolName={call.name}
|
||||||
block={call}
|
block={call}
|
||||||
seq={call.turn}
|
seq={call.turn}
|
||||||
onOpenDetails={openDetails}
|
onOpenDetails={openDetails}
|
||||||
selected={call.callId === selectedCallId}
|
selected={call.callId === selectedCallId}
|
||||||
subCalls={codeDispatches.get(call.callId)}
|
subCalls={codeDispatches.get(call.callId)}
|
||||||
selectedCallId={selectedCallId}
|
selectedCallId={selectedCallId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
|
{pending.map(item => <PendingCard key={item.key} item={item} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatsLine useSession={useSession} />
|
<StatsLine useSession={useSession} />
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
|||||||
|
|
||||||
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
||||||
async function writeClipboard(text: string): Promise<void> {
|
async function writeClipboard(text: string): Promise<void> {
|
||||||
|
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||||
|
// that runtime gap is exactly what this guard detects.
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
@@ -40,6 +43,9 @@ async function writeClipboard(text: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// execCommand('copy') is the only clipboard fallback where the async API
|
||||||
|
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||||
|
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||||
const exec = typeof document.execCommand === 'function'
|
const exec = typeof document.execCommand === 'function'
|
||||||
? document.execCommand.bind(document)
|
? document.execCommand.bind(document)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -56,6 +62,7 @@ async function writeClipboard(text: string): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
// Clipboard unavailable; the button stays idle.
|
// Clipboard unavailable; the button stays idle.
|
||||||
}
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||||
el.remove()
|
el.remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
|||||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||||
|
|
||||||
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
||||||
const nodes = useSession((s) => s.nodes)
|
const nodes = useSession(s => s.nodes)
|
||||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||||
if (stats.steps === 0) return null
|
if (stats.steps === 0) return null
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function ToolRow({
|
|||||||
const open = expanded && expandable
|
const open = expanded && expandable
|
||||||
const rowExpands = expandable && expandOnRowClick
|
const rowExpands = expandable && expandOnRowClick
|
||||||
const toggleExpand = () => {
|
const toggleExpand = () => {
|
||||||
setExpanded((v) => !v)
|
setExpanded(v => !v)
|
||||||
}
|
}
|
||||||
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
|
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export interface ToolRowOwnerProps {
|
|||||||
/** Frozen call slice: the running call or the settled result node. */
|
/** Frozen call slice: the running call or the settled result node. */
|
||||||
block: ToolCallBlock
|
block: ToolCallBlock
|
||||||
/** Open the details panel for this call (session-level facility, supplied by the view). */
|
/** Open the details panel for this call (session-level facility, supplied by the view). */
|
||||||
openDetails(): void
|
openDetails: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,21 +175,21 @@ export interface ConversationInjected {
|
|||||||
* Connect the selected Workspace and open its reusable/new blank session.
|
* Connect the selected Workspace and open its reusable/new blank session.
|
||||||
* When a blank session is already current, carry its draft to the target.
|
* When a blank session is already current, carry its draft to the target.
|
||||||
*/
|
*/
|
||||||
selectWorkspace(workspaceId: WorkspaceId): Promise<void>
|
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Business callbacks injected into the strict session content seat. */
|
/** Business callbacks injected into the strict session content seat. */
|
||||||
export interface ConversationSessionInjected {
|
export interface ConversationSessionInjected {
|
||||||
/** Views projected from the `conversation.view` slot ledger. */
|
/** Views projected from the `conversation.view` slot ledger. */
|
||||||
views: {
|
views: {
|
||||||
list(): readonly ViewTab[]
|
list: () => readonly ViewTab[]
|
||||||
subscribe(fn: () => void): () => void
|
subscribe: (fn: () => void) => () => void
|
||||||
version(): number
|
version: () => number
|
||||||
}
|
}
|
||||||
/** Bind the input machine's draft persistence mirror to the session store. */
|
/** Bind the input machine's draft persistence mirror to the session store. */
|
||||||
bindDraftMirror(write: (text: string) => void): () => void
|
bindDraftMirror: (write: (text: string) => void) => () => void
|
||||||
/** Select a real Session through the runtime navigation owner. */
|
/** Select a real Session through the runtime navigation owner. */
|
||||||
open(sessionId: SessionId): void
|
open: (sessionId: SessionId) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,7 +219,7 @@ export interface ComposerBarInjected {
|
|||||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
||||||
keyboard: ComposerKeyboard
|
keyboard: ComposerKeyboard
|
||||||
/** Cancel the in-flight turn. */
|
/** Cancel the in-flight turn. */
|
||||||
stop(): void
|
stop: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -275,8 +275,8 @@ export type ConversationSessionSlotProps =
|
|||||||
*/
|
*/
|
||||||
export interface ChatViewInjected {
|
export interface ChatViewInjected {
|
||||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||||
openDetails(target: SelectionTarget): void
|
openDetails: (target: SelectionTarget) => void
|
||||||
loadOlder(): void
|
loadOlder: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
|
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
|
||||||
@@ -290,7 +290,7 @@ export type ChatViewSlotProps =
|
|||||||
*/
|
*/
|
||||||
export interface DetailsInjected {
|
export interface DetailsInjected {
|
||||||
/** Close the details panel (layout geometry stays with ctx.layout). */
|
/** Close the details panel (layout geometry stays with ctx.layout). */
|
||||||
closeDetails(): void
|
closeDetails: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
||||||
@@ -300,6 +300,6 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
|||||||
export interface EmptyWorkspaceOwnerProps {
|
export interface EmptyWorkspaceOwnerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
anchorRef?: RefObject<HTMLElement>
|
anchorRef?: RefObject<HTMLElement>
|
||||||
onPick(workspaceId: WorkspaceId): void
|
onPick: (workspaceId: WorkspaceId) => void
|
||||||
onClose(): void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,12 +78,12 @@ export function ConversationRoot({
|
|||||||
const inputBar = sessionId === undefined
|
const inputBar = sessionId === undefined
|
||||||
? <DisabledInputBar />
|
? <DisabledInputBar />
|
||||||
: renderSlot('conversation.composer.bar', {
|
: renderSlot('conversation.composer.bar', {
|
||||||
variant: hero ? 'hero' : 'composer',
|
variant: hero ? 'hero' : 'composer',
|
||||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||||
overlay: renderSlot('conversation.input.overlay', {}),
|
overlay: renderSlot('conversation.input.overlay', {}),
|
||||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||||
})
|
})
|
||||||
|
|
||||||
const composerBar = (
|
const composerBar = (
|
||||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export function ConversationSession({
|
|||||||
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
|
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
|
||||||
const unmirror = bindDraftMirror(actions.setDraft)
|
const unmirror = bindDraftMirror(actions.setDraft)
|
||||||
return () => { unmirror() }
|
return () => { unmirror() }
|
||||||
// Mount-only: later store writes come from the machine mirror.
|
// Mount-only (deps pinned to inputActions): later store writes come from
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// the machine mirror, not this seed effect.
|
||||||
}, [inputActions])
|
}, [inputActions])
|
||||||
|
|
||||||
if (blank && composerPhase === 'blank') return null
|
if (blank && composerPhase === 'blank') return null
|
||||||
|
|||||||
@@ -86,27 +86,27 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
|||||||
: material === null
|
: material === null
|
||||||
? <div className={css.empty}>该调用不在当前窗口内</div>
|
? <div className={css.empty}>该调用不在当前窗口内</div>
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
{material.argsRaw !== null && (
|
{material.argsRaw !== null && (
|
||||||
<section className={css.section}>
|
|
||||||
<div className={css.sectionLabel}>Input</div>
|
|
||||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
<section className={css.section}>
|
<section className={css.section}>
|
||||||
<div className={css.sectionLabel}>Output</div>
|
<div className={css.sectionLabel}>Input</div>
|
||||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
||||||
material always carries its result node). */}
|
|
||||||
{material.result === null
|
|
||||||
? <div className={css.empty}>运行中…</div>
|
|
||||||
: (
|
|
||||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
|
||||||
{renderResult(material.result)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</>
|
)}
|
||||||
)}
|
<section className={css.section}>
|
||||||
|
<div className={css.sectionLabel}>Output</div>
|
||||||
|
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||||
|
material always carries its result node). */}
|
||||||
|
{material.result === null
|
||||||
|
? <div className={css.empty}>运行中…</div>
|
||||||
|
: (
|
||||||
|
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||||
|
{renderResult(material.result)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ export function InputBar({
|
|||||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||||
}: InputBarProps) {
|
}: InputBarProps) {
|
||||||
const input = useInput(s => s)
|
const input = useInput(s => s)
|
||||||
const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot)
|
const noticeStore = keyboard.notices
|
||||||
|
const notice = useSyncExternalStore(
|
||||||
|
(fn: () => void) => noticeStore.subscribe(fn),
|
||||||
|
() => noticeStore.getSnapshot(),
|
||||||
|
)
|
||||||
const promptError = useSession(s => s.promptError)
|
const promptError = useSession(s => s.promptError)
|
||||||
const running = useSession(s => s.running)
|
const running = useSession(s => s.running)
|
||||||
const disabled = useSession(s => s.removed)
|
const disabled = useSession(s => s.removed)
|
||||||
@@ -75,6 +79,8 @@ export function InputBar({
|
|||||||
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
|
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
|
||||||
// IME guard so a composition-closing Shift+Enter still breaks the line.
|
// IME guard so a composition-closing Shift+Enter still breaks the line.
|
||||||
if (e.key === 'Enter' && e.shiftKey) return
|
if (e.key === 'Enter' && e.shiftKey) return
|
||||||
|
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||||
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
|
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
|
||||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
|
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
|
||||||
@@ -92,7 +98,7 @@ export function InputBar({
|
|||||||
// the browser stack cannot represent); never let the native stack run.
|
// the browser stack cannot represent); never let the native stack run.
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (machineBusy || locked) return
|
if (machineBusy || locked) return
|
||||||
const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z'))
|
const redo = e.key === 'y' || e.shiftKey
|
||||||
if (redo) keyboard.redo()
|
if (redo) keyboard.redo()
|
||||||
else keyboard.undo()
|
else keyboard.undo()
|
||||||
return
|
return
|
||||||
@@ -134,6 +140,8 @@ export function InputBar({
|
|||||||
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
||||||
const next = e.target.value
|
const next = e.target.value
|
||||||
keyboard.setDraft(next)
|
keyboard.setDraft(next)
|
||||||
|
// selectionStart is number|null in lib.dom; the eslint program narrows it.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
keyboard.track(next, e.target.selectionStart ?? next.length)
|
keyboard.track(next, e.target.selectionStart ?? next.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,10 +153,13 @@ export function InputBar({
|
|||||||
// too (one char = one step). Mouse selection of a chip is handled in the
|
// too (one char = one step). Mouse selection of a chip is handled in the
|
||||||
// backdrop click handler below. Undo/redo must NOT reach the browser: the
|
// backdrop click handler below. Undo/redo must NOT reach the browser: the
|
||||||
// machine owns the transaction log.
|
// machine owns the transaction log.
|
||||||
|
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
|
||||||
|
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||||
const selectionOf = (el: HTMLTextAreaElement) => ({
|
const selectionOf = (el: HTMLTextAreaElement) => ({
|
||||||
start: el.selectionStart ?? 0,
|
start: el.selectionStart ?? 0,
|
||||||
end: el.selectionEnd ?? el.selectionStart ?? 0,
|
end: el.selectionEnd ?? el.selectionStart ?? 0,
|
||||||
})
|
})
|
||||||
|
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
||||||
|
|
||||||
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
|
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
|
||||||
const el = e.currentTarget
|
const el = e.currentTarget
|
||||||
@@ -330,8 +341,8 @@ export function InputBar({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onCopy={e => { onCopyOrCut(e, false) }}
|
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||||
onCut={e => { onCopyOrCut(e, true) }}
|
onCut={(e) => { onCopyOrCut(e, true) }}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
onCompositionStart={onCompositionStart}
|
onCompositionStart={onCompositionStart}
|
||||||
onCompositionEnd={onCompositionEnd}
|
onCompositionEnd={onCompositionEnd}
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ const SCOPE_TAG: symbol = (() => {
|
|||||||
const spy = new Proxy(new Context(), {
|
const spy = new Proxy(new Context(), {
|
||||||
get(target, prop, receiver) {
|
get(target, prop, receiver) {
|
||||||
recorded.push(prop)
|
recorded.push(prop)
|
||||||
|
// Reflect.get is typed any; the probe only records property names.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
return Reflect.get(target, prop, receiver)
|
return Reflect.get(target, prop, receiver)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
void scopeOf(spy as Context)
|
void scopeOf(spy)
|
||||||
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
||||||
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
||||||
return symbol
|
return symbol
|
||||||
@@ -73,14 +75,15 @@ async function bench() {
|
|||||||
const mint = (id: SessionId): Context => {
|
const mint = (id: SessionId): Context => {
|
||||||
let scoped = scopes.get(id)
|
let scoped = scopes.get(id)
|
||||||
if (scoped === undefined) {
|
if (scoped === undefined) {
|
||||||
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context
|
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id })
|
||||||
scopes.set(id, scoped)
|
scopes.set(id, scoped)
|
||||||
}
|
}
|
||||||
return scoped
|
return scoped
|
||||||
}
|
}
|
||||||
type TestProvider = {
|
type TestProvider = {
|
||||||
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
|
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
|
||||||
hooks?: Record<string, unknown>; props?: Record<string, unknown>
|
hooks?: Record<string, unknown>
|
||||||
|
props?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const providers: TestProvider[] = []
|
const providers: TestProvider[] = []
|
||||||
@@ -158,10 +161,12 @@ async function bench() {
|
|||||||
const inputSurface = (id: SessionId) => {
|
const inputSurface = (id: SessionId) => {
|
||||||
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
|
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
|
||||||
const state = contribution.hooks!['input'] as {
|
const state = contribution.hooks!['input'] as {
|
||||||
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
|
getSnapshot: () => { draft: string }
|
||||||
|
subscribe: (fn: () => void) => () => void
|
||||||
}
|
}
|
||||||
const actions = contribution.props!['inputActions'] as {
|
const actions = contribution.props!['inputActions'] as {
|
||||||
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
|
setDraft: (text: string) => void
|
||||||
|
submit: (mode?: 'queue' | 'steer') => void
|
||||||
}
|
}
|
||||||
return { state, actions }
|
return { state, actions }
|
||||||
}
|
}
|
||||||
@@ -234,11 +239,11 @@ describe('conversation slot inject surface', () => {
|
|||||||
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
|
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
|
||||||
// Unknown session: sessions.scope answers nothing.
|
// Unknown session: sessions.scope answers nothing.
|
||||||
;(b.sessionsFake.scope as unknown) = () => undefined
|
;(b.sessionsFake.scope as unknown) = () => undefined
|
||||||
expect(() => injectFn(ROOT).stop()).toThrow(/resolved no scope/)
|
expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/)
|
||||||
// A scope minted outside the service tree: no conversation service on it.
|
// A scope minted outside the service tree: no conversation service on it.
|
||||||
const foreign = new Context()
|
const foreign = new Context()
|
||||||
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
|
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
|
||||||
expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/)
|
expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||||
@@ -263,7 +268,7 @@ describe('conversation slot inject surface', () => {
|
|||||||
// no draft movement, plain re-open.
|
// no draft movement, plain re-open.
|
||||||
const { state, actions } = b.inputSurface(ROOT)
|
const { state, actions } = b.inputSurface(ROOT)
|
||||||
actions.setDraft('carry me')
|
actions.setDraft('carry me')
|
||||||
resident.selectWorkspace('workspace-1' as never)
|
void resident.selectWorkspace('workspace-1' as never)
|
||||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
|
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
|
||||||
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
|
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
|
||||||
expect(state.getSnapshot().draft).toBe('carry me')
|
expect(state.getSnapshot().draft).toBe('carry me')
|
||||||
@@ -271,7 +276,7 @@ describe('conversation slot inject surface', () => {
|
|||||||
// new session's machine receives the text, then navigation lands there.
|
// new session's machine receives the text, then navigation lands there.
|
||||||
const OTHER = 'other-1' as SessionId
|
const OTHER = 'other-1' as SessionId
|
||||||
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
|
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
|
||||||
resident.selectWorkspace('workspace-2' as never)
|
void resident.selectWorkspace('workspace-2' as never)
|
||||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
|
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
|
||||||
expect(state.getSnapshot().draft).toBe('')
|
expect(state.getSnapshot().draft).toBe('')
|
||||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async function bench() {
|
|||||||
},
|
},
|
||||||
current: undefined,
|
current: undefined,
|
||||||
phase: 'ready',
|
phase: 'ready',
|
||||||
} as SessionListState)
|
})
|
||||||
const sessionsFake = {
|
const sessionsFake = {
|
||||||
list: listStore,
|
list: listStore,
|
||||||
binding: vi.fn(),
|
binding: vi.fn(),
|
||||||
@@ -83,7 +83,7 @@ describe('apply wiring', () => {
|
|||||||
const b = await bench()
|
const b = await bench()
|
||||||
await b.fiber.await()
|
await b.fiber.await()
|
||||||
const entries = b.slots.entries('conversation.view')
|
const entries = b.slots.entries('conversation.view')
|
||||||
expect(entries.map((e) => e.options.id)).toEqual(['chat'])
|
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
||||||
expect(entries[0]?.options.label).toBe('Chat')
|
expect(entries[0]?.options.label).toBe('Chat')
|
||||||
expect(entries[0]?.options.order).toBe(0)
|
expect(entries[0]?.options.order).toBe(0)
|
||||||
// Declaring is claiming: the chat entry's registration put the hole on
|
// Declaring is claiming: the chat entry's registration put the hole on
|
||||||
@@ -117,7 +117,7 @@ describe('apply wiring', () => {
|
|||||||
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
||||||
// service being present implies the chat entry declared the hole first.
|
// service being present implies the chat entry declared the hole first.
|
||||||
const entries = b.slots.entries('conversation.chat.toolview')
|
const entries = b.slots.entries('conversation.chat.toolview')
|
||||||
expect(entries.map((e) => e.options.key)).toEqual(['bash', 'todo_write'])
|
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function snapshotWith(
|
|||||||
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||||
openState: 'open', openError: null,
|
openState: 'open', openError: null,
|
||||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||||
} as ConversationSnapshot
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
|||||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||||
const subs = new Set<() => void>()
|
const subs = new Set<() => void>()
|
||||||
return {
|
return {
|
||||||
set(next: Partial<ConversationSnapshot>) {
|
set: (next: Partial<ConversationSnapshot>) => {
|
||||||
snap = { ...snap, ...next }
|
snap = { ...snap, ...next }
|
||||||
for (const fn of [...subs]) fn()
|
for (const fn of [...subs]) fn()
|
||||||
},
|
},
|
||||||
@@ -100,9 +100,9 @@ describe('StatsLine', () => {
|
|||||||
render(<Counting {...props(source)} />)
|
render(<Counting {...props(source)} />)
|
||||||
const before = renders
|
const before = renders
|
||||||
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
|
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
|
||||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }))
|
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) })
|
||||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }))
|
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) })
|
||||||
act(() => set({ running: true }))
|
act(() => { set({ running: true }) })
|
||||||
expect(renders).toBe(before)
|
expect(renders).toBe(before)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -128,7 +128,7 @@ describe('bash sample row', () => {
|
|||||||
},
|
},
|
||||||
current: undefined,
|
current: undefined,
|
||||||
phase: 'ready',
|
phase: 'ready',
|
||||||
} as SessionListState)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowProps = (sessionId: SessionId, over?: {
|
const rowProps = (sessionId: SessionId, over?: {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
|||||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||||
} as ConversationSnapshot
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||||
@@ -108,6 +108,10 @@ async function bench(nodes: ToolResultNode[]) {
|
|||||||
return info
|
return info
|
||||||
},
|
},
|
||||||
maybeProvideInfo(id: string | undefined) {
|
maybeProvideInfo(id: string | undefined) {
|
||||||
|
// `this` inside an object-literal method is any under strict lint; the
|
||||||
|
// fake resolves through its own provideInfo above.
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
|
||||||
|
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
||||||
},
|
},
|
||||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||||
@@ -252,7 +256,7 @@ describe('registrant load-order seam', () => {
|
|||||||
children: {
|
children: {
|
||||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||||
'details': { kind: 'single', scope: 'session' },
|
'details': { kind: 'single', scope: 'session' },
|
||||||
},
|
},
|
||||||
}, AppRoot)
|
}, AppRoot)
|
||||||
|
|
||||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { Profiler } from 'react'
|
import { Profiler } from 'react'
|
||||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||||
import type {
|
import type {
|
||||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
|
||||||
|
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
@@ -39,7 +40,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
|||||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||||
const subs = new Set<() => void>()
|
const subs = new Set<() => void>()
|
||||||
return {
|
return {
|
||||||
set(next: Partial<ConversationSnapshot>) {
|
set: (next: Partial<ConversationSnapshot>) => {
|
||||||
snap = { ...snap, ...next }
|
snap = { ...snap, ...next }
|
||||||
for (const fn of [...subs]) fn()
|
for (const fn of [...subs]) fn()
|
||||||
},
|
},
|
||||||
@@ -104,8 +105,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
|||||||
useSession: bindSnapshotSelector(source),
|
useSession: bindSnapshotSelector(source),
|
||||||
useSessions: emptySessions(),
|
useSessions: emptySessions(),
|
||||||
useWorkspaces: emptyWorkspaces(),
|
useWorkspaces: emptyWorkspaces(),
|
||||||
useInput: (() => { throw new Error('unused') }) as never,
|
useInput: (() => { throw new Error('unused') }),
|
||||||
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
inputActions: { setDraft: () => {}, submit: () => {} },
|
||||||
useStore: bindSnapshotSelector(chat),
|
useStore: bindSnapshotSelector(chat),
|
||||||
actions: chat.actions,
|
actions: chat.actions,
|
||||||
renderSlot,
|
renderSlot,
|
||||||
@@ -124,9 +125,9 @@ describe('chat-flow derivation', () => {
|
|||||||
assistant(5, 'found'), toolResult(6, 'c'),
|
assistant(5, 'found'), toolResult(6, 'c'),
|
||||||
]
|
]
|
||||||
const items = deriveChatFlow(nodes)
|
const items = deriveChatFlow(nodes)
|
||||||
expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
|
expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
|
||||||
const group = items[2]!
|
const group = items[2]!
|
||||||
expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b'])
|
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
|
||||||
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
|
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
|
||||||
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||||
})
|
})
|
||||||
@@ -155,7 +156,7 @@ describe('ChatView', () => {
|
|||||||
fireEvent.scroll(scroller)
|
fireEvent.scroll(scroller)
|
||||||
fireEvent.click(view.getByText('加载更早'))
|
fireEvent.click(view.getByText('加载更早'))
|
||||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||||
act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }))
|
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
||||||
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
|
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -240,10 +241,10 @@ describe('ChatView', () => {
|
|||||||
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
||||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||||
let rowRenders = 0
|
let rowRenders = 0
|
||||||
h.props.renderSlot = (((_key: string, _owner: object) => {
|
h.props.renderSlot = ((_key: string, _owner: object) => {
|
||||||
rowRenders += 1
|
rowRenders += 1
|
||||||
return <div data-testid="counting-row" />
|
return <div data-testid="counting-row" />
|
||||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
})
|
||||||
const view = render(<h.ChatView {...h.props} />)
|
const view = render(<h.ChatView {...h.props} />)
|
||||||
expect(view.getByTestId('counting-row')).toBeTruthy()
|
expect(view.getByTestId('counting-row')).toBeTruthy()
|
||||||
const afterMount = rowRenders
|
const afterMount = rowRenders
|
||||||
@@ -270,7 +271,7 @@ describe('ChatView', () => {
|
|||||||
fireEvent.click(view.getByText('run a'))
|
fireEvent.click(view.getByText('run a'))
|
||||||
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
|
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
|
||||||
expect(view.container.querySelector('[data-selected]')).toBeNull()
|
expect(view.container.querySelector('[data-selected]')).toBeNull()
|
||||||
act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }))
|
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||||||
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
|
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -284,10 +285,10 @@ describe('ChatView', () => {
|
|||||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||||
const calls: { key: string; entryKey?: string }[] = []
|
const calls: { key: string; entryKey?: string }[] = []
|
||||||
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||||
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
|
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
|
||||||
return opts?.fallback ?? null
|
return opts?.fallback ?? null
|
||||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
})
|
||||||
render(<h.ChatView {...h.props} />)
|
render(<h.ChatView {...h.props} />)
|
||||||
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
|
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
|
||||||
// name, and the fallback (GenericToolCard) renders on an empty ledger.
|
// name, and the fallback (GenericToolCard) renders on an empty ledger.
|
||||||
@@ -306,10 +307,10 @@ describe('ChatView', () => {
|
|||||||
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
||||||
fireEvent.click(view.getByText('加载更早'))
|
fireEvent.click(view.getByText('加载更早'))
|
||||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
||||||
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }))
|
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
|
||||||
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
|
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
|
||||||
// A new trailing user bubble (own words) force-scrolls to the bottom.
|
// A new trailing user bubble (own words) force-scrolls to the bottom.
|
||||||
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }))
|
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
|
||||||
expect(scroller.scrollTop).toBe(1600)
|
expect(scroller.scrollTop).toBe(1600)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -324,7 +325,7 @@ describe('ChatView', () => {
|
|||||||
const backButton = view.getByLabelText('回到底部')
|
const backButton = view.getByLabelText('回到底部')
|
||||||
expect(backButton).toBeTruthy()
|
expect(backButton).toBeTruthy()
|
||||||
// Streaming growth must NOT drag a scrolled-away reader down.
|
// Streaming growth must NOT drag a scrolled-away reader down.
|
||||||
act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }))
|
act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) })
|
||||||
expect(scroller.scrollTop).toBe(100)
|
expect(scroller.scrollTop).toBe(100)
|
||||||
fireEvent.click(backButton)
|
fireEvent.click(backButton)
|
||||||
expect(scroller.scrollTop).toBe(1000)
|
expect(scroller.scrollTop).toBe(1000)
|
||||||
@@ -337,7 +338,7 @@ describe('ChatView', () => {
|
|||||||
const view = render(<h.ChatView {...h.props} />)
|
const view = render(<h.ChatView {...h.props} />)
|
||||||
fireEvent.click(view.getByText('加载更早'))
|
fireEvent.click(view.getByText('加载更早'))
|
||||||
expect(h.loadOlder).toHaveBeenCalledTimes(1)
|
expect(h.loadOlder).toHaveBeenCalledTimes(1)
|
||||||
act(() => h.set({ loadingOlder: true }))
|
act(() => { h.set({ loadingOlder: true }) })
|
||||||
expect(view.getByText('加载中…')).toBeTruthy()
|
expect(view.getByText('加载中…')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ afterEach(cleanup)
|
|||||||
|
|
||||||
describe('tails', () => {
|
describe('tails', () => {
|
||||||
it('node-half apply is an intentional no-op', () => {
|
it('node-half apply is an intentional no-op', () => {
|
||||||
expect(nodeApply()).toBeUndefined()
|
expect(() => { nodeApply() }).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||||
@@ -83,7 +83,7 @@ describe('tails', () => {
|
|||||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||||
current: undefined,
|
current: undefined,
|
||||||
phase: 'ready',
|
phase: 'ready',
|
||||||
} as SessionListState)
|
})
|
||||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function snapshotBase(): ConversationSnapshot {
|
|||||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||||
} as ConversationSnapshot
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('render branch tails', () => {
|
describe('render branch tails', () => {
|
||||||
@@ -73,11 +73,11 @@ describe('render branch tails', () => {
|
|||||||
const view = render(
|
const view = render(
|
||||||
<DetailsPanel
|
<DetailsPanel
|
||||||
sessionId={SID}
|
sessionId={SID}
|
||||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||||
useSessions={bindSnapshotSelector(emptyList)}
|
useSessions={bindSnapshotSelector(emptyList)}
|
||||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||||
useInput={(() => { throw new Error('unused') }) as never}
|
useInput={(() => { throw new Error('unused') })}
|
||||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||||
useStore={bindSnapshotSelector(chat)}
|
useStore={bindSnapshotSelector(chat)}
|
||||||
actions={chat.actions}
|
actions={chat.actions}
|
||||||
closeDetails={vi.fn()}
|
closeDetails={vi.fn()}
|
||||||
@@ -108,11 +108,11 @@ describe('render branch tails', () => {
|
|||||||
const view = render(
|
const view = render(
|
||||||
<DetailsPanel
|
<DetailsPanel
|
||||||
sessionId={SID}
|
sessionId={SID}
|
||||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||||
useSessions={bindSnapshotSelector(emptyList)}
|
useSessions={bindSnapshotSelector(emptyList)}
|
||||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||||
useInput={(() => { throw new Error('unused') }) as never}
|
useInput={(() => { throw new Error('unused') })}
|
||||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||||
useStore={bindSnapshotSelector(chat)}
|
useStore={bindSnapshotSelector(chat)}
|
||||||
actions={chat.actions}
|
actions={chat.actions}
|
||||||
closeDetails={vi.fn()}
|
closeDetails={vi.fn()}
|
||||||
|
|||||||
@@ -79,11 +79,11 @@ function bench(over?: BenchOptions) {
|
|||||||
useSession: bindSnapshotSelector(session),
|
useSession: bindSnapshotSelector(session),
|
||||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||||
})) as InputBarProps['useSessions'],
|
})),
|
||||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||||
items: [], state: 'idle', phase: 'ready', error: null,
|
items: [], state: 'idle', phase: 'ready', error: null,
|
||||||
baselinesReady: true, recentWorkspaceId: undefined,
|
baselinesReady: true, recentWorkspaceId: undefined,
|
||||||
})) as InputBarProps['useWorkspaces'],
|
})),
|
||||||
useInput: bindSnapshotSelector(shell.state),
|
useInput: bindSnapshotSelector(shell.state),
|
||||||
inputActions: shell.actions,
|
inputActions: shell.actions,
|
||||||
keyboard: shell,
|
keyboard: shell,
|
||||||
@@ -212,7 +212,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
|||||||
const { textarea, wiring } = bench()
|
const { textarea, wiring } = bench()
|
||||||
fireEvent.change(textarea, { target: { value: 'typed' } })
|
fireEvent.change(textarea, { target: { value: 'typed' } })
|
||||||
expect(wiring.state.getSnapshot().draft).toBe('typed')
|
expect(wiring.state.getSnapshot().draft).toBe('typed')
|
||||||
expect((textarea as HTMLTextAreaElement).value).toBe('typed')
|
expect((textarea).value).toBe('typed')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||||
@@ -364,10 +364,10 @@ describe('placeholder chrome and control seats', () => {
|
|||||||
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
||||||
expect(view.getByTestId('model-entry')).toBeTruthy()
|
expect(view.getByTestId('model-entry')).toBeTruthy()
|
||||||
// The bar hands its chrome disable state to the filling entry.
|
// The bar hands its chrome disable state to the filling entry.
|
||||||
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true)
|
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
||||||
cleanup()
|
cleanup()
|
||||||
const live = bench({ running: true })
|
const live = bench({ running: true })
|
||||||
expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true)
|
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disabled locks the Access placeholder and attach control (running does not)', () => {
|
it('disabled locks the Access placeholder and attach control (running does not)', () => {
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
|||||||
useSession: bindSnapshotSelector(session),
|
useSession: bindSnapshotSelector(session),
|
||||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||||
})) as InputBarProps['useSessions'],
|
})),
|
||||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||||
items: [], state: 'idle', phase: 'ready', error: null,
|
items: [], state: 'idle', phase: 'ready', error: null,
|
||||||
baselinesReady: true, recentWorkspaceId: undefined,
|
baselinesReady: true, recentWorkspaceId: undefined,
|
||||||
})) as InputBarProps['useWorkspaces'],
|
})),
|
||||||
useInput: bindSnapshotSelector(shell.state),
|
useInput: bindSnapshotSelector(shell.state),
|
||||||
inputActions: shell.actions,
|
inputActions: shell.actions,
|
||||||
keyboard: shell,
|
keyboard: shell,
|
||||||
@@ -88,7 +88,7 @@ describe('matrix row: claimed', () => {
|
|||||||
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
|
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
|
||||||
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
||||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
|
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
|
||||||
expect((textarea as HTMLTextAreaElement).readOnly).toBe(false)
|
expect((textarea).readOnly).toBe(false)
|
||||||
// Free editing beyond the token: hint drops, claim holds.
|
// Free editing beyond the token: hint drops, claim holds.
|
||||||
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
|
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
|
||||||
expect(shell.snapshot.phase).toBe('claimed')
|
expect(shell.snapshot.phase).toBe('claimed')
|
||||||
@@ -104,7 +104,7 @@ describe('matrix row: claimed', () => {
|
|||||||
expect(sink).not.toHaveBeenCalled()
|
expect(sink).not.toHaveBeenCalled()
|
||||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
|
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
|
||||||
// Commit: draft cleared, notice surfaced, back to plain.
|
// Commit: draft cleared, notice surfaced, back to plain.
|
||||||
await vi.waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe('') })
|
await vi.waitFor(() => { expect((textarea).value).toBe('') })
|
||||||
expect(view.getByText('完成')).toBeTruthy()
|
expect(view.getByText('完成')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ describe('matrix row: submitting', () => {
|
|||||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||||
expect(shell.snapshot.phase).toBe('submitting')
|
expect(shell.snapshot.phase).toBe('submitting')
|
||||||
expect(shell.snapshot.claim).toBeDefined()
|
expect(shell.snapshot.claim).toBeDefined()
|
||||||
expect((textarea as HTMLTextAreaElement).readOnly).toBe(true)
|
expect((textarea).readOnly).toBe(true)
|
||||||
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
|
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
|
||||||
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
|
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
|
||||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
|
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
|
||||||
@@ -145,7 +145,7 @@ describe('matrix row: submitting', () => {
|
|||||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
|
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
|
||||||
act(() => { rejectSubmit(new Error('执行失败')) })
|
act(() => { rejectSubmit(new Error('执行失败')) })
|
||||||
await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
|
await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
|
||||||
expect((first.textarea as HTMLTextAreaElement).value).toBe('/goal ')
|
expect((first.textarea).value).toBe('/goal ')
|
||||||
expect(first.view.getByText('执行失败')).toBeTruthy()
|
expect(first.view.getByText('执行失败')).toBeTruthy()
|
||||||
cleanup()
|
cleanup()
|
||||||
// Drift: typing during flight wins; no restore, plain, notice only.
|
// Drift: typing during flight wins; no restore, plain, notice only.
|
||||||
@@ -157,7 +157,7 @@ describe('matrix row: submitting', () => {
|
|||||||
act(() => { second.shell.setDraft('用户飞行中打的新稿') })
|
act(() => { second.shell.setDraft('用户飞行中打的新稿') })
|
||||||
act(() => { rejectSubmit(new Error('晚到失败')) })
|
act(() => { rejectSubmit(new Error('晚到失败')) })
|
||||||
await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
|
await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
|
||||||
expect((second.textarea as HTMLTextAreaElement).value).toBe('用户飞行中打的新稿')
|
expect((second.textarea).value).toBe('用户飞行中打的新稿')
|
||||||
expect(second.view.getByText('晚到失败')).toBeTruthy()
|
expect(second.view.getByText('晚到失败')).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -165,14 +165,14 @@ describe('matrix row: submitting', () => {
|
|||||||
describe('matrix row: locked (session disabled)', () => {
|
describe('matrix row: locked (session disabled)', () => {
|
||||||
it('disables the textarea and chrome; the machine currency is untouched', () => {
|
it('disables the textarea and chrome; the machine currency is untouched', () => {
|
||||||
const { view, textarea, shell } = bench({ disabled: true })
|
const { view, textarea, shell } = bench({ disabled: true })
|
||||||
expect((textarea as HTMLTextAreaElement).disabled).toBe(true)
|
expect((textarea).disabled).toBe(true)
|
||||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||||
expect(shell.snapshot.phase).toBe('plain')
|
expect(shell.snapshot.phase).toBe('plain')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
|
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
|
||||||
const { textarea, sink } = bench({ running: true })
|
const { textarea, sink } = bench({ running: true })
|
||||||
expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
|
expect((textarea).disabled).toBe(false)
|
||||||
fireEvent.change(textarea, { target: { value: '排队' } })
|
fireEvent.change(textarea, { target: { value: '排队' } })
|
||||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||||
expect(sink).toHaveBeenCalledWith('排队', 'queue')
|
expect(sink).toHaveBeenCalledWith('排队', 'queue')
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Context } from 'cordis'
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||||
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
||||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||||
@@ -100,7 +99,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
|||||||
await ctx.plugin(SlashService).await()
|
await ctx.plugin(SlashService).await()
|
||||||
const slash = ctx.get('slash') as SlashService
|
const slash = ctx.get('slash') as SlashService
|
||||||
register?.(slash)
|
register?.(slash)
|
||||||
const actx = sessions.scope(sessionId)! as ClientContext
|
const actx = sessions.scope(sessionId)!
|
||||||
const controller = slash.sessionOf(actx)
|
const controller = slash.sessionOf(actx)
|
||||||
const sink = vi.fn()
|
const sink = vi.fn()
|
||||||
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
|
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
|
||||||
@@ -121,11 +120,11 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
|||||||
useSession: bindSnapshotSelector(sessionStore),
|
useSession: bindSnapshotSelector(sessionStore),
|
||||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||||
})) as InputBarProps['useSessions'],
|
})),
|
||||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||||
items: [], state: 'idle', phase: 'ready', error: null,
|
items: [], state: 'idle', phase: 'ready', error: null,
|
||||||
baselinesReady: true, recentWorkspaceId: undefined,
|
baselinesReady: true, recentWorkspaceId: undefined,
|
||||||
})) as InputBarProps['useWorkspaces'],
|
})),
|
||||||
useInput: bindSnapshotSelector(shell.state),
|
useInput: bindSnapshotSelector(shell.state),
|
||||||
inputActions: shell.actions,
|
inputActions: shell.actions,
|
||||||
keyboard: shell,
|
keyboard: shell,
|
||||||
@@ -134,7 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
|||||||
variant: 'composer',
|
variant: 'composer',
|
||||||
}
|
}
|
||||||
const view = render(<InputBar {...barProps} />)
|
const view = render(<InputBar {...barProps} />)
|
||||||
const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement
|
const textarea = view.container.querySelector('textarea')!
|
||||||
const type = (text: string): void => {
|
const type = (text: string): void => {
|
||||||
fireEvent.change(textarea, { target: { value: text } })
|
fireEvent.change(textarea, { target: { value: text } })
|
||||||
}
|
}
|
||||||
@@ -145,7 +144,7 @@ async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
|
|||||||
const execute = vi.fn(executeImpl ?? ((line: string) =>
|
const execute = vi.fn(executeImpl ?? ((line: string) =>
|
||||||
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
|
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
|
||||||
const { source, executed } = commandSource(COMMANDS, execute)
|
const { source, executed } = commandSource(COMMANDS, execute)
|
||||||
const base = await scopedBench((slash) => { slash.registerSource(source as never) })
|
const base = await scopedBench((slash) => { slash.registerSource(source) })
|
||||||
return { ...base, execute, executed }
|
return { ...base, execute, executed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ function DetailsColumn(props: { children?: ReactNode }) {
|
|||||||
return <div className={css.detailsCol}>{props.children}</div>
|
return <div className={css.detailsCol}>{props.children}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */
|
/**
|
||||||
|
* One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin.
|
||||||
|
* `side` keys the hover-reveal CSS to the owning column.
|
||||||
|
*/
|
||||||
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
const origin = useRef(0)
|
const origin = useRef(0)
|
||||||
@@ -86,7 +89,7 @@ export function AppFrame({
|
|||||||
actions,
|
actions,
|
||||||
renderSlot,
|
renderSlot,
|
||||||
}: AppFrameProps) {
|
}: AppFrameProps) {
|
||||||
const panels = useStore((s) => s)
|
const panels = useStore(s => s)
|
||||||
const frameRef = useRef<HTMLDivElement | null>(null)
|
const frameRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [viewport, setViewport] = useState(() => window.innerWidth)
|
const [viewport, setViewport] = useState(() => window.innerWidth)
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ let fireResize: (() => void) | null = null
|
|||||||
class ResizeObserverStub {
|
class ResizeObserverStub {
|
||||||
#cb: ResizeObserverCallback
|
#cb: ResizeObserverCallback
|
||||||
constructor(cb: ResizeObserverCallback) { this.#cb = cb }
|
constructor(cb: ResizeObserverCallback) { this.#cb = cb }
|
||||||
observe(): void { fireResize = () => { this.#cb([], this as unknown as ResizeObserver) } }
|
observe(): void { fireResize = () => { this.#cb([], this) } }
|
||||||
unobserve(): void {}
|
unobserve(): void {}
|
||||||
disconnect(): void { fireResize = null }
|
disconnect(): void { fireResize = null }
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ let frameWidth = 1920
|
|||||||
|
|
||||||
/** Test-local selector hook over a framework-neutral store instance. */
|
/** Test-local selector hook over a framework-neutral store instance. */
|
||||||
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
|
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
|
||||||
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
|
return function useSelector<S>(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountFrame() {
|
function mountFrame() {
|
||||||
@@ -118,7 +118,7 @@ beforeEach(() => {
|
|||||||
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
|
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
|
||||||
window.innerWidth = frameWidth
|
window.innerWidth = frameWidth
|
||||||
Element.prototype.getBoundingClientRect = function () {
|
Element.prototype.getBoundingClientRect = function () {
|
||||||
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
|
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) }
|
||||||
}
|
}
|
||||||
// jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
|
// jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
|
||||||
const captured = new WeakSet<Element>()
|
const captured = new WeakSet<Element>()
|
||||||
@@ -143,12 +143,12 @@ describe('AppFrame', () => {
|
|||||||
const { slotCalls, getByTestId } = mountFrame()
|
const { slotCalls, getByTestId } = mountFrame()
|
||||||
expect(getByTestId('center-content')).toBeTruthy()
|
expect(getByTestId('center-content')).toBeTruthy()
|
||||||
expect(getByTestId('details-content')).toBeTruthy()
|
expect(getByTestId('details-content')).toBeTruthy()
|
||||||
const keys = slotCalls.map((c) => c.key)
|
const keys = slotCalls.map(c => c.key)
|
||||||
expect(keys).toContain('conversation')
|
expect(keys).toContain('conversation')
|
||||||
expect(keys).toContain('details')
|
expect(keys).toContain('details')
|
||||||
expect(keys).not.toContain('conversation.empty')
|
expect(keys).not.toContain('conversation.empty')
|
||||||
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({})
|
expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
|
||||||
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
|
expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the conversation slot mounted while no session is current', () => {
|
it('keeps the conversation slot mounted while no session is current', () => {
|
||||||
@@ -157,7 +157,7 @@ describe('AppFrame', () => {
|
|||||||
sessionMode.current = false
|
sessionMode.current = false
|
||||||
const { slotCalls, getByTestId } = mountFrame()
|
const { slotCalls, getByTestId } = mountFrame()
|
||||||
expect(getByTestId('center-content')).toBeTruthy()
|
expect(getByTestId('center-content')).toBeTruthy()
|
||||||
expect(slotCalls.map((c) => c.key)).toContain('conversation')
|
expect(slotCalls.map(c => c.key)).toContain('conversation')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders both column occupants before baselines settle (no loading gate)', () => {
|
it('renders both column occupants before baselines settle (no loading gate)', () => {
|
||||||
@@ -165,13 +165,13 @@ describe('AppFrame', () => {
|
|||||||
// pending rendering — both occupants mount from first paint.
|
// pending rendering — both occupants mount from first paint.
|
||||||
baselinesReady.current = false
|
baselinesReady.current = false
|
||||||
const { slotCalls } = mountFrame()
|
const { slotCalls } = mountFrame()
|
||||||
expect(slotCalls.map((c) => c.key)).toContain('conversation')
|
expect(slotCalls.map(c => c.key)).toContain('conversation')
|
||||||
expect(slotCalls.map((c) => c.key)).toContain('details')
|
expect(slotCalls.map(c => c.key)).toContain('details')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sidebar slot receives live concession output as owner props', () => {
|
it('sidebar slot receives live concession output as owner props', () => {
|
||||||
const { slotCalls } = mountFrame()
|
const { slotCalls } = mountFrame()
|
||||||
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
|
expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sidebar drag widens through rAF-batched pointer moves', () => {
|
it('sidebar drag widens through rAF-batched pointer moves', () => {
|
||||||
@@ -211,7 +211,7 @@ describe('AppFrame', () => {
|
|||||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
|
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
|
||||||
expect(getByTestId('sidebar-content')).toBeTruthy()
|
expect(getByTestId('sidebar-content')).toBeTruthy()
|
||||||
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
|
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
|
||||||
const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)!
|
const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
|
||||||
expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
|
expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function Button({ variant = 'ghost', size = 'md', icon, className, childr
|
|||||||
variant?: ButtonVariant
|
variant?: ButtonVariant
|
||||||
size?: 'md' | 'sm'
|
size?: 'md' | 'sm'
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
className?: string
|
className?: string | undefined
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -158,63 +158,63 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
|||||||
// (open/toggle) after onSelect.
|
// (open/toggle) after onSelect.
|
||||||
onClick={(e) => { e.stopPropagation() }}
|
onClick={(e) => { e.stopPropagation() }}
|
||||||
>
|
>
|
||||||
{items.map(entry => {
|
{items.map((entry) => {
|
||||||
if (isSeparator(entry)) {
|
if (isSeparator(entry)) {
|
||||||
return <div key={entry.id} className={css.separator} role="separator" />
|
return <div key={entry.id} className={css.separator} role="separator" />
|
||||||
}
|
}
|
||||||
if (isLabel(entry)) {
|
if (isLabel(entry)) {
|
||||||
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
|
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
|
||||||
}
|
}
|
||||||
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||||
const subOpen = hasSub && openSubmenuId === entry.id
|
const subOpen = hasSub && openSubmenuId === entry.id
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className={css.itemWrap}
|
className={css.itemWrap}
|
||||||
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||||
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
|
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
|
||||||
disabled={entry.disabled}
|
disabled={entry.disabled}
|
||||||
aria-haspopup={hasSub ? 'menu' : undefined}
|
aria-haspopup={hasSub ? 'menu' : undefined}
|
||||||
aria-expanded={hasSub ? subOpen : undefined}
|
aria-expanded={hasSub ? subOpen : undefined}
|
||||||
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (hasSub) {
|
if (hasSub) {
|
||||||
setOpenSubmenuId(entry.id)
|
setOpenSubmenuId(entry.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onSelect(entry.id)
|
onSelect(entry.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||||
<span className={css.itemLabel}>{entry.label}</span>
|
<span className={css.itemLabel}>{entry.label}</span>
|
||||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||||
</button>
|
</button>
|
||||||
{subOpen && entry.submenu !== undefined && (
|
{subOpen && entry.submenu !== undefined && (
|
||||||
<div className={css.submenu} role="menu">
|
<div className={css.submenu} role="menu">
|
||||||
{entry.submenu.map(sub => (
|
{entry.submenu.map(sub => (
|
||||||
<button
|
<button
|
||||||
key={sub.id}
|
key={sub.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className={css.item}
|
className={css.item}
|
||||||
disabled={sub.disabled}
|
disabled={sub.disabled}
|
||||||
onClick={() => { onSelect(sub.id) }}
|
onClick={() => { onSelect(sub.id) }}
|
||||||
>
|
>
|
||||||
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
|
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
|
||||||
<span className={css.itemLabel}>{sub.label}</span>
|
<span className={css.itemLabel}>{sub.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
})}
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ interface AnchorProps {
|
|||||||
* Attach a hover/focus tooltip to an anchor element.
|
* Attach a hover/focus tooltip to an anchor element.
|
||||||
* @param props.label - bubble text.
|
* @param props.label - bubble text.
|
||||||
* @param props.side - placement relative to the anchor (default 'right').
|
* @param props.side - placement relative to the anchor (default 'right').
|
||||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
|
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||||
|
* toggling never remounts it (which would cut its CSS transitions).
|
||||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -544,14 +544,14 @@ export const IconApiOutline14 = ({ size = 14, className }: IconProps) => (
|
|||||||
<path transform="translate(0.6689 1.073)" d="M11.4818 5.57813C11.4818 4.45301 11.4807 3.66237 11.4075 3.05908C11.3359 2.46953 11.2024 2.13852 10.9939 1.89441C10.9247 1.81341 10.8493 1.73801 10.7683 1.66882C10.5242 1.46033 10.1932 1.32686 9.60364 1.25525C9.00034 1.18198 8.20974 1.18091 7.0846 1.18091L5.57813 1.18091C4.45301 1.18091 3.66238 1.18198 3.05908 1.25525C2.46953 1.32686 2.13852 1.46033 1.89441 1.66882C1.81341 1.73801 1.73801 1.81341 1.66882 1.89441C1.46033 2.13852 1.32686 2.46953 1.25525 3.05908C1.18198 3.66238 1.18091 4.45301 1.18091 5.57813L1.18091 6.2771C1.18091 7.40218 1.18197 8.19288 1.25525 8.79614C1.32687 9.38553 1.46036 9.71674 1.66882 9.96082C1.73797 10.0417 1.81347 10.1173 1.89441 10.1864C2.13851 10.3948 2.46965 10.5275 3.05908 10.5991C3.66238 10.6724 4.45298 10.6735 5.57813 10.6735L7.0846 10.6735C8.20977 10.6735 9.00033 10.6724 9.60364 10.5991C10.1931 10.5275 10.5242 10.3948 10.7683 10.1864C10.8493 10.1173 10.9247 10.0417 10.9939 9.96082C11.2024 9.71674 11.3358 9.38553 11.4075 8.79614C11.4808 8.19288 11.4818 7.40218 11.4818 6.2771L11.4818 5.57813ZM12.6627 6.2771C12.6627 7.37222 12.6637 8.247 12.5798 8.93799C12.4942 9.64284 12.3133 10.2359 11.8928 10.7282C11.7834 10.8562 11.6637 10.9751 11.5356 11.0845C11.0434 11.5049 10.4511 11.6867 9.74634 11.7723C9.05525 11.8563 8.17999 11.8552 7.0846 11.8552L5.57813 11.8552C4.48273 11.8552 3.60747 11.8563 2.91638 11.7723C2.21157 11.6867 1.61933 11.5049 1.12708 11.0845C0.99901 10.9751 0.879281 10.8562 0.769898 10.7282C0.349454 10.2359 0.168506 9.64284 0.0828864 8.93799C-0.00101964 8.247 4.88512e-07 7.37222 6.47206e-07 6.2771L6.47206e-07 5.57813C6.47206e-07 4.48273 -0.00106163 3.60747 0.0828864 2.91638C0.168502 2.21168 0.349594 1.61928 0.769898 1.12708C0.879302 0.998981 0.998981 0.879302 1.12708 0.769898C1.61928 0.349594 2.21168 0.168502 2.91638 0.0828864C3.60747 -0.00106163 4.48273 6.47206e-07 5.57813 6.47206e-07L7.0846 6.47206e-07C8.17999 6.47206e-07 9.05525 -0.00106163 9.74634 0.0828864C10.451 0.168505 11.0434 0.349587 11.5356 0.769898C11.6637 0.879302 11.7834 0.998981 11.8928 1.12708C12.3131 1.61928 12.4942 2.21169 12.5798 2.91638C12.6638 3.60747 12.6627 4.48273 12.6627 5.57813L12.6627 6.2771Z" fill="currentColor"/>
|
<path transform="translate(0.6689 1.073)" d="M11.4818 5.57813C11.4818 4.45301 11.4807 3.66237 11.4075 3.05908C11.3359 2.46953 11.2024 2.13852 10.9939 1.89441C10.9247 1.81341 10.8493 1.73801 10.7683 1.66882C10.5242 1.46033 10.1932 1.32686 9.60364 1.25525C9.00034 1.18198 8.20974 1.18091 7.0846 1.18091L5.57813 1.18091C4.45301 1.18091 3.66238 1.18198 3.05908 1.25525C2.46953 1.32686 2.13852 1.46033 1.89441 1.66882C1.81341 1.73801 1.73801 1.81341 1.66882 1.89441C1.46033 2.13852 1.32686 2.46953 1.25525 3.05908C1.18198 3.66238 1.18091 4.45301 1.18091 5.57813L1.18091 6.2771C1.18091 7.40218 1.18197 8.19288 1.25525 8.79614C1.32687 9.38553 1.46036 9.71674 1.66882 9.96082C1.73797 10.0417 1.81347 10.1173 1.89441 10.1864C2.13851 10.3948 2.46965 10.5275 3.05908 10.5991C3.66238 10.6724 4.45298 10.6735 5.57813 10.6735L7.0846 10.6735C8.20977 10.6735 9.00033 10.6724 9.60364 10.5991C10.1931 10.5275 10.5242 10.3948 10.7683 10.1864C10.8493 10.1173 10.9247 10.0417 10.9939 9.96082C11.2024 9.71674 11.3358 9.38553 11.4075 8.79614C11.4808 8.19288 11.4818 7.40218 11.4818 6.2771L11.4818 5.57813ZM12.6627 6.2771C12.6627 7.37222 12.6637 8.247 12.5798 8.93799C12.4942 9.64284 12.3133 10.2359 11.8928 10.7282C11.7834 10.8562 11.6637 10.9751 11.5356 11.0845C11.0434 11.5049 10.4511 11.6867 9.74634 11.7723C9.05525 11.8563 8.17999 11.8552 7.0846 11.8552L5.57813 11.8552C4.48273 11.8552 3.60747 11.8563 2.91638 11.7723C2.21157 11.6867 1.61933 11.5049 1.12708 11.0845C0.99901 10.9751 0.879281 10.8562 0.769898 10.7282C0.349454 10.2359 0.168506 9.64284 0.0828864 8.93799C-0.00101964 8.247 4.88512e-07 7.37222 6.47206e-07 6.2771L6.47206e-07 5.57813C6.47206e-07 4.48273 -0.00106163 3.60747 0.0828864 2.91638C0.168502 2.21168 0.349594 1.61928 0.769898 1.12708C0.879302 0.998981 0.998981 0.879302 1.12708 0.769898C1.61928 0.349594 2.21168 0.168502 2.91638 0.0828864C3.60747 -0.00106163 4.48273 6.47206e-07 5.57813 6.47206e-07L7.0846 6.47206e-07C8.17999 6.47206e-07 9.05525 -0.00106163 9.74634 0.0828864C10.451 0.168505 11.0434 0.349587 11.5356 0.769898C11.6637 0.879302 11.7834 0.998981 11.8928 1.12708C12.3131 1.61928 12.4942 2.21169 12.5798 2.91638C12.6638 3.60747 12.6627 4.48273 12.6627 5.57813L12.6627 6.2771Z" fill="currentColor"/>
|
||||||
<path transform="translate(0.6689 1.073)" d="M6.02607 5.50955L6.44306 5.9274L3.84284 8.52762L3.425 8.11063L3.00715 7.69278L4.77253 5.9274L3.00715 4.16202L3.84284 3.32633L6.02607 5.50955Z" fill="currentColor"/>
|
<path transform="translate(0.6689 1.073)" d="M6.02607 5.50955L6.44306 5.9274L3.84284 8.52762L3.425 8.11063L3.00715 7.69278L4.77253 5.9274L3.00715 4.16202L3.84284 3.32633L6.02607 5.50955Z" fill="currentColor"/>
|
||||||
<path transform="translate(0.6689 1.073)" d="M9.23789 7.35397L9.23789 8.53488L6.96238 8.53488L6.96238 7.35397L9.23789 7.35397Z" fill="currentColor"/>
|
<path transform="translate(0.6689 1.073)" d="M9.23789 7.35397L9.23789 8.53488L6.96238 8.53488L6.96238 7.35397L9.23789 7.35397Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
/** ic_ds_personalization_outline_16 (figma extract) */
|
/** ic_ds_personalization_outline_16 (figma extract) */
|
||||||
export const IconPersonalizationOutline16 = ({ size = 16, className }: IconProps) => (
|
export const IconPersonalizationOutline16 = ({ size = 16, className }: IconProps) => (
|
||||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||||
<path transform="translate(1.292 1.3)" d="M10.3232 9.18164C11.2868 9.18164 12.0985 9.82833 12.3506 10.7109L13.415 10.7109L13.415 11.8711L12.3496 11.8711C12.0971 12.7532 11.2864 13.3994 10.3232 13.3994C9.36031 13.3992 8.55012 12.7531 8.29785 11.8711L0 11.8711L0 10.7109L8.29688 10.7109C8.54876 9.82845 9.35988 9.18186 10.3232 9.18164ZM10.3232 10.3418C9.7999 10.3421 9.37534 10.7667 9.375 11.29C9.375 11.8137 9.79969 12.239 10.3232 12.2393C10.847 12.2393 11.2725 11.8138 11.2725 11.29C11.2721 10.7666 10.8468 10.3418 10.3232 10.3418ZM12.4326 11.291C12.4326 11.3549 12.4284 11.418 12.4229 11.4805C12.4287 11.4181 12.4326 11.355 12.4326 11.291ZM8.21484 11.2832C8.21484 11.2856 8.21484 11.2886 8.21484 11.291L8.21484 11.29C8.21484 11.2878 8.21484 11.2855 8.21484 11.2832ZM3.08301 4.59082C4.04605 4.59095 4.85696 5.23717 5.10938 6.11914L13.415 6.11914L13.415 7.2793L5.11035 7.2793C4.85833 8.16202 4.04648 8.80846 3.08301 8.80859C2.11972 8.80843 1.30963 8.16179 1.05762 7.2793L0 7.2793L0 6.11914L1.05762 6.11914C1.30994 5.23728 2.12006 4.59098 3.08301 4.59082ZM3.08301 5.75098C2.55962 5.75117 2.13512 6.17587 2.13477 6.69922C2.13477 7.22287 2.5594 7.64824 3.08301 7.64844C3.60665 7.64828 4.03223 7.2229 4.03223 6.69922C4.03187 6.17585 3.60643 5.75113 3.08301 5.75098ZM5.19238 6.69922C5.19238 6.763 5.18816 6.82633 5.18262 6.88867C5.18846 6.82629 5.19238 6.76313 5.19238 6.69922C5.19236 6.63495 5.18853 6.57152 5.18262 6.50879C5.18826 6.57154 5.19236 6.635 5.19238 6.69922ZM0.982422 6.52344C0.977382 6.58136 0.97463 6.63999 0.974609 6.69922C0.974609 6.75775 0.977496 6.81579 0.982422 6.87305C0.977758 6.81579 0.974609 6.75767 0.974609 6.69922C0.974628 6.64 0.977618 6.58142 0.982422 6.52344ZM10.3232 0C11.2869 0 12.0986 0.646596 12.3506 1.5293L13.415 1.5293L13.415 2.68945L12.3496 2.68945C12.363 2.64266 12.3754 2.59488 12.3857 2.54688C12.1838 3.50118 11.3376 4.21777 10.3232 4.21777C9.36037 4.21756 8.55018 3.57139 8.29785 2.68945L0 2.68945L0 1.5293L8.29688 1.5293C8.5487 0.646717 9.35981 0.00021854 10.3232 0ZM10.3232 1.16016C9.79984 1.16042 9.37524 1.58499 9.375 2.1084C9.375 2.63201 9.79969 3.05735 10.3232 3.05762C10.847 3.05762 11.2725 2.63217 11.2725 2.1084C11.2722 1.58483 10.8469 1.16016 10.3232 1.16016ZM12.4229 2.29883C12.4287 2.23641 12.4326 2.17331 12.4326 2.10938C12.4326 2.17327 12.4284 2.23638 12.4229 2.29883ZM8.21484 2.10938L8.21484 2.1084L8.21484 2.10938ZM8.22266 1.93359C8.21785 1.98897 8.21506 2.04499 8.21484 2.10156C8.21503 2.04501 8.2181 1.98902 8.22266 1.93359ZM8.22266 11.1162C8.2179 11.1713 8.21507 11.227 8.21484 11.2832C8.21504 11.227 8.21814 11.1713 8.22266 11.1162Z" fill="currentColor"/>
|
<path transform="translate(1.292 1.3)" d="M10.3232 9.18164C11.2868 9.18164 12.0985 9.82833 12.3506 10.7109L13.415 10.7109L13.415 11.8711L12.3496 11.8711C12.0971 12.7532 11.2864 13.3994 10.3232 13.3994C9.36031 13.3992 8.55012 12.7531 8.29785 11.8711L0 11.8711L0 10.7109L8.29688 10.7109C8.54876 9.82845 9.35988 9.18186 10.3232 9.18164ZM10.3232 10.3418C9.7999 10.3421 9.37534 10.7667 9.375 11.29C9.375 11.8137 9.79969 12.239 10.3232 12.2393C10.847 12.2393 11.2725 11.8138 11.2725 11.29C11.2721 10.7666 10.8468 10.3418 10.3232 10.3418ZM12.4326 11.291C12.4326 11.3549 12.4284 11.418 12.4229 11.4805C12.4287 11.4181 12.4326 11.355 12.4326 11.291ZM8.21484 11.2832C8.21484 11.2856 8.21484 11.2886 8.21484 11.291L8.21484 11.29C8.21484 11.2878 8.21484 11.2855 8.21484 11.2832ZM3.08301 4.59082C4.04605 4.59095 4.85696 5.23717 5.10938 6.11914L13.415 6.11914L13.415 7.2793L5.11035 7.2793C4.85833 8.16202 4.04648 8.80846 3.08301 8.80859C2.11972 8.80843 1.30963 8.16179 1.05762 7.2793L0 7.2793L0 6.11914L1.05762 6.11914C1.30994 5.23728 2.12006 4.59098 3.08301 4.59082ZM3.08301 5.75098C2.55962 5.75117 2.13512 6.17587 2.13477 6.69922C2.13477 7.22287 2.5594 7.64824 3.08301 7.64844C3.60665 7.64828 4.03223 7.2229 4.03223 6.69922C4.03187 6.17585 3.60643 5.75113 3.08301 5.75098ZM5.19238 6.69922C5.19238 6.763 5.18816 6.82633 5.18262 6.88867C5.18846 6.82629 5.19238 6.76313 5.19238 6.69922C5.19236 6.63495 5.18853 6.57152 5.18262 6.50879C5.18826 6.57154 5.19236 6.635 5.19238 6.69922ZM0.982422 6.52344C0.977382 6.58136 0.97463 6.63999 0.974609 6.69922C0.974609 6.75775 0.977496 6.81579 0.982422 6.87305C0.977758 6.81579 0.974609 6.75767 0.974609 6.69922C0.974628 6.64 0.977618 6.58142 0.982422 6.52344ZM10.3232 0C11.2869 0 12.0986 0.646596 12.3506 1.5293L13.415 1.5293L13.415 2.68945L12.3496 2.68945C12.363 2.64266 12.3754 2.59488 12.3857 2.54688C12.1838 3.50118 11.3376 4.21777 10.3232 4.21777C9.36037 4.21756 8.55018 3.57139 8.29785 2.68945L0 2.68945L0 1.5293L8.29688 1.5293C8.5487 0.646717 9.35981 0.00021854 10.3232 0ZM10.3232 1.16016C9.79984 1.16042 9.37524 1.58499 9.375 2.1084C9.375 2.63201 9.79969 3.05735 10.3232 3.05762C10.847 3.05762 11.2725 2.63217 11.2725 2.1084C11.2722 1.58483 10.8469 1.16016 10.3232 1.16016ZM12.4229 2.29883C12.4287 2.23641 12.4326 2.17331 12.4326 2.10938C12.4326 2.17327 12.4284 2.23638 12.4229 2.29883ZM8.21484 2.10938L8.21484 2.1084L8.21484 2.10938ZM8.22266 1.93359C8.21785 1.98897 8.21506 2.04499 8.21484 2.10156C8.21503 2.04501 8.2181 1.98902 8.22266 1.93359ZM8.22266 11.1162C8.2179 11.1713 8.21507 11.227 8.21484 11.2832C8.21504 11.227 8.21814 11.1713 8.22266 11.1162Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
/** ic_ds_project_add_outline_16 (figma extract) */
|
/** ic_ds_project_add_outline_16 (figma extract) */
|
||||||
@@ -559,7 +559,7 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) =>
|
|||||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||||
<path transform="translate(9.52 2.52)" d="M3.55246 0L3.55246 2.44252L6 2.44252L6 3.55748L3.55246 3.55748L3.55246 6L2.43834 6L2.43834 3.55748L0 3.55748L0 2.44252L2.43834 2.44252L2.43834 0L3.55246 0Z" fill="currentColor"/>
|
<path transform="translate(9.52 2.52)" d="M3.55246 0L3.55246 2.44252L6 2.44252L6 3.55748L3.55246 3.55748L3.55246 6L2.43834 6L2.43834 3.55748L0 3.55748L0 2.44252L2.43834 2.44252L2.43834 0L3.55246 0Z" fill="currentColor"/>
|
||||||
<path transform="translate(0.3496 2.35)" d="M4.76367 0C5.36861 1.80598e-05 5.93113 0.310294 6.25488 0.821289L6.78027 1.64941C6.79685 1.67558 6.81791 1.69775 6.83887 1.71973C6.72186 2.15521 6.65702 2.61192 6.65137 3.08301C6.25601 2.96045 5.90909 2.70478 5.68164 2.3457L5.15723 1.5166C5.07183 1.38189 4.92318 1.3008 4.76367 1.30078L2.32422 1.30078C1.7589 1.30078 1.30078 1.7589 1.30078 2.32422L1.30078 10.1338C1.30078 10.6991 1.7589 11.1572 2.32422 11.1572L11.9766 11.1572C12.5419 11.1572 13 10.6991 13 10.1338L13 8.58398C13.4545 8.5135 13.8903 8.38748 14.3008 8.21289L14.3008 10.1338C14.3008 11.4171 13.2598 12.458 11.9766 12.458L2.32422 12.458C1.04093 12.458 0 11.4171 0 10.1338L0 2.32422C0 1.04093 1.04093 0 2.32422 0L4.76367 0Z" fill="currentColor"/>
|
<path transform="translate(0.3496 2.35)" d="M4.76367 0C5.36861 1.80598e-05 5.93113 0.310294 6.25488 0.821289L6.78027 1.64941C6.79685 1.67558 6.81791 1.69775 6.83887 1.71973C6.72186 2.15521 6.65702 2.61192 6.65137 3.08301C6.25601 2.96045 5.90909 2.70478 5.68164 2.3457L5.15723 1.5166C5.07183 1.38189 4.92318 1.3008 4.76367 1.30078L2.32422 1.30078C1.7589 1.30078 1.30078 1.7589 1.30078 2.32422L1.30078 10.1338C1.30078 10.6991 1.7589 11.1572 2.32422 11.1572L11.9766 11.1572C12.5419 11.1572 13 10.6991 13 10.1338L13 8.58398C13.4545 8.5135 13.8903 8.38748 14.3008 8.21289L14.3008 10.1338C14.3008 11.4171 13.2598 12.458 11.9766 12.458L2.32422 12.458C1.04093 12.458 0 11.4171 0 10.1338L0 2.32422C0 1.04093 1.04093 0 2.32422 0L4.76367 0Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */
|
/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */
|
||||||
@@ -567,14 +567,14 @@ export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => (
|
|||||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||||
<path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
|
<path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
|
||||||
<path opacity="0.2" d="M13.6602 7.75525C13.9618 7.7556 14.1815 8.04179 14.1045 8.33337L13.0508 12.3031C12.9304 12.7567 12.5191 13.0725 12.0498 13.0726H2.91701C2.23744 13.0725 1.7417 12.4287 1.91603 11.7719L2.77834 8.52478C2.89898 8.07146 3.31018 7.75532 3.77931 7.75525H13.6602ZM5.1963 2.95154C5.34985 2.95159 5.49377 3.02803 5.57912 3.15564L6.0508 3.86365C6.39205 4.37553 6.96685 4.68385 7.58205 4.68396H12.1699C12.7416 4.68396 13.2049 5.14754 13.2051 5.71912V6.37439H3.77931C3.02267 6.37444 2.33067 6.72671 1.88283 7.29333V3.98669C1.88299 3.4152 2.34649 2.95168 2.91798 2.95154H5.1963Z" fill="currentColor"/>
|
<path opacity="0.2" d="M13.6602 7.75525C13.9618 7.7556 14.1815 8.04179 14.1045 8.33337L13.0508 12.3031C12.9304 12.7567 12.5191 13.0725 12.0498 13.0726H2.91701C2.23744 13.0725 1.7417 12.4287 1.91603 11.7719L2.77834 8.52478C2.89898 8.07146 3.31018 7.75532 3.77931 7.75525H13.6602ZM5.1963 2.95154C5.34985 2.95159 5.49377 3.02803 5.57912 3.15564L6.0508 3.86365C6.39205 4.37553 6.96685 4.68385 7.58205 4.68396H12.1699C12.7416 4.68396 13.2049 5.14754 13.2051 5.71912V6.37439H3.77931C3.02267 6.37444 2.33067 6.72671 1.88283 7.29333V3.98669C1.88299 3.4152 2.34649 2.95168 2.91798 2.95154H5.1963Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
/** folder_close_16 (figma extract) */
|
/** folder_close_16 (figma extract) */
|
||||||
export const IconFolderClose16 = ({ size = 16, className }: IconProps) => (
|
export const IconFolderClose16 = ({ size = 16, className }: IconProps) => (
|
||||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||||
<path transform="translate(1.5 2.429)" d="M5.05582 0.518756L4.50669 0.86654L5.05582 0.518756ZM13 9.4837L13.65 9.4837L13.65 3.53962L13 3.53962L12.35 3.53962L12.35 9.4837L13 9.4837ZM11.3264 1.86603L11.3264 1.21603L6.52313 1.21603L6.52313 1.86603L6.52313 2.51603L11.3264 2.51603L11.3264 1.86603ZM5.58054 1.34727L6.12968 0.999489L5.60495 0.170972L5.05582 0.518756L4.50669 0.86654L5.03141 1.69506L5.58054 1.34727ZM4.11323 1.23058e-13L4.11323 -0.65L1.67359 -0.65L1.67359 5.00699e-14L1.67359 0.65L4.11323 0.65L4.11323 1.23058e-13ZM0 1.67359L-0.65 1.67359L-0.65 9.4837L0 9.4837L0.65 9.4837L0.65 1.67359L0 1.67359ZM11.3264 11.1573L11.3264 10.5073L1.67359 10.5073L1.67359 11.1573L1.67359 11.8073L11.3264 11.8073L11.3264 11.1573ZM0 9.4837L-0.65 9.4837C-0.65 10.767 0.390308 11.8073 1.67359 11.8073L1.67359 11.1573L1.67359 10.5073C1.10828 10.5073 0.65 10.049 0.65 9.4837L0 9.4837ZM1.67359 5.00699e-14L1.67359 -0.65C0.390307 -0.65 -0.65 0.390309 -0.65 1.67359L0 1.67359L0.65 1.67359C0.65 1.10828 1.10828 0.65 1.67359 0.65L1.67359 5.00699e-14ZM5.05582 0.518756L5.60495 0.170972C5.28121 -0.340193 4.71829 -0.65 4.11323 -0.65L4.11323 1.23058e-13L4.11323 0.65C4.27282 0.65 4.4213 0.731715 4.50669 0.86654L5.05582 0.518756ZM6.52313 1.86603L6.52313 1.21603C6.36354 1.21603 6.21507 1.13431 6.12968 0.999489L5.58054 1.34727L5.03141 1.69506C5.35515 2.20622 5.91808 2.51603 6.52313 2.51603L6.52313 1.86603ZM13 3.53962L13.65 3.53962C13.65 2.25634 12.6097 1.21603 11.3264 1.21603L11.3264 1.86603L11.3264 2.51603C11.8917 2.51603 12.35 2.97431 12.35 3.53962L13 3.53962ZM13 9.4837L12.35 9.4837C12.35 10.049 11.8917 10.5073 11.3264 10.5073L11.3264 11.1573L11.3264 11.8073C12.6097 11.8073 13.65 10.767 13.65 9.4837L13 9.4837Z" fill="currentColor"/>
|
<path transform="translate(1.5 2.429)" d="M5.05582 0.518756L4.50669 0.86654L5.05582 0.518756ZM13 9.4837L13.65 9.4837L13.65 3.53962L13 3.53962L12.35 3.53962L12.35 9.4837L13 9.4837ZM11.3264 1.86603L11.3264 1.21603L6.52313 1.21603L6.52313 1.86603L6.52313 2.51603L11.3264 2.51603L11.3264 1.86603ZM5.58054 1.34727L6.12968 0.999489L5.60495 0.170972L5.05582 0.518756L4.50669 0.86654L5.03141 1.69506L5.58054 1.34727ZM4.11323 1.23058e-13L4.11323 -0.65L1.67359 -0.65L1.67359 5.00699e-14L1.67359 0.65L4.11323 0.65L4.11323 1.23058e-13ZM0 1.67359L-0.65 1.67359L-0.65 9.4837L0 9.4837L0.65 9.4837L0.65 1.67359L0 1.67359ZM11.3264 11.1573L11.3264 10.5073L1.67359 10.5073L1.67359 11.1573L1.67359 11.8073L11.3264 11.8073L11.3264 11.1573ZM0 9.4837L-0.65 9.4837C-0.65 10.767 0.390308 11.8073 1.67359 11.8073L1.67359 11.1573L1.67359 10.5073C1.10828 10.5073 0.65 10.049 0.65 9.4837L0 9.4837ZM1.67359 5.00699e-14L1.67359 -0.65C0.390307 -0.65 -0.65 0.390309 -0.65 1.67359L0 1.67359L0.65 1.67359C0.65 1.10828 1.10828 0.65 1.67359 0.65L1.67359 5.00699e-14ZM5.05582 0.518756L5.60495 0.170972C5.28121 -0.340193 4.71829 -0.65 4.11323 -0.65L4.11323 1.23058e-13L4.11323 0.65C4.27282 0.65 4.4213 0.731715 4.50669 0.86654L5.05582 0.518756ZM6.52313 1.86603L6.52313 1.21603C6.36354 1.21603 6.21507 1.13431 6.12968 0.999489L5.58054 1.34727L5.03141 1.69506C5.35515 2.20622 5.91808 2.51603 6.52313 2.51603L6.52313 1.86603ZM13 3.53962L13.65 3.53962C13.65 2.25634 12.6097 1.21603 11.3264 1.21603L11.3264 1.86603L11.3264 2.51603C11.8917 2.51603 12.35 2.97431 12.35 3.53962L13 3.53962ZM13 9.4837L12.35 9.4837C12.35 10.049 11.8917 10.5073 11.3264 10.5073L11.3264 11.1573L11.3264 11.8073C12.6097 11.8073 13.65 10.767 13.65 9.4837L13 9.4837Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
/** tree_corner_8x10 (figma extract; session-tree "L" connector, stroke geometry pre-expanded) */
|
/** tree_corner_8x10 (figma extract; session-tree "L" connector, stroke geometry pre-expanded) */
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export interface CodeBlockProps {
|
|||||||
|
|
||||||
/** @returns true only when the host accepted the write. */
|
/** @returns true only when the host accepted the write. */
|
||||||
async function writeClipboard(text: string): Promise<boolean> {
|
async function writeClipboard(text: string): Promise<boolean> {
|
||||||
|
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||||
|
// that runtime gap is exactly what this guard detects.
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
@@ -30,6 +33,9 @@ async function writeClipboard(text: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// jsdom and older hosts: best-effort execCommand path when present.
|
// jsdom and older hosts: best-effort execCommand path when present.
|
||||||
|
// execCommand('copy') is the only clipboard fallback where the async API
|
||||||
|
// is missing; deprecated but deliberately retained.
|
||||||
|
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||||
const exec = typeof document.execCommand === 'function'
|
const exec = typeof document.execCommand === 'function'
|
||||||
? document.execCommand.bind(document)
|
? document.execCommand.bind(document)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -48,6 +54,7 @@ async function writeClipboard(text: string): Promise<boolean> {
|
|||||||
} finally {
|
} finally {
|
||||||
el.remove()
|
el.remove()
|
||||||
}
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||||
@@ -64,20 +71,20 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
|||||||
void writeClipboard(text).then((ok) => {
|
void writeClipboard(text).then((ok) => {
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
window.setTimeout(() => setCopied(false), 1000)
|
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||||
})
|
})
|
||||||
}, [copied, trimmed])
|
}, [copied, trimmed])
|
||||||
|
|
||||||
const body = html === undefined
|
const body = html === undefined
|
||||||
? (
|
? (
|
||||||
<pre className={css.plain}><code>{trimmed}</code></pre>
|
<pre className={css.plain}><code>{trimmed}</code></pre>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
// eslint-disable-next-line react/no-danger -- shiki's output is a static
|
// shiki's output is a static span tree it generated from `code` (no user
|
||||||
// span tree it generated from `code` (no user HTML passes through), the
|
// HTML passes through), the sanctioned innerHTML consumption path per
|
||||||
// sanctioned innerHTML consumption path per shiki's own docs.
|
// shiki's own docs.
|
||||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
|
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
|
|||||||
if (!open) return ''
|
if (!open) return ''
|
||||||
let s: string
|
let s: string
|
||||||
try {
|
try {
|
||||||
|
// lib typing hides stringify's undefined arm (undefined/function/symbol payloads).
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
s = JSON.stringify(payload, null, 2) ?? String(payload)
|
s = JSON.stringify(payload, null, 2) ?? String(payload)
|
||||||
} catch {
|
} catch {
|
||||||
s = String(payload)
|
s = String(payload)
|
||||||
@@ -23,7 +25,7 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
|
|||||||
}, [open, payload])
|
}, [open, payload])
|
||||||
return (
|
return (
|
||||||
<div className={css.root}>
|
<div className={css.root}>
|
||||||
<button type="button" className={css.toggle} onClick={() => setOpen((v) => !v)}>
|
<button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>
|
||||||
{open ? '▾' : '▸'} {label}
|
{open ? '▾' : '▸'} {label}
|
||||||
</button>
|
</button>
|
||||||
{open && <pre className={css.body}>{body}</pre>}
|
{open && <pre className={css.body}>{body}</pre>}
|
||||||
|
|||||||
@@ -27,25 +27,25 @@ const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
|||||||
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
||||||
function buildComponents(streaming: boolean): Components {
|
function buildComponents(streaming: boolean): Components {
|
||||||
return {
|
return {
|
||||||
a: ({ href = '', children }) => {
|
a: ({ href = '', children }) => {
|
||||||
const safeHref = sanitizeUrl(href)
|
const safeHref = sanitizeUrl(href)
|
||||||
if (safeHref === '') return <>{children}</>
|
if (safeHref === '') return <>{children}</>
|
||||||
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={safeHref}
|
href={safeHref}
|
||||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
||||||
table: ({ children }) => (
|
table: ({ children }) => (
|
||||||
<div className={css.tableScroll}>
|
<div className={css.tableScroll}>
|
||||||
<table>{children}</table>
|
<table>{children}</table>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
// Fenced blocks route through the shared CodeBlock (shiki for registered
|
// Fenced blocks route through the shared CodeBlock (shiki for registered
|
||||||
// grammars, identical-geometry plain fallback for unknown/absent
|
// grammars, identical-geometry plain fallback for unknown/absent
|
||||||
// languages); inline code keeps the default <code> path (the :not(pre)
|
// languages); inline code keeps the default <code> path (the :not(pre)
|
||||||
@@ -53,7 +53,9 @@ function buildComponents(streaming: boolean): Components {
|
|||||||
// plain arm — retokenizing a growing fence on every chunk is quadratic
|
// plain arm — retokenizing a growing fence on every chunk is quadratic
|
||||||
// main-thread work; the finalize swap highlights it once.
|
// main-thread work; the finalize swap highlights it once.
|
||||||
pre: ({ children }) => {
|
pre: ({ children }) => {
|
||||||
/* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */
|
// The markdown pipeline always hands `pre` its single `code` element;
|
||||||
|
// the undefined arm guards a react-markdown representation change.
|
||||||
|
/* v8 ignore next 2 */
|
||||||
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
|
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
|
||||||
const raw = child?.props.children
|
const raw = child?.props.children
|
||||||
// A fence whose content isn't one plain string (e.g. an empty fence)
|
// A fence whose content isn't one plain string (e.g. an empty fence)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
|
|||||||
wrapper.getBoundingClientRect = () => ({
|
wrapper.getBoundingClientRect = () => ({
|
||||||
top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34,
|
top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34,
|
||||||
width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}),
|
width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}),
|
||||||
} as DOMRect)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe('ic_ds_ icon set', () => {
|
|||||||
expect(iconNames.length).toBe(55)
|
expect(iconNames.length).toBe(55)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => {
|
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||||
const Icon = icons[name]!
|
const Icon = icons[name]!
|
||||||
const { container } = render(<Icon />)
|
const { container } = render(<Icon />)
|
||||||
const svg = container.querySelector('svg')
|
const svg = container.querySelector('svg')
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ describe('JsonBlock', () => {
|
|||||||
it('truncates beyond the size cap with a suffix note', () => {
|
it('truncates beyond the size cap with a suffix note', () => {
|
||||||
const big = 'x'.repeat(30_000)
|
const big = 'x'.repeat(30_000)
|
||||||
const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />)
|
const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />)
|
||||||
const body = container.querySelector('pre')!.textContent!
|
const body = container.querySelector('pre')!.textContent
|
||||||
expect(body.length).toBeLessThan(30_000)
|
expect(body.length).toBeLessThan(30_000)
|
||||||
expect(body).toContain('截断')
|
expect(body).toContain('截断')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
|||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
describe('StateDot', () => {
|
describe('StateDot', () => {
|
||||||
it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', state => {
|
it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', (state) => {
|
||||||
const { container } = render(<StateDot state={state} />)
|
const { container } = render(<StateDot state={state} />)
|
||||||
const dot = container.firstElementChild as HTMLElement
|
const dot = container.firstElementChild as HTMLElement
|
||||||
expect(dot.dataset['state']).toBe(state)
|
expect(dot.dataset['state']).toBe(state)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export function parseQuestionTitle(title: string): string {
|
|||||||
|
|
||||||
/** Return whether a textarea key event belongs to an active IME composition. */
|
/** Return whether a textarea key event belongs to an active IME composition. */
|
||||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||||
|
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||||
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +63,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|||||||
})))
|
})))
|
||||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const question = questions[index]!
|
const question = questions[index]!
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const draft = drafts[index]!
|
const draft = drafts[index]!
|
||||||
const hasOptions = (question.options?.length ?? 0) > 0
|
const hasOptions = (question.options?.length ?? 0) > 0
|
||||||
|
|
||||||
@@ -145,10 +150,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|||||||
const skipQuestion = (): void => {
|
const skipQuestion = (): void => {
|
||||||
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
||||||
? {
|
? {
|
||||||
selected: [], custom: '',
|
selected: [], custom: '',
|
||||||
customOpen: (question.options?.length ?? 0) === 0,
|
customOpen: (question.options?.length ?? 0) === 0,
|
||||||
skipped: true,
|
skipped: true,
|
||||||
}
|
}
|
||||||
: item)
|
: item)
|
||||||
setDrafts(nextDrafts)
|
setDrafts(nextDrafts)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const QUESTIONS = [
|
|||||||
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
|
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
|
||||||
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
|
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
|
||||||
const carrier = new PendingWait(
|
const carrier = new PendingWait(
|
||||||
'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
|
'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond)
|
||||||
return { carrier, respond }
|
return { carrier, respond }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ describe('QuestionComposer', () => {
|
|||||||
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
||||||
{ id: 'signals', selected: ['系统设计', '代码质量'] },
|
{ id: 'signals', selected: ['系统设计', '代码质量'] },
|
||||||
]))
|
]))
|
||||||
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('skips individual questions without discarding earlier answers', () => {
|
it('skips individual questions without discarding earlier answers', () => {
|
||||||
@@ -173,7 +173,7 @@ describe('QuestionComposer', () => {
|
|||||||
// Receipt rejection surfaces through the domain face's thrown message.
|
// Receipt rejection surfaces through the domain face's thrown message.
|
||||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||||
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
|
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
|
||||||
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||||
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
|
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
|
||||||
@@ -199,7 +199,7 @@ describe('QuestionComposer', () => {
|
|||||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||||
expect(await screen.findByText('网络中断')).toBeTruthy()
|
expect(await screen.findByText('网络中断')).toBeTruthy()
|
||||||
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||||
expect(await screen.findByText('字符串错误')).toBeTruthy()
|
expect(await screen.findByText('字符串错误')).toBeTruthy()
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe('GeneralSection', () => {
|
|||||||
mount()
|
mount()
|
||||||
expect(screen.getByText('Permission')).toBeTruthy()
|
expect(screen.getByText('Permission')).toBeTruthy()
|
||||||
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
|
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
|
||||||
const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement
|
const selector = screen.getByRole<HTMLButtonElement>('button', { name: /Read only/ })
|
||||||
expect(selector.disabled).toBe(true)
|
expect(selector.disabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
|||||||
// Local selection; entries can unmount underneath it, so the render-time
|
// Local selection; entries can unmount underneath it, so the render-time
|
||||||
// projection falls back to the first row when the id is gone.
|
// projection falls back to the first row when the id is gone.
|
||||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||||
const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id
|
const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,7 +56,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
|||||||
<nav className={css.nav}>
|
<nav className={css.nav}>
|
||||||
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
|
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
|
||||||
<div className={css.navList}>
|
<div className={css.navList}>
|
||||||
{rows.map((row) => (
|
{rows.map(row => (
|
||||||
<button
|
<button
|
||||||
key={row.id}
|
key={row.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
|
|||||||
startSession={startSession} toggleSidebar={toggleSidebar}
|
startSession={startSession} toggleSidebar={toggleSidebar}
|
||||||
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
|
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
|
||||||
if (key === 'sidebar.settings') {
|
if (key === 'sidebar.settings') {
|
||||||
settingsOwner = owner as SidebarSettingsOwnerProps
|
settingsOwner = owner
|
||||||
return <div data-testid="settings-seat" data-wide={owner.wide} />
|
return <div data-testid="settings-seat" data-wide={owner.wide} />
|
||||||
}
|
}
|
||||||
regionOwner = owner as SidebarSectionOwnerProps
|
regionOwner = owner as SidebarSectionOwnerProps
|
||||||
|
|||||||
@@ -34,5 +34,5 @@ export interface MenuViewInjected {
|
|||||||
* @param source - source (group) name.
|
* @param source - source (group) name.
|
||||||
* @param index - candidate index within the group.
|
* @param index - candidate index within the group.
|
||||||
*/
|
*/
|
||||||
onPick(source: string, index: number): void
|
onPick: (source: string, index: number) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function createPanelStore() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatStore = () => defineStore({
|
const _chatStore = () => defineStore({
|
||||||
init: () => ({ selection: null as { id: string } | null, draft: '' }),
|
init: () => ({ selection: null as { id: string } | null, draft: '' }),
|
||||||
actions: {
|
actions: {
|
||||||
select: (d, t: { id: string }) => { d.selection = t },
|
select: (d, t: { id: string }) => { d.selection = t },
|
||||||
@@ -49,7 +49,7 @@ const chatStore = () => defineStore({
|
|||||||
clearDraft: (d) => { d.draft = '' },
|
clearDraft: (d) => { d.draft = '' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
type ChatHandle = ReturnType<typeof chatStore>
|
type ChatHandle = ReturnType<typeof _chatStore>
|
||||||
|
|
||||||
type FrameProps =
|
type FrameProps =
|
||||||
& PropsRuntime<'chain.frame'>
|
& PropsRuntime<'chain.frame'>
|
||||||
@@ -115,7 +115,7 @@ describe('terminal-design type chain', () => {
|
|||||||
// member payloads are the runtime merge's property — not probed here
|
// member payloads are the runtime merge's property — not probed here
|
||||||
// (the runtime package's own tests cover them).
|
// (the runtime package's own tests cover them).
|
||||||
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
|
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
|
||||||
const draft: string = cp.useStore((s) => s.draft)
|
const draft: string = cp.useStore(s => s.draft)
|
||||||
cp.actions.select({ id: 'm1' })
|
cp.actions.select({ id: 'm1' })
|
||||||
void draft
|
void draft
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ describe('terminal-design type chain', () => {
|
|||||||
// chain position.
|
// chain position.
|
||||||
core.register({
|
core.register({
|
||||||
name: 'chain.takeover',
|
name: 'chain.takeover',
|
||||||
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
|
select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
}, Takeover)
|
}, Takeover)
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ describe('terminal-design type chain', () => {
|
|||||||
// checks through parameter contravariance.
|
// checks through parameter contravariance.
|
||||||
core.register({
|
core.register({
|
||||||
name: 'chain.takeover',
|
name: 'chain.takeover',
|
||||||
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
|
select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
|
||||||
}, WideTakeover)
|
}, WideTakeover)
|
||||||
|
|
||||||
// renderSlotChain share: chain keys dispatch with the fallback bag;
|
// renderSlotChain share: chain keys dispatch with the fallback bag;
|
||||||
@@ -179,7 +179,7 @@ describe('terminal-design type chain', () => {
|
|||||||
name: 'chain.side',
|
name: 'chain.side',
|
||||||
// @ts-expect-error root-scope inject has no sessionId parameter
|
// @ts-expect-error root-scope inject has no sessionId parameter
|
||||||
inject: (sessionId: string) => ({ x: sessionId }),
|
inject: (sessionId: string) => ({ x: sessionId }),
|
||||||
}, ((_p) => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
|
}, (_p => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
|
||||||
|
|
||||||
// keyed registration without key.
|
// keyed registration without key.
|
||||||
// @ts-expect-error keyed registration requires options.key
|
// @ts-expect-error keyed registration requires options.key
|
||||||
@@ -195,14 +195,14 @@ describe('terminal-design type chain', () => {
|
|||||||
// @ts-expect-error component matched prop drifts from the select return
|
// @ts-expect-error component matched prop drifts from the select return
|
||||||
core.register({
|
core.register({
|
||||||
name: 'chain.takeover',
|
name: 'chain.takeover',
|
||||||
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q') ?? null,
|
select: ({ items }: { items: readonly Item[] }) => items.find(i => i.kind === 'q') ?? null,
|
||||||
}, NarrowTakeover)
|
}, NarrowTakeover)
|
||||||
|
|
||||||
// select must return M | null, not undefined (find() must be coalesced).
|
// select must return M | null, not undefined (find() must be coalesced).
|
||||||
// @ts-expect-error select may not return undefined
|
// @ts-expect-error select may not return undefined
|
||||||
core.register({
|
core.register({
|
||||||
name: 'chain.takeover',
|
name: 'chain.takeover',
|
||||||
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q'),
|
select: ({ items }: { items: readonly Item[] }) => items.find(i => i.kind === 'q'),
|
||||||
}, Takeover)
|
}, Takeover)
|
||||||
|
|
||||||
// Chain keys are not renderSlot-dispatchable (and vice versa).
|
// Chain keys are not renderSlot-dispatchable (and vice versa).
|
||||||
@@ -211,7 +211,7 @@ describe('terminal-design type chain', () => {
|
|||||||
// @ts-expect-error non-chain keys have no renderSlotChain dispatch
|
// @ts-expect-error non-chain keys have no renderSlotChain dispatch
|
||||||
chainSlots.renderSlotChain('chain.conv', {})
|
chainSlots.renderSlotChain('chain.conv', {})
|
||||||
// @ts-expect-error a children set without chain keys provides no renderSlotChain
|
// @ts-expect-error a children set without chain keys provides no renderSlotChain
|
||||||
fp.renderSlotChain
|
type _NoChainSeat = typeof fp.renderSlotChain
|
||||||
|
|
||||||
// renderSlot owner share typed at the call site.
|
// renderSlot owner share typed at the call site.
|
||||||
// @ts-expect-error owner shape mismatch (width missing)
|
// @ts-expect-error owner shape mismatch (width missing)
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
|||||||
subtool: 'Sub',
|
subtool: 'Sub',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
|
||||||
user: css.tagUser!,
|
user: css.tagUser,
|
||||||
message: css.tagMessage!,
|
message: css.tagMessage,
|
||||||
tool: css.tagTool!,
|
tool: css.tagTool,
|
||||||
subtool: css.tagSubtool!,
|
subtool: css.tagSubtool,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
@@ -84,7 +84,7 @@ export function TrajectoryCell({
|
|||||||
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
|
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
|
||||||
<span className={css.index}>#{index}</span>
|
<span className={css.index}>#{index}</span>
|
||||||
<span className={css.tagSlot}>
|
<span className={css.tagSlot}>
|
||||||
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
|
<span className={[css.tag, TAG_CLASS[kind]].filter((c): c is string => c !== undefined).join(' ')}>{KIND_LABEL[kind]}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={css.text}>{text}</span>
|
<span className={css.text}>{text}</span>
|
||||||
<span className={css.trailing}>
|
<span className={css.trailing}>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import css from './TrajectoryStatsHeader.module.css'
|
|||||||
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||||
|
|
||||||
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
|
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
|
||||||
const nodes = useSession((s) => s.nodes)
|
const nodes = useSession(s => s.nodes)
|
||||||
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
|
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
|
||||||
if (stats.turns === 0) return null
|
if (stats.turns === 0) return null
|
||||||
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>
|
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) {
|
|||||||
<div className={css.inner}>
|
<div className={css.inner}>
|
||||||
<span className={css.title}>Turn {turn}</span>
|
<span className={css.title}>Turn {turn}</span>
|
||||||
<div className={css.columns} aria-hidden="true">
|
<div className={css.columns} aria-hidden="true">
|
||||||
{COLUMN_LABELS.map((label) => (
|
{COLUMN_LABELS.map(label => (
|
||||||
<span key={label} className={css.column}>{label}</span>
|
<span key={label} className={css.column}>{label}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import { deriveTrajectoryLayout } from './layout.ts'
|
|||||||
import css from './views.module.css'
|
import css from './views.module.css'
|
||||||
|
|
||||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||||
const nodes = useSession((s) => s.nodes)
|
const nodes = useSession(s => s.nodes)
|
||||||
const partial = useSession((s) => s.partial)
|
const partial = useSession(s => s.partial)
|
||||||
const runningCalls = useSession((s) => s.runningCalls)
|
const runningCalls = useSession(s => s.runningCalls)
|
||||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
const codeDispatches = useSession(s => s.codeDispatches)
|
||||||
const turns = useMemo(
|
const turns = useMemo(
|
||||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
|
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
|
||||||
[nodes, partial, runningCalls, codeDispatches],
|
[nodes, partial, runningCalls, codeDispatches],
|
||||||
@@ -22,15 +22,15 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={css.root}>
|
<div className={css.root}>
|
||||||
{turns.map((turn) => (
|
{turns.map(turn => (
|
||||||
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
|
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
|
||||||
{turn.groups.flatMap((group) => [
|
{turn.groups.flatMap(group => [
|
||||||
<TrajectoryGroupHeader
|
<TrajectoryGroupHeader
|
||||||
key={`${group.title}-h`}
|
key={`${group.title}-h`}
|
||||||
title={group.title}
|
title={group.title}
|
||||||
{...(group.description !== undefined ? { description: group.description } : {})}
|
{...(group.description !== undefined ? { description: group.description } : {})}
|
||||||
/>,
|
/>,
|
||||||
...group.cells.map((cell) => (
|
...group.cells.map(cell => (
|
||||||
<TrajectoryCell key={cell.index} {...cell} />
|
<TrajectoryCell key={cell.index} {...cell} />
|
||||||
)),
|
)),
|
||||||
])}
|
])}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export interface WaterfallExtraProps {
|
|||||||
|
|
||||||
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
|
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
|
||||||
const scale = pxPerNode ?? PX_PER_NODE
|
const scale = pxPerNode ?? PX_PER_NODE
|
||||||
const nodes = useSession((s) => s.nodes)
|
const nodes = useSession(s => s.nodes)
|
||||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
const codeDispatches = useSession(s => s.codeDispatches)
|
||||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||||
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
|
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
|
||||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
|
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
|
||||||
@@ -50,7 +50,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(subSpans.get(span.turn) ?? []).map((lane) => (
|
{(subSpans.get(span.turn) ?? []).map(lane => (
|
||||||
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
|
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
|
||||||
<span className={css.subTag}>{lane.name}</span>
|
<span className={css.subTag}>{lane.name}</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('TrajectoryCell', () => {
|
|||||||
expect(screen.getByText('381')).toBeTruthy()
|
expect(screen.getByText('381')).toBeTruthy()
|
||||||
expect(screen.getByText('155')).toBeTruthy()
|
expect(screen.getByText('155')).toBeTruthy()
|
||||||
expect(screen.getByText('+235.2s')).toBeTruthy()
|
expect(screen.getByText('+235.2s')).toBeTruthy()
|
||||||
const texts = [...container.querySelectorAll('span')].map((el) => el.textContent)
|
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
|
||||||
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
|
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
|
||||||
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
|
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
|
||||||
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
|
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
|
||||||
|
|||||||
@@ -73,13 +73,13 @@ describe('deriveTrajectoryLayout', () => {
|
|||||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||||
expect(turns).toHaveLength(1)
|
expect(turns).toHaveLength(1)
|
||||||
expect(turns[0]?.turn).toBe(1)
|
expect(turns[0]?.turn).toBe(1)
|
||||||
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
|
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
|
||||||
expect(kinds).toEqual(['user', 'message', 'tool'])
|
expect(kinds).toEqual(['user', 'message', 'tool'])
|
||||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
|
||||||
expect(message).toMatchObject({
|
expect(message).toMatchObject({
|
||||||
input: 10, output: 20, think: 5, timeSeconds: 5,
|
input: 10, output: 20, think: 5, timeSeconds: 5,
|
||||||
})
|
})
|
||||||
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
|
const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
|
||||||
expect(tool?.text).toBe('bash · {"command":"ls"}')
|
expect(tool?.text).toBe('bash · {"command":"ls"}')
|
||||||
expect(tool?.timeSeconds).toBe(1.3)
|
expect(tool?.timeSeconds).toBe(1.3)
|
||||||
})
|
})
|
||||||
@@ -87,14 +87,14 @@ describe('deriveTrajectoryLayout', () => {
|
|||||||
it('adds runningCalls not already present and leaves their time blank', () => {
|
it('adds runningCalls not already present and leaves their time blank', () => {
|
||||||
const turns = deriveTrajectoryLayout({
|
const turns = deriveTrajectoryLayout({
|
||||||
codeDispatches: new Map(),
|
codeDispatches: new Map(),
|
||||||
nodes: [] as unknown as ConversationSnapshot['nodes'],
|
nodes: [],
|
||||||
partial: null,
|
partial: null,
|
||||||
runningCalls: [{
|
runningCalls: [{
|
||||||
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
||||||
turn: 1, step: 2, time: 9_000, callView: null,
|
turn: 1, step: 2, time: 9_000, callView: null,
|
||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
|
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
|
||||||
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
|
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
|
||||||
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
|
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
|
||||||
})
|
})
|
||||||
@@ -113,9 +113,9 @@ describe('deriveTrajectoryLayout', () => {
|
|||||||
},
|
},
|
||||||
] as unknown as ConversationSnapshot['nodes']
|
] as unknown as ConversationSnapshot['nodes']
|
||||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||||
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
|
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
|
||||||
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
|
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||||
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
|
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('builds a wall-span step description with a tool histogram', () => {
|
it('builds a wall-span step description with a tool histogram', () => {
|
||||||
@@ -156,9 +156,9 @@ describe('deriveTrajectoryLayout', () => {
|
|||||||
},
|
},
|
||||||
] as unknown as ConversationSnapshot['nodes']
|
] as unknown as ConversationSnapshot['nodes']
|
||||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||||
expect(turns.map((t) => t.turn)).toEqual([1, 2])
|
expect(turns.map(t => t.turn)).toEqual([1, 2])
|
||||||
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
|
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
|
||||||
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
|
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps usage on the fallback Message row when assistant has no text block', () => {
|
it('keeps usage on the fallback Message row when assistant has no text block', () => {
|
||||||
@@ -170,7 +170,7 @@ describe('deriveTrajectoryLayout', () => {
|
|||||||
},
|
},
|
||||||
] as unknown as ConversationSnapshot['nodes']
|
] as unknown as ConversationSnapshot['nodes']
|
||||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
|
||||||
expect(message).toMatchObject({
|
expect(message).toMatchObject({
|
||||||
text: '', input: 11, output: 22, think: 3,
|
text: '', input: 11, output: 22, think: 3,
|
||||||
})
|
})
|
||||||
@@ -199,8 +199,8 @@ describe('deriveTrajectoryLayout', () => {
|
|||||||
] as unknown as ConversationSnapshot['nodes']
|
] as unknown as ConversationSnapshot['nodes']
|
||||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||||
const message = turns[0]?.groups
|
const message = turns[0]?.groups
|
||||||
.flatMap((g) => g.cells)
|
.flatMap(g => g.cells)
|
||||||
.find((c) => c.kind === 'message' && c.text === 'done')
|
.find(c => c.kind === 'message' && c.text === 'done')
|
||||||
// From context at 9s, not from the earlier user/tool surfaces.
|
// From context at 9s, not from the earlier user/tool surfaces.
|
||||||
expect(message?.timeSeconds).toBe(1)
|
expect(message?.timeSeconds).toBe(1)
|
||||||
})
|
})
|
||||||
@@ -234,10 +234,10 @@ describe('run_code sub-dispatch cells', () => {
|
|||||||
settledSub(2, 'read', 7_300, 7_800),
|
settledSub(2, 'read', 7_300, 7_800),
|
||||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||||
const cells = turns[0]!.groups.flatMap((g) => g.cells)
|
const cells = turns[0]!.groups.flatMap(g => g.cells)
|
||||||
expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
|
expect(cells.map(c => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
|
||||||
// Sequential indexes across the interleave; durations from the pair times.
|
// Sequential indexes across the interleave; durations from the pair times.
|
||||||
expect(cells.map((c) => c.index)).toEqual([1, 2, 3])
|
expect(cells.map(c => c.index)).toEqual([1, 2, 3])
|
||||||
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
|
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
|
||||||
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
|
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
|
||||||
})
|
})
|
||||||
@@ -249,7 +249,7 @@ describe('run_code sub-dispatch cells', () => {
|
|||||||
}
|
}
|
||||||
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
|
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||||
const sub = turns[0]!.groups.flatMap((g) => g.cells).find((c) => c.kind === 'subtool')
|
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
|
||||||
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
|
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
|||||||
version: () => slots.getVersion('conversation.view'),
|
version: () => slots.getVersion('conversation.view'),
|
||||||
}}
|
}}
|
||||||
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
|
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
|
||||||
inputActions={{ setDraft: vi.fn(), submit: vi.fn() } as never}
|
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
|
||||||
bindDraftMirror={() => () => {}}
|
bindDraftMirror={() => () => {}}
|
||||||
open={vi.fn()}
|
open={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
@@ -164,7 +164,7 @@ describe('plugin registration', () => {
|
|||||||
it('fiber disposal removes both tabs and leaves chat standing', async () => {
|
it('fiber disposal removes both tabs and leaves chat standing', async () => {
|
||||||
const b = await bench()
|
const b = await bench()
|
||||||
await b.fiber.dispose()
|
await b.fiber.dispose()
|
||||||
expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
|
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ describe('tab switching in ConversationRoot', () => {
|
|||||||
const b = await bench()
|
const b = await bench()
|
||||||
mount(b.slots)
|
mount(b.slots)
|
||||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||||
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||||
@@ -198,7 +198,7 @@ describe('tab switching in ConversationRoot', () => {
|
|||||||
|
|
||||||
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
|
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
|
||||||
const b = await bench()
|
const b = await bench()
|
||||||
mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
|
mount(b.slots, [])
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||||
@@ -221,12 +221,12 @@ describe('span derivation', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
|
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
|
||||||
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||||
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
|
const { useSession } = fakeSession([])
|
||||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
|
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
|
||||||
expect(container.firstChild).toBeNull()
|
expect(container.firstChild).toBeNull()
|
||||||
render(createElement(TrajectoryView as FC<ConvViewProps>,
|
render(createElement(TrajectoryView as FC<ConvViewProps>,
|
||||||
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
standaloneProps([])))
|
||||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -234,7 +234,7 @@ describe('span derivation', () => {
|
|||||||
describe('WaterfallView standalone branches', () => {
|
describe('WaterfallView standalone branches', () => {
|
||||||
it('empty window renders the placeholder copy', () => {
|
it('empty window renders the placeholder copy', () => {
|
||||||
render(createElement(WaterfallView as FC<ConvViewProps>,
|
render(createElement(WaterfallView as FC<ConvViewProps>,
|
||||||
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
standaloneProps([])))
|
||||||
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
|
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ describe('WaterfallView standalone branches', () => {
|
|||||||
|
|
||||||
describe('node half', () => {
|
describe('node half', () => {
|
||||||
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
||||||
expect(nodeApply()).toBeUndefined()
|
expect(() => { nodeApply() }).not.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
|
|||||||
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
|
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
|
||||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||||
const running = lanes.get(3)?.find((lane) => lane.name === 'grep')
|
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
|
||||||
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
|
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
|
||||||
// Extends from its start to the window end.
|
// Extends from its start to the window end.
|
||||||
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
|
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
|
|||||||
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
|
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
|
||||||
import css from './WorkspaceBrowser.module.css'
|
import css from './WorkspaceBrowser.module.css'
|
||||||
|
|
||||||
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
|
/**
|
||||||
|
* Column slide length (--ds-transition-duration-slow): rail-search focus waits it out —
|
||||||
|
* focus() forces a synchronous layout and would jank the slide.
|
||||||
|
*/
|
||||||
const EXPAND_SLIDE_MS = 300
|
const EXPAND_SLIDE_MS = 300
|
||||||
|
|
||||||
const GROUP_BY_ITEMS = [
|
const GROUP_BY_ITEMS = [
|
||||||
@@ -32,7 +35,7 @@ const GROUP_BY_ITEMS = [
|
|||||||
|
|
||||||
/** Immutable membership toggle for the local expansion arrays. */
|
/** Immutable membership toggle for the local expansion arrays. */
|
||||||
function toggled(list: readonly string[], key: string): string[] {
|
function toggled(list: readonly string[], key: string): string[] {
|
||||||
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
|
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
||||||
@@ -61,7 +64,7 @@ function GroupByMenu({ groupBy, onPick }: {
|
|||||||
type="button"
|
type="button"
|
||||||
className={clsx(css.iconButton, css.wide)}
|
className={clsx(css.iconButton, css.wide)}
|
||||||
aria-label="Group by"
|
aria-label="Group by"
|
||||||
onClick={() => { setOpen((v) => !v) }}
|
onClick={() => { setOpen(v => !v) }}
|
||||||
>
|
>
|
||||||
<IconPersonalizationOutline16 />
|
<IconPersonalizationOutline16 />
|
||||||
</button>
|
</button>
|
||||||
@@ -96,7 +99,7 @@ function SessionTree({
|
|||||||
useSessions, startSession, open, workspaces, query,
|
useSessions, startSession, open, workspaces, query,
|
||||||
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
||||||
}: SessionTreeProps) {
|
}: SessionTreeProps) {
|
||||||
const list = useSessions((s) => s)
|
const list = useSessions(s => s)
|
||||||
const current = list.current
|
const current = list.current
|
||||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||||
@@ -108,7 +111,7 @@ function SessionTree({
|
|||||||
?? UNGROUPED_KEY
|
?? UNGROUPED_KEY
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (current === undefined || currentGroup === undefined) return
|
if (current === undefined || currentGroup === undefined) return
|
||||||
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||||
}, [current, currentGroup])
|
}, [current, currentGroup])
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||||
@@ -129,22 +132,22 @@ function SessionTree({
|
|||||||
<div key={group.key} className={css.groupSection}>
|
<div key={group.key} className={css.groupSection}>
|
||||||
<ProjectRowItem
|
<ProjectRowItem
|
||||||
group={group}
|
group={group}
|
||||||
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
|
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
|
||||||
onCreate={() => {
|
onCreate={() => {
|
||||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||||
}}
|
}}
|
||||||
actions={group.workspaceId === undefined
|
actions={group.workspaceId === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
rename: () => {
|
rename: () => {
|
||||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||||
},
|
},
|
||||||
delete: () => {
|
delete: () => {
|
||||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{group.sessions.map((node, index) => {
|
{group.sessions.map((node, index) => {
|
||||||
// Draggable: real-workspace group roots outside search. The drag
|
// Draggable: real-workspace group roots outside search. The drag
|
||||||
@@ -189,7 +192,7 @@ function SessionTree({
|
|||||||
currentId={current}
|
currentId={current}
|
||||||
now={now}
|
now={now}
|
||||||
onOpen={open}
|
onOpen={open}
|
||||||
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
|
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||||
drag={dragProps}
|
drag={dragProps}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -204,7 +207,7 @@ function SessionTree({
|
|||||||
|
|
||||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||||
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
|
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
|
||||||
const list = useSessions((s) => s)
|
const list = useSessions(s => s)
|
||||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
return (
|
return (
|
||||||
@@ -292,7 +295,7 @@ export function WorkspaceBrowser({
|
|||||||
setRenameError(null)
|
setRenameError(null)
|
||||||
}
|
}
|
||||||
const confirmRename = () => {
|
const confirmRename = () => {
|
||||||
if (renameBlocked || renameTarget === null) return
|
if (renameBlocked) return
|
||||||
setRenaming(true)
|
setRenaming(true)
|
||||||
setRenameError(null)
|
setRenameError(null)
|
||||||
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
|
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
|
||||||
@@ -419,24 +422,24 @@ export function WorkspaceBrowser({
|
|||||||
{wide && (groupBy === 'flat'
|
{wide && (groupBy === 'flat'
|
||||||
? <FlatList useSessions={useSessions} open={open} query={query} />
|
? <FlatList useSessions={useSessions} open={open} query={query} />
|
||||||
: (
|
: (
|
||||||
<SessionTree
|
<SessionTree
|
||||||
useSessions={useSessions}
|
useSessions={useSessions}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
startSession={startSession}
|
startSession={startSession}
|
||||||
open={open}
|
open={open}
|
||||||
query={query}
|
query={query}
|
||||||
insertSessionBefore={insertSessionBefore}
|
insertSessionBefore={insertSessionBefore}
|
||||||
onRenameRequest={(workspaceId, currentTitle) => {
|
onRenameRequest={(workspaceId, currentTitle) => {
|
||||||
setRenameTarget({ workspaceId, currentTitle })
|
setRenameTarget({ workspaceId, currentTitle })
|
||||||
setRenameDraft(currentTitle)
|
setRenameDraft(currentTitle)
|
||||||
setRenameError(null)
|
setRenameError(null)
|
||||||
}}
|
}}
|
||||||
onDeleteRequest={(workspaceId, title) => {
|
onDeleteRequest={(workspaceId, title) => {
|
||||||
setDeleteTarget({ workspaceId, title })
|
setDeleteTarget({ workspaceId, title })
|
||||||
setDeleteError(null)
|
setDeleteError(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -481,7 +484,7 @@ export function WorkspaceBrowser({
|
|||||||
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
|
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={css.deleteAction!}
|
className={css.deleteAction}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
onClick={confirmDelete}
|
onClick={confirmDelete}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function WorkspaceCreateFlow({
|
|||||||
|
|
||||||
const items: MenuEntry[] = [
|
const items: MenuEntry[] = [
|
||||||
...workspaces.map(workspace => ({
|
...workspaces.map(workspace => ({
|
||||||
id: workspace.workspaceId as string,
|
id: workspace.workspaceId,
|
||||||
label: workspace.title,
|
label: workspace.title,
|
||||||
icon: <IconFolderClose16 size={16} />,
|
icon: <IconFolderClose16 size={16} />,
|
||||||
disabled: pickingFolder,
|
disabled: pickingFolder,
|
||||||
@@ -161,8 +161,8 @@ export function WorkspaceCreateFlow({
|
|||||||
title={folderConflict ? 'A workspace with this name already exists' : 'Couldn’t open folder'}
|
title={folderConflict ? 'A workspace with this name already exists' : 'Couldn’t open folder'}
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
|
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
|
||||||
<Button variant="primary" className={css.modalAction!} onClick={openLocalFolder}>Choose again</Button>
|
<Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -179,10 +179,10 @@ export function WorkspaceCreateFlow({
|
|||||||
description="The name is used for both the workspace and its new folder."
|
description="The name is used for both the workspace and its new folder."
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
|
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className={css.modalAction!}
|
className={css.modalAction}
|
||||||
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
|
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
|
||||||
onClick={confirmCreate}
|
onClick={confirmCreate}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ export type WorkspaceBrowserProps =
|
|||||||
*/
|
*/
|
||||||
export type WorkspacePickerInjected = {
|
export type WorkspacePickerInjected = {
|
||||||
/** Explicitly create or adopt a real Workspace before targeting a Session. */
|
/** Explicitly create or adopt a real Workspace before targeting a Session. */
|
||||||
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
|
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||||
/** Ask the local Host to open its native single-directory picker. */
|
/** Ask the local Host to open its native single-directory picker. */
|
||||||
pickDirectory(): Promise<string | null>
|
pickDirectory: () => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -192,37 +192,37 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
|||||||
onDragStart={drag === undefined
|
onDragStart={drag === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: (e) => {
|
: (e) => {
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
drag.start()
|
drag.start()
|
||||||
}}
|
}}
|
||||||
onDragEnd={drag?.end}
|
onDragEnd={drag?.end}
|
||||||
onDragOver={drag === undefined
|
onDragOver={drag === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: (e) => {
|
: (e) => {
|
||||||
if (!drag.active) return
|
if (!drag.active) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.dataTransfer.dropEffect = 'move'
|
e.dataTransfer.dropEffect = 'move'
|
||||||
drag.hover(rowHalf(e))
|
drag.hover(rowHalf(e))
|
||||||
}}
|
}}
|
||||||
onDrop={drag === undefined
|
onDrop={drag === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: (e) => {
|
: (e) => {
|
||||||
if (!drag.active) return
|
if (!drag.active) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
drag.drop(rowHalf(e))
|
drag.drop(rowHalf(e))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.hasChildren && !flat
|
{row.hasChildren && !flat
|
||||||
? (
|
? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.twist}
|
className={css.twist}
|
||||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||||
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
||||||
>
|
>
|
||||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||||
<span className={css.title}>{row.title}</span>
|
<span className={css.title}>{row.title}</span>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function stubRect(row: HTMLElement): void {
|
|||||||
row.getBoundingClientRect = () => ({
|
row.getBoundingClientRect = () => ({
|
||||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
|
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
|
||||||
x: 0, y: 100, toJSON: () => ({}),
|
x: 0, y: 100, toJSON: () => ({}),
|
||||||
} as DOMRect)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
|
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
|||||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||||
recentWorkspaceId: items[0]?.workspaceId,
|
recentWorkspaceId: items[0]?.workspaceId,
|
||||||
})
|
})
|
||||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
function hook<T>(snapshot: T) {
|
||||||
|
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||||
|
}
|
||||||
|
|
||||||
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
|
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
|
||||||
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
|
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
|
||||||
@@ -297,7 +299,7 @@ describe('WorkspaceBrowser', () => {
|
|||||||
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
|
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
|
||||||
three.getBoundingClientRect = () => ({
|
three.getBoundingClientRect = () => ({
|
||||||
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
|
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
|
||||||
} as DOMRect)
|
})
|
||||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||||
fireEvent.dragStart(one, { dataTransfer })
|
fireEvent.dragStart(one, { dataTransfer })
|
||||||
// Drop on the top half of "three": insert one before three.
|
// Drop on the top half of "three": insert one before three.
|
||||||
@@ -310,7 +312,7 @@ describe('WorkspaceBrowser', () => {
|
|||||||
fireEvent.dragStart(one, { dataTransfer })
|
fireEvent.dragStart(one, { dataTransfer })
|
||||||
one.getBoundingClientRect = () => ({
|
one.getBoundingClientRect = () => ({
|
||||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
||||||
} as DOMRect)
|
})
|
||||||
fireDrag(one, 'dragOver', 105)
|
fireDrag(one, 'dragOver', 105)
|
||||||
fireDrag(one, 'drop', 105)
|
fireDrag(one, 'drop', 105)
|
||||||
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
||||||
@@ -336,7 +338,7 @@ describe('WorkspaceBrowser', () => {
|
|||||||
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
|
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
|
||||||
two.getBoundingClientRect = () => ({
|
two.getBoundingClientRect = () => ({
|
||||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||||
} as DOMRect)
|
})
|
||||||
fireDrag(two, 'drop', 155)
|
fireDrag(two, 'drop', 155)
|
||||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
|
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
|
||||||
})
|
})
|
||||||
@@ -353,7 +355,7 @@ describe('WorkspaceBrowser', () => {
|
|||||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||||
two.getBoundingClientRect = () => ({
|
two.getBoundingClientRect = () => ({
|
||||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||||
} as DOMRect)
|
})
|
||||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||||
fireEvent.dragStart(one, { dataTransfer })
|
fireEvent.dragStart(one, { dataTransfer })
|
||||||
fireEvent.dragEnd(one)
|
fireEvent.dragEnd(one)
|
||||||
@@ -382,7 +384,7 @@ describe('WorkspaceBrowser', () => {
|
|||||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||||
two.getBoundingClientRect = () => ({
|
two.getBoundingClientRect = () => ({
|
||||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||||
} as DOMRect)
|
})
|
||||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||||
fireEvent.dragStart(one, { dataTransfer })
|
fireEvent.dragStart(one, { dataTransfer })
|
||||||
fireDrag(two, 'drop', 180)
|
fireDrag(two, 'drop', 180)
|
||||||
@@ -404,13 +406,13 @@ describe('WorkspaceBrowser', () => {
|
|||||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||||
expect(input.value).toBe('Alpha')
|
expect(input.value).toBe('Alpha')
|
||||||
// Unchanged and blank names stay blocked.
|
// Unchanged and blank names stay blocked.
|
||||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||||
fireEvent.change(input, { target: { value: ' ' } })
|
fireEvent.change(input, { target: { value: ' ' } })
|
||||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||||
// A duplicate of another workspace's title shows the inline conflict.
|
// A duplicate of another workspace's title shows the inline conflict.
|
||||||
fireEvent.change(input, { target: { value: ' Beta ' } })
|
fireEvent.change(input, { target: { value: ' Beta ' } })
|
||||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
|
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
|
||||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||||
fireEvent.change(input, { target: { value: 'Gamma' } })
|
fireEvent.change(input, { target: { value: 'Gamma' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||||
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
|
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
|
||||||
@@ -473,13 +475,13 @@ describe('WorkspaceBrowser', () => {
|
|||||||
expect(dialog.textContent).toContain('folder and session logs will be kept')
|
expect(dialog.textContent).toContain('folder and session logs will be kept')
|
||||||
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
|
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
|
||||||
|
|
||||||
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
|
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
|
||||||
fireEvent.click(confirm)
|
fireEvent.click(confirm)
|
||||||
fireEvent.click(confirm)
|
fireEvent.click(confirm)
|
||||||
expect(deleteWorkspace).toHaveBeenCalledOnce()
|
expect(deleteWorkspace).toHaveBeenCalledOnce()
|
||||||
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
|
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
|
||||||
expect(confirm.disabled).toBe(true)
|
expect(confirm.disabled).toBe(true)
|
||||||
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
|
||||||
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
|
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
|
||||||
fireEvent.keyDown(document, { key: 'Escape' })
|
fireEvent.keyDown(document, { key: 'Escape' })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ function workspace(id: string, title = id): WorkspaceView {
|
|||||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
function hook<T>(snapshot: T) {
|
||||||
|
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||||
|
}
|
||||||
const sessions: SessionListState = {
|
const sessions: SessionListState = {
|
||||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||||
}
|
}
|
||||||
@@ -131,8 +133,8 @@ describe('WorkspacePicker', () => {
|
|||||||
const pending = new Promise<string | null>((settle) => { resolve = settle })
|
const pending = new Promise<string | null>((settle) => { resolve = settle })
|
||||||
const b = mount([], vi.fn(), vi.fn(() => pending))
|
const b = mount([], vi.fn(), vi.fn(() => pending))
|
||||||
chooseItem('Open local folder…')
|
chooseItem('Open local folder…')
|
||||||
expect((screen.getByRole('menuitem', { name: 'Open local folder…' }) as HTMLButtonElement).disabled).toBe(true)
|
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
|
||||||
expect((screen.getByRole('menuitem', { name: 'Create a new workspace' }) as HTMLButtonElement).disabled).toBe(true)
|
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
|
||||||
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
|
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
|
||||||
await act(async () => { resolve(null); await pending })
|
await act(async () => { resolve(null); await pending })
|
||||||
@@ -159,7 +161,7 @@ describe('WorkspacePicker', () => {
|
|||||||
chooseItem('Create a new workspace')
|
chooseItem('Create a new workspace')
|
||||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
|
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
|
||||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
|
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
|
||||||
expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
|
||||||
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
|
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
|
||||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ function standardKit(
|
|||||||
scope: SlotScope,
|
scope: SlotScope,
|
||||||
info: SessionMaybeProvideInfo | undefined,
|
info: SessionMaybeProvideInfo | undefined,
|
||||||
): {
|
): {
|
||||||
kit: InjectedProps; actions: object | undefined
|
kit: InjectedProps
|
||||||
|
actions: object | undefined
|
||||||
} {
|
} {
|
||||||
const kit: InjectedProps = {
|
const kit: InjectedProps = {
|
||||||
useSessions: observableHook(host.sessions.list),
|
useSessions: observableHook(host.sessions.list),
|
||||||
@@ -232,13 +233,13 @@ function standardKit(
|
|||||||
kit['renderSlot'] = boundRenderSlot(host, entry)
|
kit['renderSlot'] = boundRenderSlot(host, entry)
|
||||||
// renderSlotChain rides the same declaration source: only entries whose
|
// renderSlotChain rides the same declaration source: only entries whose
|
||||||
// children include a chain-kind slot receive the chain dispatch seat.
|
// children include a chain-kind slot receive the chain dispatch seat.
|
||||||
if (Object.values(entry.children).some((spec) => spec.kind === 'chain')) {
|
if (Object.values(entry.children).some(spec => spec.kind === 'chain')) {
|
||||||
kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
|
kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
|
||||||
}
|
}
|
||||||
// SessionProvider standard seat: entries declaring a session-scope child
|
// SessionProvider standard seat: entries declaring a session-scope child
|
||||||
// render the session area, so the framework hands them the self-wired
|
// render the session area, so the framework hands them the self-wired
|
||||||
// provider (module-level component = stable reference; no value import).
|
// provider (module-level component = stable reference; no value import).
|
||||||
if (Object.values(entry.children).some((spec) => spec.scope === 'session')) {
|
if (Object.values(entry.children).some(spec => spec.scope === 'session')) {
|
||||||
kit['SessionProvider'] = SessionProvider
|
kit['SessionProvider'] = SessionProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +254,9 @@ function standardKit(
|
|||||||
* composition point, one per scope branch).
|
* composition point, one per scope branch).
|
||||||
*/
|
*/
|
||||||
function SessionEntry({ entry, ownerProps, info }: {
|
function SessionEntry({ entry, ownerProps, info }: {
|
||||||
entry: StoredEntry; ownerProps: object; info: SessionProvideInfo
|
entry: StoredEntry
|
||||||
|
ownerProps: object
|
||||||
|
info: SessionProvideInfo
|
||||||
}) {
|
}) {
|
||||||
const host = useHost()
|
const host = useHost()
|
||||||
const Comp = entry.component as FC<InjectedProps>
|
const Comp = entry.component as FC<InjectedProps>
|
||||||
@@ -280,7 +283,9 @@ function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: obje
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StrictSessionEntry({ slotKey, entry, ownerProps }: {
|
function StrictSessionEntry({ slotKey, entry, ownerProps }: {
|
||||||
slotKey: string; entry: StoredEntry; ownerProps: object
|
slotKey: string
|
||||||
|
entry: StoredEntry
|
||||||
|
ownerProps: object
|
||||||
}) {
|
}) {
|
||||||
const info = useSessionMaybeProvideInfo()
|
const info = useSessionMaybeProvideInfo()
|
||||||
if (info.sessionId === undefined) return null
|
if (info.sessionId === undefined) return null
|
||||||
@@ -292,12 +297,14 @@ function StrictSessionEntry({ slotKey, entry, ownerProps }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SlotOutlet({ slotKey, ownerProps, opts }: {
|
function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||||
slotKey: string; ownerProps: object; opts?: (RenderOpts & ChainRenderOpts) | undefined
|
slotKey: string
|
||||||
|
ownerProps: object
|
||||||
|
opts?: (RenderOpts & ChainRenderOpts) | undefined
|
||||||
}) {
|
}) {
|
||||||
const host = useHost()
|
const host = useHost()
|
||||||
// Version tick drives entries() re-read; the host batches per microtask.
|
// Version tick drives entries() re-read; the host batches per microtask.
|
||||||
useSyncExternalStore(
|
useSyncExternalStore(
|
||||||
(fn) => host.subscribe(slotKey, fn),
|
fn => host.subscribe(slotKey, fn),
|
||||||
() => host.getVersion(slotKey),
|
() => host.getVersion(slotKey),
|
||||||
)
|
)
|
||||||
const sessionInfo = useSessionMaybeProvideInfo()
|
const sessionInfo = useSessionMaybeProvideInfo()
|
||||||
@@ -321,12 +328,12 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|||||||
spec.scope === 'session'
|
spec.scope === 'session'
|
||||||
? <StrictSessionEntry slotKey={slotKey} entry={entry} ownerProps={owner} key={key} />
|
? <StrictSessionEntry slotKey={slotKey} entry={entry} ownerProps={owner} key={key} />
|
||||||
: (
|
: (
|
||||||
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
||||||
{spec.scope === 'session-maybe'
|
{spec.scope === 'session-maybe'
|
||||||
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
|
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
|
||||||
: <RootEntry entry={entry} ownerProps={owner} />}
|
: <RootEntry entry={entry} ownerProps={owner} />}
|
||||||
</SlotErrorBoundary>
|
</SlotErrorBoundary>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (spec.kind === 'single') {
|
if (spec.kind === 'single') {
|
||||||
@@ -335,7 +342,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|||||||
return guarded(entry)
|
return guarded(entry)
|
||||||
}
|
}
|
||||||
if (spec.kind === 'keyed') {
|
if (spec.kind === 'keyed') {
|
||||||
const entry = entries.find((e) => e.options?.key === opts?.entryKey)
|
const entry = entries.find(e => e.options.key === opts?.entryKey)
|
||||||
if (!entry) return <>{opts?.fallback ?? null}</>
|
if (!entry) return <>{opts?.fallback ?? null}</>
|
||||||
return guarded(entry)
|
return guarded(entry)
|
||||||
}
|
}
|
||||||
@@ -388,13 +395,13 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|||||||
return elected ?? <>{opts?.fallback ?? null}</>
|
return elected ?? <>{opts?.fallback ?? null}</>
|
||||||
}
|
}
|
||||||
// list: registration order refined by explicit order, optional id filter.
|
// list: registration order refined by explicit order, optional id filter.
|
||||||
const withListOptions = entries.map((entry) => ({
|
const withListOptions = entries.map(entry => ({
|
||||||
entry,
|
entry,
|
||||||
id: entry.options?.id,
|
id: entry.options.id,
|
||||||
order: entry.options?.order ?? 0,
|
order: entry.options.order ?? 0,
|
||||||
}))
|
}))
|
||||||
let list = [...withListOptions].sort((a, b) => a.order - b.order)
|
let list = [...withListOptions].sort((a, b) => a.order - b.order)
|
||||||
if (opts?.only !== undefined) list = list.filter((item) => item.id === opts.only)
|
if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only)
|
||||||
if (list.length === 0) return <>{opts?.fallback ?? null}</>
|
if (list.length === 0) return <>{opts?.fallback ?? null}</>
|
||||||
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
|
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
|
||||||
}
|
}
|
||||||
@@ -403,7 +410,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|||||||
function RootOutlet({ ownerProps }: { ownerProps: object }) {
|
function RootOutlet({ ownerProps }: { ownerProps: object }) {
|
||||||
const host = useHost()
|
const host = useHost()
|
||||||
useSyncExternalStore(
|
useSyncExternalStore(
|
||||||
(fn) => host.subscribe('root', fn),
|
fn => host.subscribe('root', fn),
|
||||||
() => host.getVersion('root'),
|
() => host.getVersion('root'),
|
||||||
)
|
)
|
||||||
const entry = host.entriesOf('root')[0]
|
const entry = host.entriesOf('root')[0]
|
||||||
|
|||||||
@@ -73,11 +73,14 @@ const absentSource: HostObservable<undefined> = {
|
|||||||
/** Bind a source that disappears with the current session to an optional selector hook. */
|
/** Bind a source that disappears with the current session to an optional selector hook. */
|
||||||
export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> {
|
export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> {
|
||||||
if (source !== undefined) return observableHook(source)
|
if (source !== undefined) return observableHook(source)
|
||||||
return useAbsentSnapshot as MaybeSnapshotSelectorHook<T>
|
return useAbsentSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
|
function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
|
||||||
return observableHook(absentSource)(() => undefined)
|
// The uSES subscription must still run (hook-order stability); the absent
|
||||||
|
// source always snapshots undefined, returned explicitly.
|
||||||
|
observableHook(absentSource)(() => undefined)
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +90,7 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
|
|||||||
*/
|
*/
|
||||||
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
|
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
|
||||||
const host = useHost()
|
const host = useHost()
|
||||||
const id = observableHook(host.sessions.current)((s) => s)
|
const id = observableHook(host.sessions.current)(s => s)
|
||||||
return (
|
return (
|
||||||
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
|
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
|
||||||
{children}
|
{children}
|
||||||
@@ -112,7 +115,7 @@ export interface SessionProviderProps {
|
|||||||
*/
|
*/
|
||||||
export function SessionProvider({ empty, children }: SessionProviderProps) {
|
export function SessionProvider({ empty, children }: SessionProviderProps) {
|
||||||
const host = useHost()
|
const host = useHost()
|
||||||
const id = observableHook(host.sessions.current)((s) => s)
|
const id = observableHook(host.sessions.current)(s => s)
|
||||||
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
|
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
|
||||||
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
|
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from
|
|||||||
// Keep equality local: this suite asserts the eq parameter contract without
|
// Keep equality local: this suite asserts the eq parameter contract without
|
||||||
// adding a reverse dependency from web-react to runtime.
|
// adding a reverse dependency from web-react to runtime.
|
||||||
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
|
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
|
||||||
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))
|
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(k => Object.is(a[k], b[k]))
|
||||||
|
|
||||||
interface Snap { a: number; b: number }
|
interface Snap { a: number; b: number }
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ describe('bindSnapshotSelector', () => {
|
|||||||
const { source, set } = makeSource({ a: 1, b: 10 })
|
const { source, set } = makeSource({ a: 1, b: 10 })
|
||||||
const useSelector = bindSnapshotSelector(source)
|
const useSelector = bindSnapshotSelector(source)
|
||||||
const probe = { renders: 0, value: undefined as number | undefined }
|
const probe = { renders: 0, value: undefined as number | undefined }
|
||||||
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||||
expect(probe.value).toBe(1)
|
expect(probe.value).toBe(1)
|
||||||
const before = probe.renders
|
const before = probe.renders
|
||||||
act(() => { set({ a: 1, b: 11 }) }) // unrelated field: Object.is bail
|
act(() => { set({ a: 1, b: 11 }) }) // unrelated field: Object.is bail
|
||||||
@@ -65,7 +65,7 @@ describe('bindSnapshotSelector', () => {
|
|||||||
const { source, set } = makeSource({ a: 1, b: 10 })
|
const { source, set } = makeSource({ a: 1, b: 10 })
|
||||||
const useSelector = bindSnapshotSelector(source)
|
const useSelector = bindSnapshotSelector(source)
|
||||||
const probe = { renders: 0, value: undefined as { a: number } | undefined }
|
const probe = { renders: 0, value: undefined as { a: number } | undefined }
|
||||||
render(<Harness useSelector={useSelector} sel={(s) => ({ a: s.a })} eq={shallowEqual} probe={probe} />)
|
render(<Harness useSelector={useSelector} sel={s => ({ a: s.a })} eq={shallowEqual} probe={probe} />)
|
||||||
const before = probe.renders
|
const before = probe.renders
|
||||||
act(() => { set({ a: 1, b: 99 }) }) // fresh object, shallow-equal slice
|
act(() => { set({ a: 1, b: 99 }) }) // fresh object, shallow-equal slice
|
||||||
expect(probe.renders).toBe(before)
|
expect(probe.renders).toBe(before)
|
||||||
@@ -78,11 +78,11 @@ describe('bindSnapshotSelector', () => {
|
|||||||
const { source, set, stats } = makeSource({ a: 1, b: 10 })
|
const { source, set, stats } = makeSource({ a: 1, b: 10 })
|
||||||
const useSelector = bindSnapshotSelector(source)
|
const useSelector = bindSnapshotSelector(source)
|
||||||
const probe = { renders: 0, value: undefined as number | undefined }
|
const probe = { renders: 0, value: undefined as number | undefined }
|
||||||
const { rerender } = render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
const { rerender } = render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||||
const after = stats.subscribeCalls
|
const after = stats.subscribeCalls
|
||||||
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||||
act(() => { set({ a: 2, b: 10 }) })
|
act(() => { set({ a: 2, b: 10 }) })
|
||||||
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||||
expect(stats.subscribeCalls).toBe(after)
|
expect(stats.subscribeCalls).toBe(after)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ describe('bindSnapshotSelector', () => {
|
|||||||
const probe = { renders: 0, value: undefined as number | undefined }
|
const probe = { renders: 0, value: undefined as number | undefined }
|
||||||
const view = render(
|
const view = render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />
|
<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
expect(probe.value).toBe(1)
|
expect(probe.value).toBe(1)
|
||||||
@@ -112,7 +112,7 @@ describe('bindSnapshotSelector', () => {
|
|||||||
}
|
}
|
||||||
const useSelector = bindSnapshotSelector(new MethodSource())
|
const useSelector = bindSnapshotSelector(new MethodSource())
|
||||||
const probe = { renders: 0, value: undefined as number | undefined }
|
const probe = { renders: 0, value: undefined as number | undefined }
|
||||||
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||||
expect(probe.value).toBe(7)
|
expect(probe.value).toBe(7)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
|
|||||||
function hostOver(core: SlotCore): SlotRendererHost {
|
function hostOver(core: SlotCore): SlotRendererHost {
|
||||||
return {
|
return {
|
||||||
subscribe: (key, fn) => core.subscribe(key, fn),
|
subscribe: (key, fn) => core.subscribe(key, fn),
|
||||||
getVersion: (key) => core.getVersion(key),
|
getVersion: key => core.getVersion(key),
|
||||||
entriesOf: (key) => core.entries(key),
|
entriesOf: key => core.entries(key),
|
||||||
specOf: (key) => core.specDynamic(key),
|
specOf: key => core.specDynamic(key),
|
||||||
isLive: (entry) => core.isLive(entry),
|
isLive: entry => core.isLive(entry),
|
||||||
storeOf: () => undefined,
|
storeOf: () => undefined,
|
||||||
sessions: {
|
sessions: {
|
||||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||||
@@ -61,7 +61,7 @@ function mountFrame(core: SlotCore, body: (renderSlot: FrameSlots['renderSlot'])
|
|||||||
describe('createSlotRenderer over the real SlotCore', () => {
|
describe('createSlotRenderer over the real SlotCore', () => {
|
||||||
it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => {
|
it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => {
|
||||||
const core = new SlotCore()
|
const core = new SlotCore()
|
||||||
const { view } = mountFrame(core, (renderSlot) =>
|
const { view } = mountFrame(core, renderSlot =>
|
||||||
renderSlot('spec.single', {}, { fallback: <i>none</i> }))
|
renderSlot('spec.single', {}, { fallback: <i>none</i> }))
|
||||||
expect(view.container.textContent).toBe('none')
|
expect(view.container.textContent).toBe('none')
|
||||||
let dispose = () => {}
|
let dispose = () => {}
|
||||||
@@ -78,7 +78,7 @@ describe('createSlotRenderer over the real SlotCore', () => {
|
|||||||
const core = new SlotCore()
|
const core = new SlotCore()
|
||||||
const notified = vi.fn()
|
const notified = vi.fn()
|
||||||
core.subscribe('spec.list', notified)
|
core.subscribe('spec.list', notified)
|
||||||
const { view } = mountFrame(core, (renderSlot) => renderSlot('spec.list', {}))
|
const { view } = mountFrame(core, renderSlot => renderSlot('spec.list', {}))
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>)
|
core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>)
|
||||||
core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>)
|
core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>)
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry
|
|||||||
* but entry.store is typed to the full contract — the real defineStore lives
|
* but entry.store is typed to the full contract — the real defineStore lives
|
||||||
* in runtime, which web-react tests must not import (dependency direction).
|
* in runtime, which web-react tests must not import (dependency direction).
|
||||||
*/
|
*/
|
||||||
function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> {
|
function miniStore<T extends object>(
|
||||||
|
init: () => T,
|
||||||
|
mutators: Record<string, (state: T, ...params: never[]) => T>,
|
||||||
|
): StoreHandle<T, ActionsDecl<T>> {
|
||||||
return {
|
return {
|
||||||
spec: { init, actions: {} },
|
spec: { init, actions: {} },
|
||||||
create: () => {
|
create: () => {
|
||||||
@@ -96,10 +99,10 @@ function makeHost() {
|
|||||||
subs.set(key, set)
|
subs.set(key, set)
|
||||||
return () => { set.delete(fn) }
|
return () => { set.delete(fn) }
|
||||||
},
|
},
|
||||||
getVersion: (key) => versions.get(key) ?? 0,
|
getVersion: key => versions.get(key) ?? 0,
|
||||||
entriesOf: (key) => entries.get(key) ?? [],
|
entriesOf: key => entries.get(key) ?? [],
|
||||||
specOf: (key) => specs.get(key),
|
specOf: key => specs.get(key),
|
||||||
isLive: (entry) => live.has(entry),
|
isLive: entry => live.has(entry),
|
||||||
storeOf: (entry, scopeKey) => {
|
storeOf: (entry, scopeKey) => {
|
||||||
if (entry.store === undefined) return undefined
|
if (entry.store === undefined) return undefined
|
||||||
let perScope = storeCache.get(entry)
|
let perScope = storeCache.get(entry)
|
||||||
@@ -121,8 +124,8 @@ function makeHost() {
|
|||||||
sessions: {
|
sessions: {
|
||||||
list,
|
list,
|
||||||
current,
|
current,
|
||||||
provideInfo: (id) => infos.get(id),
|
provideInfo: id => infos.get(id),
|
||||||
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
|
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||||
?? { sessionId: undefined, hooks: {}, props: {} },
|
?? { sessionId: undefined, hooks: {}, props: {} },
|
||||||
},
|
},
|
||||||
workspaces: { list: workspaces },
|
workspaces: { list: workspaces },
|
||||||
@@ -145,7 +148,7 @@ function makeHost() {
|
|||||||
live.add(entry)
|
live.add(entry)
|
||||||
bump(key)
|
bump(key)
|
||||||
return () => {
|
return () => {
|
||||||
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
|
entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
|
||||||
live.delete(entry)
|
live.delete(entry)
|
||||||
bump(key)
|
bump(key)
|
||||||
}
|
}
|
||||||
@@ -187,7 +190,7 @@ const chainEntryOf = (partial: {
|
|||||||
priority?: number
|
priority?: number
|
||||||
}): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({
|
}): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({
|
||||||
component: partial.component,
|
component: partial.component,
|
||||||
select: partial.select as StoredEntry['select'],
|
select: partial.select,
|
||||||
...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}),
|
...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -230,7 +233,7 @@ describe('child outlets and the renderSlot binding', () => {
|
|||||||
const h = makeHost()
|
const h = makeHost()
|
||||||
h.declare('k.single', SINGLE_ROOT)
|
h.declare('k.single', SINGLE_ROOT)
|
||||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
|
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
|
||||||
(renderSlot) => renderSlot('k.single', {}, { fallback: <i>none</i> }))
|
renderSlot => renderSlot('k.single', {}, { fallback: <i>none</i> }))
|
||||||
expect(view.container.textContent).toBe('none')
|
expect(view.container.textContent).toBe('none')
|
||||||
let dispose = () => {}
|
let dispose = () => {}
|
||||||
act(() => { dispose = h.add('k.single', { component: () => <b>SB</b> }) })
|
act(() => { dispose = h.add('k.single', { component: () => <b>SB</b> }) })
|
||||||
@@ -242,7 +245,7 @@ describe('child outlets and the renderSlot binding', () => {
|
|||||||
it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => {
|
it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => {
|
||||||
const h = makeHost()
|
const h = makeHost()
|
||||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
|
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
|
||||||
(renderSlot) => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>)
|
renderSlot => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>)
|
||||||
// Declared by children (authorization) but absent from the ledger (specOf
|
// Declared by children (authorization) but absent from the ledger (specOf
|
||||||
// undefined): the outlet renders nothing, not even the fallback path's spec dispatch.
|
// undefined): the outlet renders nothing, not even the fallback path's spec dispatch.
|
||||||
expect(view.container.querySelector('main')!.textContent).toBe('')
|
expect(view.container.querySelector('main')!.textContent).toBe('')
|
||||||
@@ -256,7 +259,7 @@ describe('child outlets and the renderSlot binding', () => {
|
|||||||
h.add('k.list', { component: () => <span>a</span>, options: { id: 'a', order: 1 } })
|
h.add('k.list', { component: () => <span>a</span>, options: { id: 'a', order: 1 } })
|
||||||
h.add('k.keyed', { component: () => <span>goal</span>, options: { key: 'goal' } })
|
h.add('k.keyed', { component: () => <span>goal</span>, options: { key: 'goal' } })
|
||||||
const children = { 'k.list': { kind: 'list', scope: 'root' } as DeclaredSpec, 'k.keyed': { kind: 'keyed', scope: 'root' } as DeclaredSpec }
|
const children = { 'k.list': { kind: 'list', scope: 'root' } as DeclaredSpec, 'k.keyed': { kind: 'keyed', scope: 'root' } as DeclaredSpec }
|
||||||
const { view } = mountRoot(h, children, (renderSlot) => <>
|
const { view } = mountRoot(h, children, renderSlot => <>
|
||||||
<main>{renderSlot('k.list', {})}</main>
|
<main>{renderSlot('k.list', {})}</main>
|
||||||
<aside>{renderSlot('k.list', {}, { only: 'b' })}</aside>
|
<aside>{renderSlot('k.list', {}, { only: 'b' })}</aside>
|
||||||
<nav>{renderSlot('k.keyed', {}, { entryKey: 'goal' })}</nav>
|
<nav>{renderSlot('k.keyed', {}, { entryKey: 'goal' })}</nav>
|
||||||
@@ -291,7 +294,7 @@ describe('child outlets and the renderSlot binding', () => {
|
|||||||
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
|
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
|
||||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
|
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
|
||||||
(renderSlot) => renderSlot('k.list', {}))
|
renderSlot => renderSlot('k.list', {}))
|
||||||
spy.mockRestore()
|
spy.mockRestore()
|
||||||
expect(view.container.textContent).toBe('alive')
|
expect(view.container.textContent).toBe('alive')
|
||||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||||
@@ -309,10 +312,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
|||||||
}))
|
}))
|
||||||
h.add('k.chain', chainEntryOf({
|
h.add('k.chain', chainEntryOf({
|
||||||
component: ({ matched }: { matched?: { label: string } }) => <b>{matched?.label}</b>,
|
component: ({ matched }: { matched?: { label: string } }) => <b>{matched?.label}</b>,
|
||||||
select: (owner) => ({ label: `hit:${(owner as { tag: string }).tag}` }),
|
select: owner => ({ label: `hit:${(owner as { tag: string }).tag}` }),
|
||||||
}))
|
}))
|
||||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||||
(renderSlotChain) => renderSlotChain('k.chain', { tag: 'T' }))
|
renderSlotChain => renderSlotChain('k.chain', { tag: 'T' }))
|
||||||
// The declining entry never mounts: the routing decision is select-layer only.
|
// The declining entry never mounts: the routing decision is select-layer only.
|
||||||
expect(view.container.textContent).toBe('hit:T')
|
expect(view.container.textContent).toBe('hit:T')
|
||||||
expect(declinerBody).not.toHaveBeenCalled()
|
expect(declinerBody).not.toHaveBeenCalled()
|
||||||
@@ -327,10 +330,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
|||||||
}))
|
}))
|
||||||
h.add('k.chain', chainEntryOf({
|
h.add('k.chain', chainEntryOf({
|
||||||
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
|
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
|
||||||
select: (owner) => (owner as { pick?: string }).pick ?? null,
|
select: owner => (owner as { pick?: string }).pick ?? null,
|
||||||
}))
|
}))
|
||||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
|
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
|
||||||
<main>{renderSlotChain('k.chain', { pick: 'OK' })}</main>
|
<main>{renderSlotChain('k.chain', { pick: 'OK' })}</main>
|
||||||
<aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside>
|
<aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside>
|
||||||
</>)
|
</>)
|
||||||
@@ -347,16 +350,16 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
|||||||
h.declare('k.chain', CHAIN_ROOT)
|
h.declare('k.chain', CHAIN_ROOT)
|
||||||
h.add('k.chain', chainEntryOf({
|
h.add('k.chain', chainEntryOf({
|
||||||
component: () => { throw new Error('entry A boom') },
|
component: () => { throw new Error('entry A boom') },
|
||||||
select: (owner) => (owner as { pick?: string }).pick === 'A' ? {} : null,
|
select: owner => (owner as { pick?: string }).pick === 'A' ? {} : null,
|
||||||
}))
|
}))
|
||||||
h.add('k.chain', chainEntryOf({
|
h.add('k.chain', chainEntryOf({
|
||||||
component: () => <b>B-ok</b>,
|
component: () => <b>B-ok</b>,
|
||||||
select: (owner) => (owner as { pick?: string }).pick === 'B' ? {} : null,
|
select: owner => (owner as { pick?: string }).pick === 'B' ? {} : null,
|
||||||
}))
|
}))
|
||||||
let pick = 'A'
|
let pick = 'A'
|
||||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||||
(renderSlotChain) => renderSlotChain('k.chain', { pick }))
|
renderSlotChain => renderSlotChain('k.chain', { pick }))
|
||||||
spy.mockRestore()
|
spy.mockRestore()
|
||||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||||
// Re-elect entry B: the entry-keyed boundary remounts fresh instead of
|
// Re-elect entry B: the entry-keyed boundary remounts fresh instead of
|
||||||
@@ -372,9 +375,9 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
|||||||
h.declare('k.chain', CHAIN_ROOT)
|
h.declare('k.chain', CHAIN_ROOT)
|
||||||
h.add('k.chain', chainEntryOf({
|
h.add('k.chain', chainEntryOf({
|
||||||
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
|
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
|
||||||
select: (owner) => (owner as { pick?: string }).pick ?? null,
|
select: owner => (owner as { pick?: string }).pick ?? null,
|
||||||
}))
|
}))
|
||||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
|
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
|
||||||
<main>{renderSlotChain('k.chain', {}, { fallback: <i>bar</i> })}</main>
|
<main>{renderSlotChain('k.chain', {}, { fallback: <i>bar</i> })}</main>
|
||||||
<aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside>
|
<aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside>
|
||||||
</>)
|
</>)
|
||||||
@@ -387,7 +390,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
|||||||
const h = makeHost()
|
const h = makeHost()
|
||||||
h.declare('k.chain', CHAIN_ROOT)
|
h.declare('k.chain', CHAIN_ROOT)
|
||||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||||
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
|
renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
|
||||||
expect(view.container.textContent).toBe('none')
|
expect(view.container.textContent).toBe('none')
|
||||||
let dispose = () => {}
|
let dispose = () => {}
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -422,7 +425,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
}))
|
}))
|
||||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||||
(renderSlotChain) => renderSlotChain('k.chain', {}))
|
renderSlotChain => renderSlotChain('k.chain', {}))
|
||||||
expect(view.container.textContent).toBe('early')
|
expect(view.container.textContent).toBe('early')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -490,13 +493,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
|||||||
h.declare('k.chain', CHAIN_ROOT)
|
h.declare('k.chain', CHAIN_ROOT)
|
||||||
h.add('k.chain', chainEntryOf({
|
h.add('k.chain', chainEntryOf({
|
||||||
component: () => <b>TAKEOVER</b>,
|
component: () => <b>TAKEOVER</b>,
|
||||||
select: (owner) => (owner as { take?: boolean }).take ? {} : null,
|
select: owner => (owner as { take?: boolean }).take ? {} : null,
|
||||||
}))
|
}))
|
||||||
const mounted = vi.fn()
|
const mounted = vi.fn()
|
||||||
const Probe = fallbackProbe(mounted)
|
const Probe = fallbackProbe(mounted)
|
||||||
let take = false
|
let take = false
|
||||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||||
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true }))
|
renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true }))
|
||||||
const wrapper = () => view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')!
|
const wrapper = () => view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')!
|
||||||
const input = () => view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
|
const input = () => view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
|
||||||
|
|
||||||
@@ -525,13 +528,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
|||||||
h.declare('k.chain', CHAIN_ROOT)
|
h.declare('k.chain', CHAIN_ROOT)
|
||||||
h.add('k.chain', chainEntryOf({
|
h.add('k.chain', chainEntryOf({
|
||||||
component: () => <b>TAKEOVER</b>,
|
component: () => <b>TAKEOVER</b>,
|
||||||
select: (owner) => (owner as { take?: boolean }).take ? {} : null,
|
select: owner => (owner as { take?: boolean }).take ? {} : null,
|
||||||
}))
|
}))
|
||||||
const mounted = vi.fn()
|
const mounted = vi.fn()
|
||||||
const Probe = fallbackProbe(mounted)
|
const Probe = fallbackProbe(mounted)
|
||||||
let take = false
|
let take = false
|
||||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||||
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe /> }))
|
renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe /> }))
|
||||||
fireEvent.change(view.container.querySelector('input[aria-label="probe"]')!, { target: { value: 'gone' } })
|
fireEvent.change(view.container.querySelector('input[aria-label="probe"]')!, { target: { value: 'gone' } })
|
||||||
expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull()
|
expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull()
|
||||||
|
|
||||||
@@ -561,7 +564,7 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
|||||||
priority: 2,
|
priority: 2,
|
||||||
}))
|
}))
|
||||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||||
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true }))
|
renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true }))
|
||||||
expect(view.container.textContent).toContain('ELECTED')
|
expect(view.container.textContent).toContain('ELECTED')
|
||||||
expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
|
expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
|
||||||
spy.mockRestore()
|
spy.mockRestore()
|
||||||
@@ -578,9 +581,9 @@ describe('standard-kit synthesis', () => {
|
|||||||
h.declare('k.single', SINGLE_ROOT)
|
h.declare('k.single', SINGLE_ROOT)
|
||||||
h.add('k.single', {
|
h.add('k.single', {
|
||||||
component: ({ useSessions }: { useSessions: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
|
component: ({ useSessions }: { useSessions: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
|
||||||
<b>{useSessions((s) => s.ids.length)}</b>,
|
<b>{useSessions(s => s.ids.length)}</b>,
|
||||||
})
|
})
|
||||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
|
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||||
expect(view.container.textContent).toBe('0')
|
expect(view.container.textContent).toBe('0')
|
||||||
act(() => { h.list.set({ ids: ['a', 'b'] }) })
|
act(() => { h.list.set({ ids: ['a', 'b'] }) })
|
||||||
expect(view.container.textContent).toBe('2')
|
expect(view.container.textContent).toBe('2')
|
||||||
@@ -591,9 +594,9 @@ describe('standard-kit synthesis', () => {
|
|||||||
h.declare('k.single', SINGLE_ROOT)
|
h.declare('k.single', SINGLE_ROOT)
|
||||||
h.add('k.single', {
|
h.add('k.single', {
|
||||||
component: ({ useWorkspaces }: { useWorkspaces: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
|
component: ({ useWorkspaces }: { useWorkspaces: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
|
||||||
<b>{useWorkspaces((s) => s.ids.length)}</b>,
|
<b>{useWorkspaces(s => s.ids.length)}</b>,
|
||||||
})
|
})
|
||||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
|
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||||
expect(view.container.textContent).toBe('0')
|
expect(view.container.textContent).toBe('0')
|
||||||
act(() => { h.workspaces.set({ ids: ['w1'] }) })
|
act(() => { h.workspaces.set({ ids: ['w1'] }) })
|
||||||
expect(view.container.textContent).toBe('1')
|
expect(view.container.textContent).toBe('1')
|
||||||
@@ -606,11 +609,11 @@ describe('standard-kit synthesis', () => {
|
|||||||
const seen: AnyProps[] = []
|
const seen: AnyProps[] = []
|
||||||
h.add('k.session', {
|
h.add('k.session', {
|
||||||
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
|
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
|
||||||
seen.push({ ...props, read: props.useSession!((s) => s.sid) })
|
seen.push({ ...props, read: props.useSession!(s => s.sid) })
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
|
mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
|
||||||
<SessionProvider empty={() => <i>empty</i>}>
|
<SessionProvider empty={() => <i>empty</i>}>
|
||||||
{() => renderSlot('k.session', {})}
|
{() => renderSlot('k.session', {})}
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
@@ -669,14 +672,14 @@ describe('standard-kit synthesis', () => {
|
|||||||
h.declare('k.session', SINGLE_SESSION)
|
h.declare('k.session', SINGLE_SESSION)
|
||||||
h.add('k.session', { component: () => <b>x</b> })
|
h.add('k.session', { component: () => <b>x</b> })
|
||||||
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION },
|
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION },
|
||||||
(renderSlot) => renderSlot('k.session', {}))
|
renderSlot => renderSlot('k.session', {}))
|
||||||
expect(view.container.querySelector('b')).toBeNull()
|
expect(view.container.querySelector('b')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
|
it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
|
||||||
const h = makeHost()
|
const h = makeHost()
|
||||||
h.declare('k.single', SINGLE_ROOT)
|
h.declare('k.single', SINGLE_ROOT)
|
||||||
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
|
const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
|
||||||
let bump = () => {}
|
let bump = () => {}
|
||||||
h.add('k.single', {
|
h.add('k.single', {
|
||||||
component: ({ useStore, actions }: {
|
component: ({ useStore, actions }: {
|
||||||
@@ -684,11 +687,11 @@ describe('standard-kit synthesis', () => {
|
|||||||
actions: { inc: () => void }
|
actions: { inc: () => void }
|
||||||
}) => {
|
}) => {
|
||||||
bump = actions.inc
|
bump = actions.inc
|
||||||
return <b>{useStore((s) => s.n)}</b>
|
return <b>{useStore(s => s.n)}</b>
|
||||||
},
|
},
|
||||||
store: handle,
|
store: handle,
|
||||||
})
|
})
|
||||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
|
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||||
expect(view.container.textContent).toBe('0')
|
expect(view.container.textContent).toBe('0')
|
||||||
act(() => { bump() })
|
act(() => { bump() })
|
||||||
expect(view.container.textContent).toBe('1')
|
expect(view.container.textContent).toBe('1')
|
||||||
@@ -707,11 +710,11 @@ describe('standard-kit synthesis', () => {
|
|||||||
actions: { setDraft: (text: string) => void }
|
actions: { setDraft: (text: string) => void }
|
||||||
}) => {
|
}) => {
|
||||||
setDraft = actions.setDraft
|
setDraft = actions.setDraft
|
||||||
return <b>{useStore((s) => s.draft) || '(blank)'}</b>
|
return <b>{useStore(s => s.draft) || '(blank)'}</b>
|
||||||
},
|
},
|
||||||
store: handle,
|
store: handle,
|
||||||
})
|
})
|
||||||
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
|
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
|
||||||
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
||||||
))
|
))
|
||||||
act(() => { h.current.set('s1') })
|
act(() => { h.current.set('s1') })
|
||||||
@@ -730,7 +733,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
|||||||
h.declare('k.single', SINGLE_ROOT)
|
h.declare('k.single', SINGLE_ROOT)
|
||||||
const inject = vi.fn(() => ({ tag: 'FROM-INJECT' }))
|
const inject = vi.fn(() => ({ tag: 'FROM-INJECT' }))
|
||||||
h.add('k.single', { component: ({ tag }: { tag?: string }) => <b>{tag}</b>, inject })
|
h.add('k.single', { component: ({ tag }: { tag?: string }) => <b>{tag}</b>, inject })
|
||||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
|
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||||
expect(view.container.textContent).toBe('FROM-INJECT')
|
expect(view.container.textContent).toBe('FROM-INJECT')
|
||||||
act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet
|
act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet
|
||||||
expect(inject).toHaveBeenCalledTimes(1)
|
expect(inject).toHaveBeenCalledTimes(1)
|
||||||
@@ -745,9 +748,9 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
|||||||
const inject = vi.fn((sessionId: string) => ({ sid: sessionId }))
|
const inject = vi.fn((sessionId: string) => ({ sid: sessionId }))
|
||||||
h.add('k.session', {
|
h.add('k.session', {
|
||||||
component: ({ sid }: { sid?: string }) => <b>{sid}</b>,
|
component: ({ sid }: { sid?: string }) => <b>{sid}</b>,
|
||||||
inject: inject as unknown as StoredEntry['inject'],
|
inject: inject,
|
||||||
})
|
})
|
||||||
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
|
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
|
||||||
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
||||||
))
|
))
|
||||||
act(() => { h.current.set('s1') })
|
act(() => { h.current.set('s1') })
|
||||||
@@ -767,22 +770,22 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
|||||||
h.declare('k.single', SINGLE_ROOT)
|
h.declare('k.single', SINGLE_ROOT)
|
||||||
h.declare('k.session', SINGLE_SESSION)
|
h.declare('k.session', SINGLE_SESSION)
|
||||||
h.addSession('s1')
|
h.addSession('s1')
|
||||||
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
|
const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
|
||||||
const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
|
const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
|
||||||
const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
|
const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
|
||||||
const seenRoot: AnyProps[] = []
|
const seenRoot: AnyProps[] = []
|
||||||
const seenSession: AnyProps[] = []
|
const seenSession: AnyProps[] = []
|
||||||
h.add('k.single', {
|
h.add('k.single', {
|
||||||
component: (props: object) => { seenRoot.push(props as AnyProps); return null },
|
component: (props: object) => { seenRoot.push(props as AnyProps); return null },
|
||||||
inject: rootInject as unknown as StoredEntry['inject'],
|
inject: rootInject,
|
||||||
store: handle,
|
store: handle,
|
||||||
})
|
})
|
||||||
h.add('k.session', {
|
h.add('k.session', {
|
||||||
component: (props: object) => { seenSession.push(props as AnyProps); return null },
|
component: (props: object) => { seenSession.push(props as AnyProps); return null },
|
||||||
inject: sessionInject as unknown as StoredEntry['inject'],
|
inject: sessionInject,
|
||||||
store: handle,
|
store: handle,
|
||||||
})
|
})
|
||||||
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, (renderSlot) => <>
|
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, renderSlot => <>
|
||||||
{renderSlot('k.single', {})}
|
{renderSlot('k.single', {})}
|
||||||
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
||||||
</>)
|
</>)
|
||||||
@@ -807,7 +810,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
|||||||
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
|
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
|
||||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
|
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
|
||||||
(renderSlot) => <main>{renderSlot('k.list', {})}</main>)
|
renderSlot => <main>{renderSlot('k.list', {})}</main>)
|
||||||
spy.mockRestore()
|
spy.mockRestore()
|
||||||
// The failing entry blacks out alone; the sibling and the tree above survive.
|
// The failing entry blacks out alone; the sibling and the tree above survive.
|
||||||
expect(view.container.querySelector('main')).not.toBeNull()
|
expect(view.container.querySelector('main')).not.toBeNull()
|
||||||
@@ -824,7 +827,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
|||||||
inject: () => ({ fromInject: 'inject', shared: 'inject' }),
|
inject: () => ({ fromInject: 'inject', shared: 'inject' }),
|
||||||
})
|
})
|
||||||
mountRoot(h, { 'k.single': SINGLE_ROOT },
|
mountRoot(h, { 'k.single': SINGLE_ROOT },
|
||||||
(renderSlot) => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
|
renderSlot => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
|
||||||
const props = seen.at(-1)!
|
const props = seen.at(-1)!
|
||||||
expect(typeof props['useSessions']).toBe('function') // kit always present
|
expect(typeof props['useSessions']).toBe('function') // kit always present
|
||||||
expect(typeof props['useWorkspaces']).toBe('function')
|
expect(typeof props['useWorkspaces']).toBe('function')
|
||||||
|
|||||||
@@ -43,15 +43,15 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
|||||||
const host: SlotRendererHost = {
|
const host: SlotRendererHost = {
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
getVersion: () => 0,
|
getVersion: () => 0,
|
||||||
entriesOf: (key) => key === 'root' ? [rootEntry] : sessionEntries,
|
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
|
||||||
specOf: (key) => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
||||||
isLive: () => true,
|
isLive: () => true,
|
||||||
storeOf: () => undefined,
|
storeOf: () => undefined,
|
||||||
sessions: {
|
sessions: {
|
||||||
list: observable<unknown>({ ids: [] }),
|
list: observable<unknown>({ ids: [] }),
|
||||||
current,
|
current,
|
||||||
provideInfo: (id) => infos.get(id),
|
provideInfo: id => infos.get(id),
|
||||||
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
|
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||||
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
|
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
|
||||||
},
|
},
|
||||||
workspaces: { list: observable<unknown>({ items: [] }) },
|
workspaces: { list: observable<unknown>({ items: [] }) },
|
||||||
@@ -78,7 +78,7 @@ describe('SessionProvider', () => {
|
|||||||
const h = makeHost({
|
const h = makeHost({
|
||||||
root: () => (
|
root: () => (
|
||||||
<SessionProvider empty={() => <span>empty</span>}>
|
<SessionProvider empty={() => <span>empty</span>}>
|
||||||
{(id) => <div data-testid="body">{id}</div>}
|
{id => <div data-testid="body">{id}</div>}
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -93,7 +93,7 @@ describe('SessionProvider', () => {
|
|||||||
|
|
||||||
it('renders null empty state when the empty prop is omitted', () => {
|
it('renders null empty state when the empty prop is omitted', () => {
|
||||||
const h = makeHost({
|
const h = makeHost({
|
||||||
root: () => <SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
|
root: () => <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
|
||||||
})
|
})
|
||||||
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||||
expect(view.container.textContent).toBe('')
|
expect(view.container.textContent).toBe('')
|
||||||
@@ -110,7 +110,7 @@ describe('SessionProvider', () => {
|
|||||||
return <div>{id}</div>
|
return <div>{id}</div>
|
||||||
}
|
}
|
||||||
const h = makeHost({
|
const h = makeHost({
|
||||||
root: () => <SessionProvider>{(id) => <Body id={id} />}</SessionProvider>,
|
root: () => <SessionProvider>{id => <Body id={id} />}</SessionProvider>,
|
||||||
})
|
})
|
||||||
h.addSession('s1')
|
h.addSession('s1')
|
||||||
h.addSession('s2')
|
h.addSession('s2')
|
||||||
@@ -127,7 +127,7 @@ describe('SessionProvider', () => {
|
|||||||
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
|
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
|
||||||
const seen: Record<string, unknown>[] = []
|
const seen: Record<string, unknown>[] = []
|
||||||
const h = makeHost({
|
const h = makeHost({
|
||||||
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
|
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
|
||||||
})
|
})
|
||||||
h.addSession('s1')
|
h.addSession('s1')
|
||||||
h.addSession('s2')
|
h.addSession('s2')
|
||||||
@@ -135,7 +135,7 @@ describe('SessionProvider', () => {
|
|||||||
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
|
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
|
||||||
// The bound hook reads the cell's bare source — asserting through it
|
// The bound hook reads the cell's bare source — asserting through it
|
||||||
// proves the machinery wired THIS session's source, not another's.
|
// proves the machinery wired THIS session's source, not another's.
|
||||||
seen.push({ sessionId: props.sessionId, read: props.useSession!((s) => s.sid) })
|
seen.push({ sessionId: props.sessionId, read: props.useSession!(s => s.sid) })
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
@@ -152,7 +152,7 @@ describe('SessionProvider', () => {
|
|||||||
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
|
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
|
||||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
expect(() => render(
|
expect(() => render(
|
||||||
<SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
|
<SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
|
||||||
)).toThrow(/outside the installed renderer tree/)
|
)).toThrow(/outside the installed renderer tree/)
|
||||||
spy.mockRestore()
|
spy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ function makeHost() {
|
|||||||
subs.set(key, set)
|
subs.set(key, set)
|
||||||
return () => { set.delete(fn) }
|
return () => { set.delete(fn) }
|
||||||
},
|
},
|
||||||
getVersion: (key) => versions.get(key) ?? 0,
|
getVersion: key => versions.get(key) ?? 0,
|
||||||
entriesOf: (key) => entries.get(key) ?? [],
|
entriesOf: key => entries.get(key) ?? [],
|
||||||
specOf: () => ({ kind: 'single', scope: 'root' }),
|
specOf: () => ({ kind: 'single', scope: 'root' }),
|
||||||
isLive: (entry) => live.has(entry),
|
isLive: entry => live.has(entry),
|
||||||
storeOf: () => undefined,
|
storeOf: () => undefined,
|
||||||
sessions: {
|
sessions: {
|
||||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||||
@@ -54,7 +54,7 @@ function makeHost() {
|
|||||||
live.add(entry)
|
live.add(entry)
|
||||||
bump(key)
|
bump(key)
|
||||||
return () => {
|
return () => {
|
||||||
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
|
entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
|
||||||
live.delete(entry)
|
live.delete(entry)
|
||||||
bump(key)
|
bump(key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
import { act, render } from '@testing-library/react'
|
import { act, render } from '@testing-library/react'
|
||||||
import { useInvoke } from '@deepseek-ai/dsh-client-web-react'
|
import { useInvoke } from '@deepseek-ai/dsh-client-web-react'
|
||||||
|
|
||||||
function deferred<T>() {
|
function deferred() {
|
||||||
let resolve!: (v: T) => void
|
let resolve!: () => void
|
||||||
let reject!: (e: unknown) => void
|
let reject!: (e: unknown) => void
|
||||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
|
||||||
return { promise, resolve, reject }
|
return { promise, resolve, reject }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ const newProbe = (): Probe => ({ invoke: () => {}, pending: false, renders: 0 })
|
|||||||
|
|
||||||
describe('useInvoke', () => {
|
describe('useInvoke', () => {
|
||||||
it('tracks pending across the action lifecycle', async () => {
|
it('tracks pending across the action lifecycle', async () => {
|
||||||
const d = deferred<void>()
|
const d = deferred()
|
||||||
const probe = newProbe()
|
const probe = newProbe()
|
||||||
render(<Harness fn={() => d.promise} probe={probe} />)
|
render(<Harness fn={() => d.promise} probe={probe} />)
|
||||||
expect(probe.pending).toBe(false)
|
expect(probe.pending).toBe(false)
|
||||||
@@ -39,8 +39,8 @@ describe('useInvoke', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps pending true until the last concurrent call settles', async () => {
|
it('keeps pending true until the last concurrent call settles', async () => {
|
||||||
const d1 = deferred<void>()
|
const d1 = deferred()
|
||||||
const d2 = deferred<void>()
|
const d2 = deferred()
|
||||||
const queue = [d1, d2]
|
const queue = [d1, d2]
|
||||||
const probe = newProbe()
|
const probe = newProbe()
|
||||||
render(<Harness fn={() => queue.shift()!.promise} probe={probe} />)
|
render(<Harness fn={() => queue.shift()!.promise} probe={probe} />)
|
||||||
@@ -68,7 +68,7 @@ describe('useInvoke', () => {
|
|||||||
|
|
||||||
it('resets pending and logs when the action rejects', async () => {
|
it('resets pending and logs when the action rejects', async () => {
|
||||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const d = deferred<void>()
|
const d = deferred()
|
||||||
const probe = newProbe()
|
const probe = newProbe()
|
||||||
render(<Harness fn={() => d.promise} probe={probe} />)
|
render(<Harness fn={() => d.promise} probe={probe} />)
|
||||||
act(() => { probe.invoke() })
|
act(() => { probe.invoke() })
|
||||||
|
|||||||
@@ -42,18 +42,18 @@ export function AppRoot(props: AppRootProps) {
|
|||||||
<div className={css.wordmark}>HARNESS</div>
|
<div className={css.wordmark}>HARNESS</div>
|
||||||
{!loud
|
{!loud
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<div className={css.spinner} />
|
<div className={css.spinner} />
|
||||||
<div className={css.hint}>Loading plugins…</div>
|
<div className={css.hint}>Loading plugins…</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div className={css.failed}>
|
<div className={css.failed}>
|
||||||
<div className={css.failedTitle}>Failed to load plugins</div>
|
<div className={css.failedTitle}>Failed to load plugins</div>
|
||||||
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
||||||
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
*/
|
*/
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
|
||||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import { DocumentTitle } from './DocumentTitle.tsx'
|
import { DocumentTitle } from './DocumentTitle.tsx'
|
||||||
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
|
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
|
||||||
@@ -26,7 +25,7 @@ export interface AssemblyDeps {
|
|||||||
*/
|
*/
|
||||||
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
|
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
|
||||||
const { ctx } = deps
|
const { ctx } = deps
|
||||||
const sessions = ctx.get('sessions') as SessionsService | undefined
|
const sessions = ctx.get('sessions')
|
||||||
if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable')
|
if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable')
|
||||||
const useSessions = bindSnapshotSelector(sessions.list)
|
const useSessions = bindSnapshotSelector(sessions.list)
|
||||||
const SessionDocumentTitle = (): ReactNode => {
|
const SessionDocumentTitle = (): ReactNode => {
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ export class AppWebEntry {
|
|||||||
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
|
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
|
||||||
private async prefetchImmediateTier(): Promise<void> {
|
private async prefetchImmediateTier(): Promise<void> {
|
||||||
await Promise.all(this.manifest.plugins
|
await Promise.all(this.manifest.plugins
|
||||||
.filter((row) => row.immediately)
|
.filter(row => row.immediately)
|
||||||
.map((row) => this.modules.prefetch(row.id).catch(() => {
|
.map(row => this.modules.prefetch(row.id).catch(() => {
|
||||||
// Import refetches and reports this loudly per entry; swallowing
|
// Import refetches and reports this loudly per entry; swallowing
|
||||||
// here keeps one failing prefetch from masking the others.
|
// here keeps one failing prefetch from masking the others.
|
||||||
})))
|
})))
|
||||||
@@ -186,7 +186,7 @@ export class AppWebEntry {
|
|||||||
// its wrapper apply reads the kernel slot and provides ctx.modules (the
|
// its wrapper apply reads the kernel slot and provides ctx.modules (the
|
||||||
// provide lives on the plugin face; see MODULES_ID for why the row loop
|
// provide lives on the plugin face; see MODULES_ID for why the row loop
|
||||||
// must then skip it).
|
// must then skip it).
|
||||||
const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID]
|
const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID), APP_SHELL_ID]
|
||||||
// Entry creation order carries no semantics (fiber inject waiting owns
|
// Entry creation order carries no semantics (fiber inject waiting owns
|
||||||
// activation order); creating concurrently lets non-prefetched bundle
|
// activation order); creating concurrently lets non-prefetched bundle
|
||||||
// fetches parallelize. The app-shell assembly entry is appended by the
|
// fetches parallelize. The app-shell assembly entry is appended by the
|
||||||
@@ -225,7 +225,7 @@ export class AppWebEntry {
|
|||||||
const state = STATE_LABELS[entry.fiber.state]
|
const state = STATE_LABELS[entry.fiber.state]
|
||||||
if (state === 'active') continue
|
if (state === 'active') continue
|
||||||
if (state === 'pending') {
|
if (state === 'pending') {
|
||||||
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
|
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||||
} else {
|
} else {
|
||||||
failures.push(`${name}: ${state}`)
|
failures.push(`${name}: ${state}`)
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ export type LoaderStatus = Record<string, LoaderEntryState>
|
|||||||
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
|
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
|
||||||
export interface KernelSignal<T> {
|
export interface KernelSignal<T> {
|
||||||
/** Current value (stable reference between changes). */
|
/** Current value (stable reference between changes). */
|
||||||
getSnapshot(): T
|
getSnapshot: () => T
|
||||||
/**
|
/**
|
||||||
* Subscribe to changes.
|
* Subscribe to changes.
|
||||||
* @param fn - change listener.
|
* @param fn - change listener.
|
||||||
* @returns the unsubscribe disposer.
|
* @returns the unsubscribe disposer.
|
||||||
*/
|
*/
|
||||||
subscribe(fn: () => void): () => void
|
subscribe: (fn: () => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writable one-value signal (settled flag, boot failure report). */
|
/** Writable one-value signal (settled flag, boot failure report). */
|
||||||
@@ -61,7 +61,7 @@ export interface KernelValueSignal<T> extends KernelSignal<T> {
|
|||||||
* Publish a new value and notify subscribers.
|
* Publish a new value and notify subscribers.
|
||||||
* @param next - the new value.
|
* @param next - the new value.
|
||||||
*/
|
*/
|
||||||
set(next: T): void
|
set: (next: T) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,7 +90,7 @@ export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
|
|||||||
* @param id - entry name.
|
* @param id - entry name.
|
||||||
* @param state - projected fiber state.
|
* @param state - projected fiber state.
|
||||||
*/
|
*/
|
||||||
set(id: string, state: LoaderEntryState): void
|
set: (id: string, state: LoaderEntryState) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user