mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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(`
|
||||
{
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user