diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts index 53c90ccd7a..0fc2152ec4 100644 --- a/apps/web/tests/slash-flow.snapshot.ts +++ b/apps/web/tests/slash-flow.snapshot.ts @@ -122,7 +122,7 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on- // workspace picker is live. const locked = await screen.findByPlaceholderText( 'Choose a workspace to start', {}, { timeout: 10_000 }, - ) as HTMLTextAreaElement + ) expect(locked.disabled).toBe(true) // Pick (create) a Workspace: connectWorkspace materializes the full @@ -139,7 +139,7 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on- const composer = await screen.findByPlaceholderText( 'Describe what you want to build', {}, { timeout: 10_000 }, - ) as HTMLTextAreaElement + ) expect(composer.disabled).toBe(false) // '/' opens the menu with the session's wire command catalog (the session diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 95c64940be..ab57b495c3 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -117,14 +117,14 @@ function workspaceChip(): HTMLElement { async function findLockedComposer(): Promise { return await screen.findByPlaceholderText( 'Choose a workspace to start', {}, { timeout: 10_000 }, - ) as HTMLTextAreaElement + ) } /** The live blank-session hero composer (session materialized). */ async function findHeroComposer(): Promise { return await screen.findByPlaceholderText( 'Describe what you want to build', {}, { timeout: 10_000 }, - ) as HTMLTextAreaElement + ) } /** Edit the machine-owned controlled input and assert the same-tick echo. */ @@ -161,7 +161,7 @@ it('locks the composer in the New Session view state until a Workspace is chosen headline: visibleText(screen.getByText("Let's start building")), chip: visibleText(workspaceChip()), composerDisabled: composer.disabled, - sendDisabled: (screen.getByRole('button', { name: 'Send message' }) as HTMLButtonElement).disabled, + sendDisabled: screen.getByRole('button', { name: 'Send message' }).disabled, sidebar: visibleText(tree), }).toMatchInlineSnapshot(` { diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 3a5b917e0f..bf7295cc50 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -88,9 +88,12 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program catalogs and skill lists without casts. - onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) - onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + onCommandList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), diff --git a/packages/client/runtime/src/client/sessions/notifier.ts b/packages/client/runtime/src/client/sessions/notifier.ts index aa647a0ea0..f6f7a1cd49 100644 --- a/packages/client/runtime/src/client/sessions/notifier.ts +++ b/packages/client/runtime/src/client/sessions/notifier.ts @@ -64,7 +64,10 @@ export class Notifier { for (const listener of this.listeners) listener() } - /** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). Notification stays pending. */ + /** + * Pre-getSnapshot check: rebuild synchronously when dirty (read path + * before first subscribe / while unobserved). Notification stays pending. + */ ensureFresh(): void { if (!this.dirty) return this.dirty = false diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index b617837c9a..75c55bc4bd 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -53,7 +53,7 @@ function queuePreviewOf(content: readonly ContentBlock[]): string { const flat = content .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) .join(' ').replace(/\s+/g, ' ').trim() - const chars = [...flat] + const chars = Array.from(flat) return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index ecf60de2ba..dcb334f6ea 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -110,9 +110,12 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program requires-bearing catalogs and dual-address // skill lists without casts. - onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) - onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + onCommandList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 17ea433c66..ee76d885ab 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -12,7 +12,9 @@ import { entries, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId -function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }> = {}) { +type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }> + +function summary(sessionId: SessionId, over: SummaryOver = {}) { return { sessionId, updatedAt: 100, running: false, blank: false, ...over } } diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index e1289149b4..360f7c1a9d 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -50,7 +50,7 @@ describe('queue intake', () => { const session = makeSession() session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap')) const preview = session.getSnapshot().queue[0]?.preview ?? '' - expect([...preview]).toHaveLength(201) // 200 + … + expect(Array.from(preview)).toHaveLength(201) // 200 + … expect(preview.endsWith('…')).toBe(true) }) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index d1236d0dc0..44ab4ffb4f 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -28,7 +28,9 @@ function bench(): Bench { } /** Refresh the manager list from programmable rows and flush the microtask batch. */ -async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }[]): Promise { +type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean } + +async function feedList(b: Bench, rows: FeedRow[]): Promise { b.api.onList = () => Promise.resolve(ok({ items: rows.map(r => ({ sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false, diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 2a44c75222..97bcb50f0a 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -104,10 +104,10 @@ function fakeSessions() { list: { getSnapshot: () => state, subscribe: () => () => undefined }, provideInfo: (id: string) => (id === 'known' ? { - sessionId: id, - hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }, - props: {}, - } + sessionId: id, + hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }, + props: {}, + } : undefined), } } diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 580b856c06..df59ad2dcd 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -10,8 +10,6 @@ import { Service } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' -// Type-only: the notice route reads ctx.conversation.input — no runtime edge. -import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, SlashServiceContract, SubmitOutcome, @@ -70,7 +68,7 @@ export class CommandService extends Service implements CommandServiceContract { * @returns the disposer removing the registration. */ register(contribution: CommandContribution): () => void { - return this.ctx.effect(() => { + const dispose = this.ctx.effect(() => { const { contributions } = this.live if (contributions.has(contribution.name)) { throw new Error(`ui-command: duplicate contribution for /${contribution.name}`) @@ -78,6 +76,7 @@ export class CommandService extends Service implements CommandServiceContract { contributions.set(contribution.name, contribution) return () => { contributions.delete(contribution.name) } }, 'command.register()') + return () => { void dispose() } } /** @@ -275,7 +274,7 @@ export class CommandService extends Service implements CommandServiceContract { private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void { const actx = this.scopeFor(id) if (actx === undefined) return - const conversation = actx.get('conversation') as ConversationService | undefined + const conversation = actx.get('conversation') if (conversation === undefined) return conversation.input.for(actx).notify(level, text) } diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index a39735c6a3..03c0df2d50 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -62,8 +62,8 @@ describe('apply', () => { expect(command).toBeInstanceOf(CommandService) // Frozen-contract conformance (compile-time check rides the assignment). const contract: CommandServiceContract = command as CommandService - expect(contract.register).toBeTypeOf('function') - expect(contract.popupFor).toBeTypeOf('function') + expect(typeof contract.register).toBe('function') + expect(typeof contract.popupFor).toBe('function') expect([...sources.keys()]).toEqual(['/ command']) expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup']) await fiber.dispose() diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index ddb773d4a9..0cf94e2f82 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -134,9 +134,9 @@ const req = (query: string, position: 'leading' | 'inline' = 'leading') => describe('registration', () => { it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => { const { registered, source, fiber } = await bench() - expect(source.matchSpace).toBeTypeOf('function') - expect(source.matchEnter).toBeTypeOf('function') - expect(source.warm).toBeTypeOf('function') + expect(typeof source.matchSpace).toBe('function') + expect(typeof source.matchEnter).toBe('function') + expect(typeof source.warm).toBe('function') expect([...registered.keys()]).toEqual(['/ command']) await fiber.dispose() expect(registered.size).toBe(0) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 934813136e..c8d5be336d 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,7 +1,7 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ViewTab } from './contract/views.ts' import type { @@ -54,7 +54,7 @@ export function apply(ctx: Context): void { // The per-session input machine registry (InputService face; published as // ctx.conversation.input by the service below sharing this one instance). - const inputHub = new InputHub(ctx as ClientContext) + const inputHub = new InputHub(ctx) // Decision 19/20: the input machine feeds every session-scope slot // component through the standard provide channel — the 'input' hook plus @@ -119,7 +119,7 @@ export function apply(ctx: Context): void { version: () => slots.getVersion('conversation.view'), }, bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), - open: id => { sessions.open(id) }, + open: (id) => { sessions.open(id) }, }), }, ConversationSession) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 6c2621524b..1e620f0905 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -248,7 +248,10 @@ export interface ComposerChainProps { interactions: readonly PendingInteraction[] } -/** Full conversation-slot component props: runtime & child-render (view ring + composer chain/bar + input-region + hero picker slots) & store & injected shares. */ +/** + * Full conversation-slot component props: runtime & child-render (view ring + * + composer chain/bar + input-region + hero picker slots) & store & injected shares. + */ export type ConversationSlotProps = PropsRuntime<'conversation'> & PropsRenderSlots< | 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar' diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index e366d1bd27..8567ea4ad8 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -342,7 +342,7 @@ export class InputMachine { private onSetInvalid(invalidIds: readonly number[]): InputEffect[] { const ids = new Set(invalidIds) if (!this.occurrences.some(o => (o.invalid === true) !== ids.has(o.occurrenceId))) return [] - this.occurrences = this.occurrences.map(o => { + this.occurrences = this.occurrences.map((o) => { const invalid = ids.has(o.occurrenceId) if ((o.invalid === true) === invalid) return o const { invalid: _drop, ...rest } = o diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 5cb2d84ab3..0d2d8e9d8e 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -12,7 +12,7 @@ import type { Context } from 'cordis' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. -import type { ClientContext, Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { InputHub } from './input/hub.ts' /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ @@ -29,7 +29,7 @@ export class ConversationService extends Service { */ constructor(ctx: Context, config?: { input?: InputHub }) { super(ctx, 'conversation') - this.input = config?.input ?? new InputHub(ctx as ClientContext) + this.input = config?.input ?? new InputHub(ctx) } /** diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index a815213a56..da255415ce 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -89,6 +89,7 @@ async function bench() { binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), scope: (id: SessionId) => mint(id), provideInfo: () => undefined, + maybeProvideInfo: () => ({ hooks: {}, props: {} }), provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, scopeOf, sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 8152b959bf..1a6a8a66cb 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -37,6 +37,7 @@ async function bench() { binding: vi.fn(), scope: () => undefined, provideInfo: () => undefined, + maybeProvideInfo: () => ({ hooks: {}, props: {} }), provide: vi.fn(() => () => {}), create: vi.fn(), open: vi.fn(), @@ -94,15 +95,17 @@ describe('apply wiring', () => { const b = await bench() await b.fiber.await() const conversation = renderEntryOf(b.slots, 'conversation') + const conversationSession = renderEntryOf(b.slots, 'conversation.session') const chatView = renderEntryOf(b.slots, 'conversation.view') const details = renderEntryOf(b.slots, 'details') expect(conversation?.inject).toBeTypeOf('function') expect(chatView?.inject).toBeTypeOf('function') expect(details?.inject).toBeTypeOf('function') - // The shared handle: one apply-built store value on ALL session entries. - expect(conversation?.store).toBeDefined() - expect(details?.store).toBe(conversation?.store) - expect(chatView?.store).toBe(conversation?.store) + // The shared handle: one apply-built store value on ALL session entries + // (the session-maybe 'conversation' shell carries no store by design). + expect(conversationSession?.store).toBeDefined() + expect(details?.store).toBe(conversationSession?.store) + expect(chatView?.store).toBe(conversationSession?.store) // The hero workspace picker hole rides the conversation entry's children // declaration (the empty-state occupant is gone). expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index a44530df4f..125e421772 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -94,8 +94,8 @@ async function bench(snapshot: ConversationSnapshot) { : undefined), scope: () => ({ get: () => scoped }), scopeOf: () => SID, - provide: (provider: (binding: unknown) => { hooks?: Record; props?: Record }) => { - const contribution = provider(sessionsFake.binding(SID)) + provide: (descriptor: { resolve: (binding: unknown) => { hooks?: Record; props?: Record } }) => { + const contribution = descriptor.resolve(sessionsFake.binding(SID)) Object.assign(provided.hooks, contribution.hooks ?? {}) Object.assign(provided.props, contribution.props ?? {}) return () => {} @@ -103,6 +103,9 @@ async function bench(snapshot: ConversationSnapshot) { provideInfo: (id: string) => (id === SID ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } : undefined), + maybeProvideInfo: (id: string | undefined) => (id === SID + ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } + : { hooks: provided.hooks, props: provided.props }), create: vi.fn(), open: vi.fn(), } diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 3f23e38253..b1122a4098 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -107,7 +107,10 @@ async function bench(nodes: ToolResultNode[]) { } return info }, - provide: (fn: (typeof providers)[number]) => { providers.push(fn); return () => {} }, + maybeProvideInfo(id: string | undefined) { + return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} } + }, + provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, scopeOf: () => SID, create: vi.fn(), open: vi.fn(), @@ -227,6 +230,7 @@ describe('registrant load-order seam', () => { binding: () => undefined, scope: () => undefined, provideInfo: () => undefined, + maybeProvideInfo: () => ({ hooks: {}, props: {} }), provide: () => () => {}, create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index e7d5a9533a..ec50a3f317 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -23,6 +23,7 @@ function bench(): Bench { ids: [], byId: {}, current: undefined, phase: 'ready', }), provideInfo: () => undefined, + maybeProvideInfo: () => ({ hooks: {}, props: {} }), provide: () => () => {}, }) ctx.provide('workspaces', { @@ -42,16 +43,19 @@ function bench(): Bench { name: 'root', children: { 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'conversation.session': { kind: 'single', scope: 'session' }, 'details': { kind: 'single', scope: 'session' }, }, }, (_p: { renderSlot?: unknown }) => null) - slots.register({ name: 'conversation', store: chat }, () => null) + // apply.ts mounts the shared chat handle only under session-scope slots + // (the session-maybe 'conversation' shell carries no store). + slots.register({ name: 'conversation.session', store: chat }, () => null) slots.register({ name: 'details', store: chat }, () => null) return { slots, chat } } /** Resolve the store instance the renderer would hand a slot's component for a session. */ -function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: SessionId) { +function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) { const host = renderHost(b) const entry = host.entriesOf(slot)[0]! return host.storeOf(entry, sessionId)! as ReturnType['create']> @@ -80,7 +84,7 @@ describe('selection survives on the store seat', () => { it('one session, two slots: conversation writes, details reads the SAME instance', () => { const b = bench() - const conv = storeFor(b, 'conversation', sid('s1')) + const conv = storeFor(b, 'conversation.session', sid('s1')) const details = storeFor(b, 'details', sid('s1')) conv.actions.select({ turnSeq: 3, callId: 'c1' }) expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) @@ -91,8 +95,8 @@ describe('selection survives on the store seat', () => { it('sessions are isolated: s2 selection never bleeds into s1', () => { const b = bench() - const one = storeFor(b, 'conversation', sid('s1')) - const two = storeFor(b, 'conversation', sid('s2')) + const one = storeFor(b, 'conversation.session', sid('s1')) + const two = storeFor(b, 'conversation.session', sid('s2')) expect(two).not.toBe(one) one.actions.select({ turnSeq: 1, callId: 'a' }) two.actions.select({ turnSeq: 9, callId: 'z' }) @@ -105,14 +109,14 @@ describe('selection survives on the store seat', () => { const id = sid('s1') const projection = createSnapshotStore({ displayTitle: 's1' }) - const store = storeFor(b, 'conversation', id) + const store = storeFor(b, 'conversation.session', id) store.actions.select({ turnSeq: 3, callId: 'c1' }) store.actions.setDraft('half-typed') projection.set({ displayTitle: 'proj-a' }) expect(projection.getSnapshot().displayTitle).toBe('proj-a') - const after = storeFor(b, 'conversation', id) + const after = storeFor(b, 'conversation.session', id) expect(after).toBe(store) expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) expect(after.store.getSnapshot().draft).toBe('half-typed') @@ -121,7 +125,7 @@ describe('selection survives on the store seat', () => { it('session death buries the instance and its persisted draft', () => { const b = bench() - const doomed = storeFor(b, 'conversation', sid('s1')) + const doomed = storeFor(b, 'conversation.session', sid('s1')) doomed.actions.setDraft('to be buried') doomed.actions.select({ turnSeq: 1 }) expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull() @@ -132,7 +136,7 @@ describe('selection survives on the store seat', () => { // Persisted residue is gone with the session... expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull() // ...and a re-created same-id session starts from a FRESH instance. - const reborn = storeFor(b, 'conversation', sid('s1')) + const reborn = storeFor(b, 'conversation.session', sid('s1')) expect(reborn).not.toBe(doomed) expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) }) diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index eb8e888b73..677843c844 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -40,7 +40,7 @@ export const inject = ['slash', 'connection'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const { list } = (ctx.get('connection') as ConnectionHandle).api.skills + const skills = (ctx.get('connection') as ConnectionHandle).api.skills // Session-keyed catalog cache; single-flight per key. Plugin-closure state: // the fiber effect below is its teardown boundary. const fetches = new Map() @@ -50,7 +50,7 @@ export function apply(ctx: ClientContext): void { if (existing !== undefined) return existing.promise const abort = new AbortController() const promise = (async () => { - const { result } = await list({ sessionId }, abort.signal) + const { result } = await skills.list({ sessionId }, abort.signal) if (!result.ok) throw new Error(`skill.list failed: ${result.error.code}: ${result.error.message}`) return result.value.skills })() @@ -86,8 +86,8 @@ export function apply(ctx: ClientContext): void { // Superseded keystroke: the shared fetch stays warm, this caller yields. if (signal.aborted) return [] return skills - .filter((skill) => skill.name.startsWith(query)) - .map((skill) => ({ name: skill.name, description: skill.description })) + .filter(skill => skill.name.startsWith(query)) + .map(skill => ({ name: skill.name, description: skill.description })) }, warm(session) { // Fire-and-forget scope-birth prewarm; the shared fetch reports @@ -95,7 +95,7 @@ export function apply(ctx: ClientContext): void { fetchCatalog(session.sessionId).catch(() => {}) }, lexicon(session) { - return fetches.get(session.sessionId)?.settled?.map((skill) => skill.name) + return fetches.get(session.sessionId)?.settled?.map(skill => skill.name) }, onPick({ candidate }) { // Decision 21: plain-text reference — the literal lands in the draft @@ -105,8 +105,8 @@ export function apply(ctx: ClientContext): void { return { text: `/${candidate.name} ` } }, codec: { - clipboardText: (ref) => `/${ref}`, - serialize: (ref) => Promise.resolve(`${ref}`), + clipboardText: ref => `/${ref}`, + serialize: ref => Promise.resolve(`${ref}`), }, } const slash = ctx.get('slash') as SlashServiceContract diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 9e0cc8700f..11f53e142c 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -234,7 +234,7 @@ describe('pick and codec', () => { describe('adjudication', () => { it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => { const { source } = await bench(listOk(CATALOG)) - expect(source.matchSpace).toBeUndefined() - expect(source.matchEnter).toBeUndefined() + expect(typeof source.matchSpace).toBe('undefined') + expect(typeof source.matchEnter).toBe('undefined') }) }) diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index 4d7817adf7..d3d3567e4d 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -211,7 +211,10 @@ export class SlashController { return undefined } - /** Drop the menu group of a disposed source (root registry change notification). */ + /** + * Drop the menu group of a disposed source (root registry change notification). + * @param source - the source whose registration was disposed. + */ sourceRemoved(source: SlashSource): void { const state = this.menu.getSnapshot() if (state.open && state.hit !== null && state.hit.trigger === source.trigger) { diff --git a/packages/client/ui-slash/src/client/index.ts b/packages/client/ui-slash/src/client/index.ts index 509f9e9ebe..4f192d066e 100644 --- a/packages/client/ui-slash/src/client/index.ts +++ b/packages/client/ui-slash/src/client/index.ts @@ -52,7 +52,7 @@ export function apply(ctx: ClientContext): void { inject: (sessionId): MenuViewInjected => { // Session-scoped slot: resolve this session's controller (the slot // frame hands ids, not ctx — the registered id→ctx interchange). - const actx = sessions.scope(sessionId as Parameters[0]) + const actx = sessions.scope(sessionId) if (actx === undefined) throw new Error(`ui-slash: session "${String(sessionId)}" resolved no scope`) const controller = slash.sessionOf(actx) return { diff --git a/packages/client/ui-slash/src/core/detect.ts b/packages/client/ui-slash/src/core/detect.ts index c8e0fc1098..5f2e43680c 100644 --- a/packages/client/ui-slash/src/core/detect.ts +++ b/packages/client/ui-slash/src/core/detect.ts @@ -18,12 +18,12 @@ const WHITESPACE = /\s/u */ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean { if (index === 0) return true - const prev = draft[index - 1]! + const prev = draft.charAt(index - 1) if (WHITESPACE.test(prev)) return true if (WORD_CHAR.test(prev)) return false if (char === '/') { if (prev === '/') return false - if (prev === ':' && index >= 2 && !WHITESPACE.test(draft[index - 2]!)) return false + if (prev === ':' && index >= 2 && !WHITESPACE.test(draft.charAt(index - 2))) return false } return true } @@ -47,7 +47,7 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean { export const detectTrigger: DetectTrigger = (draft, caret, guard) => { if (guard.tier === 'frozen') return null for (let i = caret - 1; i >= 0; i--) { - const ch = draft[i]! + const ch = draft.charAt(i) if (WHITESPACE.test(ch)) return null if (ch !== '/' && ch !== '@') continue if (guard.tier === 'claimed' && ch === '/') continue diff --git a/packages/client/ui-slash/src/core/menu.ts b/packages/client/ui-slash/src/core/menu.ts index 871022ac13..fe1c7f10eb 100644 --- a/packages/client/ui-slash/src/core/menu.ts +++ b/packages/client/ui-slash/src/core/menu.ts @@ -110,15 +110,13 @@ export const menuReduce: MenuReduce = (state, ev) => { if (!state.open) return state const pos = positions(state.groups) if (pos.length === 0) return state - const at = state.highlight - ? pos.findIndex(p => p.source === state.highlight!.source && p.index === state.highlight!.index) - : -1 - const next = at < 0 - ? (ev.dir === 1 ? pos[0]! : pos[pos.length - 1]!) - : pos[(at + ev.dir + pos.length) % pos.length]! - if (state.highlight && next.source === state.highlight.source && next.index === state.highlight.index) { - return state - } + const hl = state.highlight + const at = hl ? pos.findIndex(p => p.source === hl.source && p.index === hl.index) : -1 + const next = pos[at < 0 + ? (ev.dir === 1 ? 0 : pos.length - 1) + : (at + ev.dir + pos.length) % pos.length] + if (next === undefined) return state + if (hl && next.source === hl.source && next.index === hl.index) return state return { ...state, highlight: next } } case 'close': diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index c1dbe19529..379d7bbe8a 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -486,7 +486,7 @@ describe('pick / scoped input events', () => { }) describe('lexicon', () => { - function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] | undefined, hasHook = true): SlashSource { + function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): SlashSource { return { trigger, name, diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 729cebc843..1f30f7027b 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -241,8 +241,8 @@ export type InjectParams = ? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions>] : [sessionId: SessionIdOf]) : ScopeOf extends 'session-maybe' ? ([H] extends [StoreDecl] - ? [sessionId: SessionIdOf | undefined, actions: BoundActions> | undefined] - : [sessionId: SessionIdOf | undefined]) + ? [sessionId: SessionIdOf | undefined, actions: BoundActions> | undefined] + : [sessionId: SessionIdOf | undefined]) : ([H] extends [StoreDecl] ? [actions: BoundActions>] : []) /** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */ diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 10db03b811..3ad1543c68 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -26,14 +26,14 @@ export function apply(ctx: ClientContext): void { const childLabels = (session: ClientSessionContext, query: string): string[] => { const { byId } = sessions.list.getSnapshot() return Object.values(byId) - .filter((child) => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query)) - .map((child) => child.displayTitle) + .filter(child => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query)) + .map(child => child.displayTitle) } const source: SlashSource = { trigger: '@', name: 'subagent', candidates(session, { query }) { - return Promise.resolve(childLabels(session, query).map((name) => ({ name }))) + return Promise.resolve(childLabels(session, query).map(name => ({ name }))) }, lexicon(session) { // The list snapshot is always warm — the full running-children roster. @@ -47,10 +47,10 @@ export function apply(ctx: ClientContext): void { return { text: `@${candidate.name} ` } }, codec: { - clipboardText: (ref) => `@${ref}`, + clipboardText: ref => `@${ref}`, // TODO: serialize returns the raw label until the '@' consumption // feature defines a model representation (design ledger). - serialize: (ref) => Promise.resolve(`@${ref}`), + serialize: ref => Promise.resolve(`@${ref}`), }, } const slash = ctx.get('slash') as SlashServiceContract diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index fcc6dc0b15..fc74470406 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -31,7 +31,7 @@ const sid = (id: string) => id as SessionId function sessionsWith(sessions: SessionSummary[]) { const byId: Record = {} for (const s of sessions) byId[s.id] = s - const snapshot = { ids: sessions.map((s) => s.id), byId, current: undefined } as unknown as SessionListState + const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState return { list: { getSnapshot: () => snapshot } } } @@ -139,7 +139,7 @@ describe('pick and codec', () => { describe('adjudication', () => { it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => { const source = await bench(FAMILY) - expect(source.matchSpace).toBeUndefined() - expect(source.matchEnter).toBeUndefined() + expect('matchSpace' in source && source.matchSpace !== undefined).toBe(false) + expect('matchEnter' in source && source.matchEnter !== undefined).toBe(false) }) }) diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index eda5dd83d8..b79980d63e 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -96,7 +96,9 @@ function fullResponse(narrow: RpcResponse): Response { // K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own // schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection. // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -async function handleUnary(api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal): Promise { +async function handleUnary( + api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal, +): Promise { const route = UNARY_ROUTES[method] const payload = route.schema.safeParse(message.payload) if (!payload.success) { diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index dab31d40c4..c495385375 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -67,7 +67,7 @@ describe('sessions.list cold merge', () => { expect(a?.running).toBe(false) // Cold summaries are never blank: lazy persistence keeps never-appended // sessions out of list(), so a listed session necessarily has events. - expect(items.every(item => item.blank === false)).toBe(true) + expect(items.every(item => !item.blank)).toBe(true) expect(a?.cwd).toBe('/proj') expect(a?.parentSessionId).toBeUndefined() expect(b?.updatedAt).toBe(2000) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 729a61bee1..64aacbde67 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -703,7 +703,7 @@ class EventRelationCollector { const eventNames = this.eventNamesFromCall(node, receiverKind) if (method === 'on' || method === 'once') { for (const event of eventNames) this.ensure(event).listeners.add(source.pkg) - } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') { + } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall' || method === 'bail') { for (const event of eventNames) this.addDispatcher(event, source.pkg, method) } }