style: fix lint across client packages

eslint --fix autofixes plus manual repairs: max-len line splits
(fake-api handlers, notifier/slots JSDoc, spec signatures), charAt over
non-null-asserted indexing in slash detect/menu cores, Array.from for
code-point capping, typeof assertions for unbound-method in specs,
generic getByRole for the send-button cast, effect disposer void-wrap in
command register, and dropped unused type imports.
This commit is contained in:
imccyu
2026-07-27 04:08:02 +08:00
parent a27be43ac1
commit f6396f2573
35 changed files with 122 additions and 89 deletions

View File

@@ -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

View File

@@ -117,14 +117,14 @@ function workspaceChip(): HTMLElement {
async function findLockedComposer(): Promise<HTMLTextAreaElement> {
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<HTMLTextAreaElement> {
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<HTMLButtonElement>('button', { name: 'Send message' }).disabled,
sidebar: visibleText(tree),
}).toMatchInlineSnapshot(`
{

View File

@@ -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<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> = () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),

View File

@@ -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

View File

@@ -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
}

View File

@@ -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<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> = () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),

View File

@@ -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 }
}

View File

@@ -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)
})

View File

@@ -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<void> {
type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
b.api.onList = () => Promise.resolve(ok({
items: rows.map(r => ({
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,

View File

@@ -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),
}
}

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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'

View File

@@ -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

View File

@@ -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)
}
/**

View File

@@ -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),

View File

@@ -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' })

View File

@@ -94,8 +94,8 @@ async function bench(snapshot: ConversationSnapshot) {
: undefined),
scope: () => ({ get: () => scoped }),
scopeOf: () => SID,
provide: (provider: (binding: unknown) => { hooks?: Record<string, unknown>; props?: Record<string, unknown> }) => {
const contribution = provider(sessionsFake.binding(SID))
provide: (descriptor: { resolve: (binding: unknown) => { hooks?: Record<string, unknown>; props?: Record<string, unknown> } }) => {
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(),
}

View File

@@ -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(),

View File

@@ -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<ReturnType<typeof createChatStore>['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 })
})

View File

@@ -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<SessionId, CatalogFetch>()
@@ -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(`<skill>${ref}</skill>`),
clipboardText: ref => `/${ref}`,
serialize: ref => Promise.resolve(`<skill>${ref}</skill>`),
},
}
const slash = ctx.get('slash') as SlashServiceContract

View File

@@ -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')
})
})

View File

@@ -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) {

View File

@@ -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<typeof sessions.scope>[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 {

View File

@@ -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

View File

@@ -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':

View File

@@ -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,

View File

@@ -241,8 +241,8 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions<HandleOf<H>>] : [sessionId: SessionIdOf])
: ScopeOf<K> extends 'session-maybe'
? ([H] extends [StoreDecl]
? [sessionId: SessionIdOf | undefined, actions: BoundActions<HandleOf<H>> | undefined]
: [sessionId: SessionIdOf | undefined])
? [sessionId: SessionIdOf | undefined, actions: BoundActions<HandleOf<H>> | undefined]
: [sessionId: SessionIdOf | undefined])
: ([H] extends [StoreDecl] ? [actions: BoundActions<HandleOf<H>>] : [])
/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */

View File

@@ -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

View File

@@ -31,7 +31,7 @@ const sid = (id: string) => id as SessionId
function sessionsWith(sessions: SessionSummary[]) {
const byId: Record<string, SessionSummary> = {}
for (const s of sessions) byId[s.id] = s
const snapshot = { ids: sessions.map((s) => s.id), byId, current: undefined } as unknown as SessionListState
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)
})
})

View File

@@ -96,7 +96,9 @@ function fullResponse(narrow: RpcResponse<unknown>): 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<K extends keyof RpcMethodMap>(api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal): Promise<Response> {
async function handleUnary<K extends keyof RpcMethodMap>(
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
): Promise<Response> {
const route = UNARY_ROUTES[method]
const payload = route.schema.safeParse(message.payload)
if (!payload.success) {

View File

@@ -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)

View File

@@ -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)
}
}