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.
|
// workspace picker is live.
|
||||||
const locked = await screen.findByPlaceholderText(
|
const locked = await screen.findByPlaceholderText(
|
||||||
'Choose a workspace to start', {}, { timeout: 10_000 },
|
'Choose a workspace to start', {}, { timeout: 10_000 },
|
||||||
) as HTMLTextAreaElement
|
)
|
||||||
expect(locked.disabled).toBe(true)
|
expect(locked.disabled).toBe(true)
|
||||||
|
|
||||||
// Pick (create) a Workspace: connectWorkspace materializes the full
|
// 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(
|
const composer = await screen.findByPlaceholderText(
|
||||||
'Describe what you want to build', {}, { timeout: 10_000 },
|
'Describe what you want to build', {}, { timeout: 10_000 },
|
||||||
) as HTMLTextAreaElement
|
)
|
||||||
expect(composer.disabled).toBe(false)
|
expect(composer.disabled).toBe(false)
|
||||||
|
|
||||||
// '/' opens the menu with the session's wire command catalog (the session
|
// '/' opens the menu with the session's wire command catalog (the session
|
||||||
|
|||||||
@@ -117,14 +117,14 @@ function workspaceChip(): HTMLElement {
|
|||||||
async function findLockedComposer(): Promise<HTMLTextAreaElement> {
|
async function findLockedComposer(): Promise<HTMLTextAreaElement> {
|
||||||
return await screen.findByPlaceholderText(
|
return await screen.findByPlaceholderText(
|
||||||
'Choose a workspace to start', {}, { timeout: 10_000 },
|
'Choose a workspace to start', {}, { timeout: 10_000 },
|
||||||
) as HTMLTextAreaElement
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The live blank-session hero composer (session materialized). */
|
/** The live blank-session hero composer (session materialized). */
|
||||||
async function findHeroComposer(): Promise<HTMLTextAreaElement> {
|
async function findHeroComposer(): Promise<HTMLTextAreaElement> {
|
||||||
return await screen.findByPlaceholderText(
|
return await screen.findByPlaceholderText(
|
||||||
'Describe what you want to build', {}, { timeout: 10_000 },
|
'Describe what you want to build', {}, { timeout: 10_000 },
|
||||||
) as HTMLTextAreaElement
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Edit the machine-owned controlled input and assert the same-tick echo. */
|
/** 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")),
|
headline: visibleText(screen.getByText("Let's start building")),
|
||||||
chip: visibleText(workspaceChip()),
|
chip: visibleText(workspaceChip()),
|
||||||
composerDisabled: composer.disabled,
|
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),
|
sidebar: visibleText(tree),
|
||||||
}).toMatchInlineSnapshot(`
|
}).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -88,9 +88,12 @@ export class FakeApiClient implements IApiClient {
|
|||||||
|
|
||||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||||
// wire shapes so cases can program catalogs and skill lists without casts.
|
// wire shapes so cases can program catalogs and skill lists without casts.
|
||||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
|
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> = () => Promise.resolve(ok({ matched: false }))
|
= () => Promise.resolve(ok({ commands: [] }))
|
||||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
|
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'] = {
|
readonly commands: IApiClient['commands'] = {
|
||||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
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()
|
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 {
|
ensureFresh(): void {
|
||||||
if (!this.dirty) return
|
if (!this.dirty) return
|
||||||
this.dirty = false
|
this.dirty = false
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
|
|||||||
const flat = content
|
const flat = content
|
||||||
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
|
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
|
||||||
.join(' ').replace(/\s+/g, ' ').trim()
|
.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
|
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
|
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||||
// wire shapes so cases can program requires-bearing catalogs and dual-address
|
// wire shapes so cases can program requires-bearing catalogs and dual-address
|
||||||
// skill lists without casts.
|
// skill lists without casts.
|
||||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
|
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> = () => Promise.resolve(ok({ matched: false }))
|
= () => Promise.resolve(ok({ commands: [] }))
|
||||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
|
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'] = {
|
readonly commands: IApiClient['commands'] = {
|
||||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
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 S1 = 'fk-m1' as SessionId
|
||||||
const S2 = 'fk-m2' 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 }
|
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe('queue intake', () => {
|
|||||||
const session = makeSession()
|
const session = makeSession()
|
||||||
session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap'))
|
session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap'))
|
||||||
const preview = session.getSnapshot().queue[0]?.preview ?? ''
|
const preview = session.getSnapshot().queue[0]?.preview ?? ''
|
||||||
expect([...preview]).toHaveLength(201) // 200 + …
|
expect(Array.from(preview)).toHaveLength(201) // 200 + …
|
||||||
expect(preview.endsWith('…')).toBe(true)
|
expect(preview.endsWith('…')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ function bench(): Bench {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Refresh the manager list from programmable rows and flush the microtask batch. */
|
/** 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({
|
b.api.onList = () => Promise.resolve(ok({
|
||||||
items: rows.map(r => ({
|
items: rows.map(r => ({
|
||||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
|
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 },
|
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||||
provideInfo: (id: string) => (id === 'known'
|
provideInfo: (id: string) => (id === 'known'
|
||||||
? {
|
? {
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
|
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
|
||||||
props: {},
|
props: {},
|
||||||
}
|
}
|
||||||
: undefined),
|
: undefined),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { Service } from 'cordis'
|
|||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/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 {
|
import type {
|
||||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||||
SlashServiceContract, SubmitOutcome,
|
SlashServiceContract, SubmitOutcome,
|
||||||
@@ -70,7 +68,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
|||||||
* @returns the disposer removing the registration.
|
* @returns the disposer removing the registration.
|
||||||
*/
|
*/
|
||||||
register(contribution: CommandContribution): () => void {
|
register(contribution: CommandContribution): () => void {
|
||||||
return this.ctx.effect(() => {
|
const dispose = this.ctx.effect(() => {
|
||||||
const { contributions } = this.live
|
const { contributions } = this.live
|
||||||
if (contributions.has(contribution.name)) {
|
if (contributions.has(contribution.name)) {
|
||||||
throw new Error(`ui-command: duplicate contribution for /${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)
|
contributions.set(contribution.name, contribution)
|
||||||
return () => { contributions.delete(contribution.name) }
|
return () => { contributions.delete(contribution.name) }
|
||||||
}, 'command.register()')
|
}, '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 {
|
private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void {
|
||||||
const actx = this.scopeFor(id)
|
const actx = this.scopeFor(id)
|
||||||
if (actx === undefined) return
|
if (actx === undefined) return
|
||||||
const conversation = actx.get('conversation') as ConversationService | undefined
|
const conversation = actx.get('conversation')
|
||||||
if (conversation === undefined) return
|
if (conversation === undefined) return
|
||||||
conversation.input.for(actx).notify(level, text)
|
conversation.input.for(actx).notify(level, text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ describe('apply', () => {
|
|||||||
expect(command).toBeInstanceOf(CommandService)
|
expect(command).toBeInstanceOf(CommandService)
|
||||||
// Frozen-contract conformance (compile-time check rides the assignment).
|
// Frozen-contract conformance (compile-time check rides the assignment).
|
||||||
const contract: CommandServiceContract = command as CommandService
|
const contract: CommandServiceContract = command as CommandService
|
||||||
expect(contract.register).toBeTypeOf('function')
|
expect(typeof contract.register).toBe('function')
|
||||||
expect(contract.popupFor).toBeTypeOf('function')
|
expect(typeof contract.popupFor).toBe('function')
|
||||||
expect([...sources.keys()]).toEqual(['/ command'])
|
expect([...sources.keys()]).toEqual(['/ command'])
|
||||||
expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup'])
|
expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup'])
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
|
|||||||
@@ -134,9 +134,9 @@ const req = (query: string, position: 'leading' | 'inline' = 'leading') =>
|
|||||||
describe('registration', () => {
|
describe('registration', () => {
|
||||||
it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
|
it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
|
||||||
const { registered, source, fiber } = await bench()
|
const { registered, source, fiber } = await bench()
|
||||||
expect(source.matchSpace).toBeTypeOf('function')
|
expect(typeof source.matchSpace).toBe('function')
|
||||||
expect(source.matchEnter).toBeTypeOf('function')
|
expect(typeof source.matchEnter).toBe('function')
|
||||||
expect(source.warm).toBeTypeOf('function')
|
expect(typeof source.warm).toBe('function')
|
||||||
expect([...registered.keys()]).toEqual(['/ command'])
|
expect([...registered.keys()]).toEqual(['/ command'])
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
expect(registered.size).toBe(0)
|
expect(registered.size).toBe(0)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Registers the conversation components, shared store, and service callbacks. */
|
/** Registers the conversation components, shared store, and service callbacks. */
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
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 {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||||
import type { ViewTab } from './contract/views.ts'
|
import type { ViewTab } from './contract/views.ts'
|
||||||
import type {
|
import type {
|
||||||
@@ -54,7 +54,7 @@ export function apply(ctx: Context): void {
|
|||||||
|
|
||||||
// The per-session input machine registry (InputService face; published as
|
// The per-session input machine registry (InputService face; published as
|
||||||
// ctx.conversation.input by the service below sharing this one instance).
|
// 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
|
// Decision 19/20: the input machine feeds every session-scope slot
|
||||||
// component through the standard provide channel — the 'input' hook plus
|
// 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'),
|
version: () => slots.getVersion('conversation.view'),
|
||||||
},
|
},
|
||||||
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
||||||
open: id => { sessions.open(id) },
|
open: (id) => { sessions.open(id) },
|
||||||
}),
|
}),
|
||||||
}, ConversationSession)
|
}, ConversationSession)
|
||||||
|
|
||||||
|
|||||||
@@ -248,7 +248,10 @@ export interface ComposerChainProps {
|
|||||||
interactions: readonly PendingInteraction[]
|
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 =
|
export type ConversationSlotProps =
|
||||||
PropsRuntime<'conversation'> & PropsRenderSlots<
|
PropsRuntime<'conversation'> & PropsRenderSlots<
|
||||||
| 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar'
|
| 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar'
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export class InputMachine {
|
|||||||
private onSetInvalid(invalidIds: readonly number[]): InputEffect[] {
|
private onSetInvalid(invalidIds: readonly number[]): InputEffect[] {
|
||||||
const ids = new Set(invalidIds)
|
const ids = new Set(invalidIds)
|
||||||
if (!this.occurrences.some(o => (o.invalid === true) !== ids.has(o.occurrenceId))) return []
|
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)
|
const invalid = ids.has(o.occurrenceId)
|
||||||
if ((o.invalid === true) === invalid) return o
|
if ((o.invalid === true) === invalid) return o
|
||||||
const { invalid: _drop, ...rest } = 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
|
// Type-only imports: a plugin-to-plugin value import is a bundle purity
|
||||||
// error, so scope resolution goes through the sessions service (scopeOf
|
// error, so scope resolution goes through the sessions service (scopeOf
|
||||||
// method) instead of the standalone helper.
|
// 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'
|
import { InputHub } from './input/hub.ts'
|
||||||
|
|
||||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||||
@@ -29,7 +29,7 @@ export class ConversationService extends Service {
|
|||||||
*/
|
*/
|
||||||
constructor(ctx: Context, config?: { input?: InputHub }) {
|
constructor(ctx: Context, config?: { input?: InputHub }) {
|
||||||
super(ctx, 'conversation')
|
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) }),
|
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
|
||||||
scope: (id: SessionId) => mint(id),
|
scope: (id: SessionId) => mint(id),
|
||||||
provideInfo: () => undefined,
|
provideInfo: () => undefined,
|
||||||
|
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||||
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
|
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
|
||||||
scopeOf,
|
scopeOf,
|
||||||
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
|
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async function bench() {
|
|||||||
binding: vi.fn(),
|
binding: vi.fn(),
|
||||||
scope: () => undefined,
|
scope: () => undefined,
|
||||||
provideInfo: () => undefined,
|
provideInfo: () => undefined,
|
||||||
|
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||||
provide: vi.fn(() => () => {}),
|
provide: vi.fn(() => () => {}),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
@@ -94,15 +95,17 @@ describe('apply wiring', () => {
|
|||||||
const b = await bench()
|
const b = await bench()
|
||||||
await b.fiber.await()
|
await b.fiber.await()
|
||||||
const conversation = renderEntryOf(b.slots, 'conversation')
|
const conversation = renderEntryOf(b.slots, 'conversation')
|
||||||
|
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
|
||||||
const chatView = renderEntryOf(b.slots, 'conversation.view')
|
const chatView = renderEntryOf(b.slots, 'conversation.view')
|
||||||
const details = renderEntryOf(b.slots, 'details')
|
const details = renderEntryOf(b.slots, 'details')
|
||||||
expect(conversation?.inject).toBeTypeOf('function')
|
expect(conversation?.inject).toBeTypeOf('function')
|
||||||
expect(chatView?.inject).toBeTypeOf('function')
|
expect(chatView?.inject).toBeTypeOf('function')
|
||||||
expect(details?.inject).toBeTypeOf('function')
|
expect(details?.inject).toBeTypeOf('function')
|
||||||
// The shared handle: one apply-built store value on ALL session entries.
|
// The shared handle: one apply-built store value on ALL session entries
|
||||||
expect(conversation?.store).toBeDefined()
|
// (the session-maybe 'conversation' shell carries no store by design).
|
||||||
expect(details?.store).toBe(conversation?.store)
|
expect(conversationSession?.store).toBeDefined()
|
||||||
expect(chatView?.store).toBe(conversation?.store)
|
expect(details?.store).toBe(conversationSession?.store)
|
||||||
|
expect(chatView?.store).toBe(conversationSession?.store)
|
||||||
// The hero workspace picker hole rides the conversation entry's children
|
// The hero workspace picker hole rides the conversation entry's children
|
||||||
// declaration (the empty-state occupant is gone).
|
// declaration (the empty-state occupant is gone).
|
||||||
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ async function bench(snapshot: ConversationSnapshot) {
|
|||||||
: undefined),
|
: undefined),
|
||||||
scope: () => ({ get: () => scoped }),
|
scope: () => ({ get: () => scoped }),
|
||||||
scopeOf: () => SID,
|
scopeOf: () => SID,
|
||||||
provide: (provider: (binding: unknown) => { hooks?: Record<string, unknown>; props?: Record<string, unknown> }) => {
|
provide: (descriptor: { resolve: (binding: unknown) => { hooks?: Record<string, unknown>; props?: Record<string, unknown> } }) => {
|
||||||
const contribution = provider(sessionsFake.binding(SID))
|
const contribution = descriptor.resolve(sessionsFake.binding(SID))
|
||||||
Object.assign(provided.hooks, contribution.hooks ?? {})
|
Object.assign(provided.hooks, contribution.hooks ?? {})
|
||||||
Object.assign(provided.props, contribution.props ?? {})
|
Object.assign(provided.props, contribution.props ?? {})
|
||||||
return () => {}
|
return () => {}
|
||||||
@@ -103,6 +103,9 @@ async function bench(snapshot: ConversationSnapshot) {
|
|||||||
provideInfo: (id: string) => (id === SID
|
provideInfo: (id: string) => (id === SID
|
||||||
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
|
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
|
||||||
: undefined),
|
: 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(),
|
create: vi.fn(),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ async function bench(nodes: ToolResultNode[]) {
|
|||||||
}
|
}
|
||||||
return info
|
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,
|
scopeOf: () => SID,
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
@@ -227,6 +230,7 @@ describe('registrant load-order seam', () => {
|
|||||||
binding: () => undefined,
|
binding: () => undefined,
|
||||||
scope: () => undefined,
|
scope: () => undefined,
|
||||||
provideInfo: () => undefined,
|
provideInfo: () => undefined,
|
||||||
|
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||||
provide: () => () => {},
|
provide: () => () => {},
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function bench(): Bench {
|
|||||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||||
}),
|
}),
|
||||||
provideInfo: () => undefined,
|
provideInfo: () => undefined,
|
||||||
|
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||||
provide: () => () => {},
|
provide: () => () => {},
|
||||||
})
|
})
|
||||||
ctx.provide('workspaces', {
|
ctx.provide('workspaces', {
|
||||||
@@ -42,16 +43,19 @@ function bench(): Bench {
|
|||||||
name: 'root',
|
name: 'root',
|
||||||
children: {
|
children: {
|
||||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||||
|
'conversation.session': { kind: 'single', scope: 'session' },
|
||||||
'details': { kind: 'single', scope: 'session' },
|
'details': { kind: 'single', scope: 'session' },
|
||||||
},
|
},
|
||||||
}, (_p: { renderSlot?: unknown }) => null)
|
}, (_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)
|
slots.register({ name: 'details', store: chat }, () => null)
|
||||||
return { slots, chat }
|
return { slots, chat }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the store instance the renderer would hand a slot's component for a session. */
|
/** 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 host = renderHost(b)
|
||||||
const entry = host.entriesOf(slot)[0]!
|
const entry = host.entriesOf(slot)[0]!
|
||||||
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
|
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', () => {
|
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
|
||||||
const b = bench()
|
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'))
|
const details = storeFor(b, 'details', sid('s1'))
|
||||||
conv.actions.select({ turnSeq: 3, callId: 'c1' })
|
conv.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||||
expect(details.store.getSnapshot().selection).toEqual({ 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', () => {
|
it('sessions are isolated: s2 selection never bleeds into s1', () => {
|
||||||
const b = bench()
|
const b = bench()
|
||||||
|
|
||||||
const one = storeFor(b, 'conversation', sid('s1'))
|
const one = storeFor(b, 'conversation.session', sid('s1'))
|
||||||
const two = storeFor(b, 'conversation', sid('s2'))
|
const two = storeFor(b, 'conversation.session', sid('s2'))
|
||||||
expect(two).not.toBe(one)
|
expect(two).not.toBe(one)
|
||||||
one.actions.select({ turnSeq: 1, callId: 'a' })
|
one.actions.select({ turnSeq: 1, callId: 'a' })
|
||||||
two.actions.select({ turnSeq: 9, callId: 'z' })
|
two.actions.select({ turnSeq: 9, callId: 'z' })
|
||||||
@@ -105,14 +109,14 @@ describe('selection survives on the store seat', () => {
|
|||||||
const id = sid('s1')
|
const id = sid('s1')
|
||||||
const projection = createSnapshotStore({ displayTitle: '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.select({ turnSeq: 3, callId: 'c1' })
|
||||||
store.actions.setDraft('half-typed')
|
store.actions.setDraft('half-typed')
|
||||||
|
|
||||||
projection.set({ displayTitle: 'proj-a' })
|
projection.set({ displayTitle: 'proj-a' })
|
||||||
expect(projection.getSnapshot().displayTitle).toBe('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).toBe(store)
|
||||||
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||||
expect(after.store.getSnapshot().draft).toBe('half-typed')
|
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', () => {
|
it('session death buries the instance and its persisted draft', () => {
|
||||||
const b = bench()
|
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.setDraft('to be buried')
|
||||||
doomed.actions.select({ turnSeq: 1 })
|
doomed.actions.select({ turnSeq: 1 })
|
||||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
|
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...
|
// Persisted residue is gone with the session...
|
||||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
||||||
// ...and a re-created same-id session starts from a FRESH instance.
|
// ...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).not.toBe(doomed)
|
||||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const inject = ['slash', 'connection']
|
|||||||
* @param ctx - client root context.
|
* @param ctx - client root context.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: ClientContext): void {
|
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:
|
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
|
||||||
// the fiber effect below is its teardown boundary.
|
// the fiber effect below is its teardown boundary.
|
||||||
const fetches = new Map<SessionId, CatalogFetch>()
|
const fetches = new Map<SessionId, CatalogFetch>()
|
||||||
@@ -50,7 +50,7 @@ export function apply(ctx: ClientContext): void {
|
|||||||
if (existing !== undefined) return existing.promise
|
if (existing !== undefined) return existing.promise
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const promise = (async () => {
|
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}`)
|
if (!result.ok) throw new Error(`skill.list failed: ${result.error.code}: ${result.error.message}`)
|
||||||
return result.value.skills
|
return result.value.skills
|
||||||
})()
|
})()
|
||||||
@@ -86,8 +86,8 @@ export function apply(ctx: ClientContext): void {
|
|||||||
// Superseded keystroke: the shared fetch stays warm, this caller yields.
|
// Superseded keystroke: the shared fetch stays warm, this caller yields.
|
||||||
if (signal.aborted) return []
|
if (signal.aborted) return []
|
||||||
return skills
|
return skills
|
||||||
.filter((skill) => skill.name.startsWith(query))
|
.filter(skill => skill.name.startsWith(query))
|
||||||
.map((skill) => ({ name: skill.name, description: skill.description }))
|
.map(skill => ({ name: skill.name, description: skill.description }))
|
||||||
},
|
},
|
||||||
warm(session) {
|
warm(session) {
|
||||||
// Fire-and-forget scope-birth prewarm; the shared fetch reports
|
// Fire-and-forget scope-birth prewarm; the shared fetch reports
|
||||||
@@ -95,7 +95,7 @@ export function apply(ctx: ClientContext): void {
|
|||||||
fetchCatalog(session.sessionId).catch(() => {})
|
fetchCatalog(session.sessionId).catch(() => {})
|
||||||
},
|
},
|
||||||
lexicon(session) {
|
lexicon(session) {
|
||||||
return fetches.get(session.sessionId)?.settled?.map((skill) => skill.name)
|
return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
|
||||||
},
|
},
|
||||||
onPick({ candidate }) {
|
onPick({ candidate }) {
|
||||||
// Decision 21: plain-text reference — the literal lands in the draft
|
// 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} ` }
|
return { text: `/${candidate.name} ` }
|
||||||
},
|
},
|
||||||
codec: {
|
codec: {
|
||||||
clipboardText: (ref) => `/${ref}`,
|
clipboardText: ref => `/${ref}`,
|
||||||
serialize: (ref) => Promise.resolve(`<skill>${ref}</skill>`),
|
serialize: ref => Promise.resolve(`<skill>${ref}</skill>`),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const slash = ctx.get('slash') as SlashServiceContract
|
const slash = ctx.get('slash') as SlashServiceContract
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ describe('pick and codec', () => {
|
|||||||
describe('adjudication', () => {
|
describe('adjudication', () => {
|
||||||
it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => {
|
it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => {
|
||||||
const { source } = await bench(listOk(CATALOG))
|
const { source } = await bench(listOk(CATALOG))
|
||||||
expect(source.matchSpace).toBeUndefined()
|
expect(typeof source.matchSpace).toBe('undefined')
|
||||||
expect(source.matchEnter).toBeUndefined()
|
expect(typeof source.matchEnter).toBe('undefined')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -211,7 +211,10 @@ export class SlashController {
|
|||||||
return undefined
|
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 {
|
sourceRemoved(source: SlashSource): void {
|
||||||
const state = this.menu.getSnapshot()
|
const state = this.menu.getSnapshot()
|
||||||
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
|
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function apply(ctx: ClientContext): void {
|
|||||||
inject: (sessionId): MenuViewInjected => {
|
inject: (sessionId): MenuViewInjected => {
|
||||||
// Session-scoped slot: resolve this session's controller (the slot
|
// Session-scoped slot: resolve this session's controller (the slot
|
||||||
// frame hands ids, not ctx — the registered id→ctx interchange).
|
// 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`)
|
if (actx === undefined) throw new Error(`ui-slash: session "${String(sessionId)}" resolved no scope`)
|
||||||
const controller = slash.sessionOf(actx)
|
const controller = slash.sessionOf(actx)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ const WHITESPACE = /\s/u
|
|||||||
*/
|
*/
|
||||||
function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
|
function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
|
||||||
if (index === 0) return true
|
if (index === 0) return true
|
||||||
const prev = draft[index - 1]!
|
const prev = draft.charAt(index - 1)
|
||||||
if (WHITESPACE.test(prev)) return true
|
if (WHITESPACE.test(prev)) return true
|
||||||
if (WORD_CHAR.test(prev)) return false
|
if (WORD_CHAR.test(prev)) return false
|
||||||
if (char === '/') {
|
if (char === '/') {
|
||||||
if (prev === '/') return false
|
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
|
return true
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
|
|||||||
export const detectTrigger: DetectTrigger = (draft, caret, guard) => {
|
export const detectTrigger: DetectTrigger = (draft, caret, guard) => {
|
||||||
if (guard.tier === 'frozen') return null
|
if (guard.tier === 'frozen') return null
|
||||||
for (let i = caret - 1; i >= 0; i--) {
|
for (let i = caret - 1; i >= 0; i--) {
|
||||||
const ch = draft[i]!
|
const ch = draft.charAt(i)
|
||||||
if (WHITESPACE.test(ch)) return null
|
if (WHITESPACE.test(ch)) return null
|
||||||
if (ch !== '/' && ch !== '@') continue
|
if (ch !== '/' && ch !== '@') continue
|
||||||
if (guard.tier === 'claimed' && ch === '/') continue
|
if (guard.tier === 'claimed' && ch === '/') continue
|
||||||
|
|||||||
@@ -110,15 +110,13 @@ export const menuReduce: MenuReduce = (state, ev) => {
|
|||||||
if (!state.open) return state
|
if (!state.open) return state
|
||||||
const pos = positions(state.groups)
|
const pos = positions(state.groups)
|
||||||
if (pos.length === 0) return state
|
if (pos.length === 0) return state
|
||||||
const at = state.highlight
|
const hl = state.highlight
|
||||||
? pos.findIndex(p => p.source === state.highlight!.source && p.index === state.highlight!.index)
|
const at = hl ? pos.findIndex(p => p.source === hl.source && p.index === hl.index) : -1
|
||||||
: -1
|
const next = pos[at < 0
|
||||||
const next = at < 0
|
? (ev.dir === 1 ? 0 : pos.length - 1)
|
||||||
? (ev.dir === 1 ? pos[0]! : pos[pos.length - 1]!)
|
: (at + ev.dir + pos.length) % pos.length]
|
||||||
: pos[(at + ev.dir + pos.length) % pos.length]!
|
if (next === undefined) return state
|
||||||
if (state.highlight && next.source === state.highlight.source && next.index === state.highlight.index) {
|
if (hl && next.source === hl.source && next.index === hl.index) return state
|
||||||
return state
|
|
||||||
}
|
|
||||||
return { ...state, highlight: next }
|
return { ...state, highlight: next }
|
||||||
}
|
}
|
||||||
case 'close':
|
case 'close':
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ describe('pick / scoped input events', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('lexicon', () => {
|
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 {
|
return {
|
||||||
trigger,
|
trigger,
|
||||||
name,
|
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])
|
? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions<HandleOf<H>>] : [sessionId: SessionIdOf])
|
||||||
: ScopeOf<K> extends 'session-maybe'
|
: ScopeOf<K> extends 'session-maybe'
|
||||||
? ([H] extends [StoreDecl]
|
? ([H] extends [StoreDecl]
|
||||||
? [sessionId: SessionIdOf | undefined, actions: BoundActions<HandleOf<H>> | undefined]
|
? [sessionId: SessionIdOf | undefined, actions: BoundActions<HandleOf<H>> | undefined]
|
||||||
: [sessionId: SessionIdOf | undefined])
|
: [sessionId: SessionIdOf | undefined])
|
||||||
: ([H] extends [StoreDecl] ? [actions: BoundActions<HandleOf<H>>] : [])
|
: ([H] extends [StoreDecl] ? [actions: BoundActions<HandleOf<H>>] : [])
|
||||||
|
|
||||||
/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */
|
/** 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 childLabels = (session: ClientSessionContext, query: string): string[] => {
|
||||||
const { byId } = sessions.list.getSnapshot()
|
const { byId } = sessions.list.getSnapshot()
|
||||||
return Object.values(byId)
|
return Object.values(byId)
|
||||||
.filter((child) => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query))
|
.filter(child => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query))
|
||||||
.map((child) => child.displayTitle)
|
.map(child => child.displayTitle)
|
||||||
}
|
}
|
||||||
const source: SlashSource = {
|
const source: SlashSource = {
|
||||||
trigger: '@',
|
trigger: '@',
|
||||||
name: 'subagent',
|
name: 'subagent',
|
||||||
candidates(session, { query }) {
|
candidates(session, { query }) {
|
||||||
return Promise.resolve(childLabels(session, query).map((name) => ({ name })))
|
return Promise.resolve(childLabels(session, query).map(name => ({ name })))
|
||||||
},
|
},
|
||||||
lexicon(session) {
|
lexicon(session) {
|
||||||
// The list snapshot is always warm — the full running-children roster.
|
// 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} ` }
|
return { text: `@${candidate.name} ` }
|
||||||
},
|
},
|
||||||
codec: {
|
codec: {
|
||||||
clipboardText: (ref) => `@${ref}`,
|
clipboardText: ref => `@${ref}`,
|
||||||
// TODO: serialize returns the raw label until the '@' consumption
|
// TODO: serialize returns the raw label until the '@' consumption
|
||||||
// feature defines a model representation (design ledger).
|
// feature defines a model representation (design ledger).
|
||||||
serialize: (ref) => Promise.resolve(`@${ref}`),
|
serialize: ref => Promise.resolve(`@${ref}`),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const slash = ctx.get('slash') as SlashServiceContract
|
const slash = ctx.get('slash') as SlashServiceContract
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const sid = (id: string) => id as SessionId
|
|||||||
function sessionsWith(sessions: SessionSummary[]) {
|
function sessionsWith(sessions: SessionSummary[]) {
|
||||||
const byId: Record<string, SessionSummary> = {}
|
const byId: Record<string, SessionSummary> = {}
|
||||||
for (const s of sessions) byId[s.id] = s
|
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 } }
|
return { list: { getSnapshot: () => snapshot } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ describe('pick and codec', () => {
|
|||||||
describe('adjudication', () => {
|
describe('adjudication', () => {
|
||||||
it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => {
|
it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => {
|
||||||
const source = await bench(FAMILY)
|
const source = await bench(FAMILY)
|
||||||
expect(source.matchSpace).toBeUndefined()
|
expect('matchSpace' in source && source.matchSpace !== undefined).toBe(false)
|
||||||
expect(source.matchEnter).toBeUndefined()
|
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
|
// 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.
|
// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
// 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 route = UNARY_ROUTES[method]
|
||||||
const payload = route.schema.safeParse(message.payload)
|
const payload = route.schema.safeParse(message.payload)
|
||||||
if (!payload.success) {
|
if (!payload.success) {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe('sessions.list cold merge', () => {
|
|||||||
expect(a?.running).toBe(false)
|
expect(a?.running).toBe(false)
|
||||||
// Cold summaries are never blank: lazy persistence keeps never-appended
|
// Cold summaries are never blank: lazy persistence keeps never-appended
|
||||||
// sessions out of list(), so a listed session necessarily has events.
|
// 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?.cwd).toBe('/proj')
|
||||||
expect(a?.parentSessionId).toBeUndefined()
|
expect(a?.parentSessionId).toBeUndefined()
|
||||||
expect(b?.updatedAt).toBe(2000)
|
expect(b?.updatedAt).toBe(2000)
|
||||||
|
|||||||
@@ -703,7 +703,7 @@ class EventRelationCollector {
|
|||||||
const eventNames = this.eventNamesFromCall(node, receiverKind)
|
const eventNames = this.eventNamesFromCall(node, receiverKind)
|
||||||
if (method === 'on' || method === 'once') {
|
if (method === 'on' || method === 'once') {
|
||||||
for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
|
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)
|
for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user