Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/client.js

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { BoardController } from './controller.ts'
import { disposeI18n, initI18n } from './i18n/runtime.ts'
import { injectStyles } from './styles.ts'
import { mountBoardCompat } from './official-panel.tsx'
import { createSessionJumper, type SessionsServiceFace, type WorkspacesServiceFace } from './session-jump.ts'
import { createSessionJumper, type SessionsServiceFace, type UiWorkspaceFace, type WorkspacesServiceFace } from './session-jump.ts'

/** Client plugin name. */
export const name = 'dsh-taskboard/client'
Expand Down Expand Up @@ -259,11 +259,15 @@ export function apply(ctx: ClientContextFace): void {

// Session navigation for execution rows: resolved LAZILY on every jump —
// apply may run before the runtime provides the services, and a captured
// undefined would permanently disable the jump. On a platform without
// them the jump degrades to an 'unavailable' notice instead of failing.
// undefined would permanently disable the jump. The navigation owner is
// uiWorkspace (DSH 0.1.6+; sessions.open was removed there), passed as a
// third accessor so the jumper can fall back on runtimes that still ship
// the legacy selector. On a platform with neither, the jump degrades to an
// 'unavailable' notice instead of failing.
controller.installSessionJumper(createSessionJumper({
getSessions: () => ctx.get?.('sessions') as SessionsServiceFace | undefined,
getWorkspaces: () => ctx.get?.('workspaces') as WorkspacesServiceFace | undefined,
getUiWorkspace: () => ctx.get?.('uiWorkspace') as UiWorkspaceFace | undefined,
}))

controller.start()
Expand Down
69 changes: 57 additions & 12 deletions src/client/session-jump.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
/**
* Session jump: resolve an execution's session against the runtime's live
* session list and open it in the GUI. The runtime's `sessions` service owns
* the list mirror (`list.getSnapshot().byId`) and staging (`open`); the
* `workspaces` service carries the registry-global archive set.
* session list and open it in the GUI.
*
* NAVIGATION OWNER (DSH 0.1.6+): the runtime's `sessions` service owns only
* the list mirror (`list.getSnapshot().byId`) and the catalog; its `open()`
* selector existed through 0.1.5 and was REMOVED in 0.1.6 — see the ISessions
* contract, "Host catalog and local reference-source counts; navigation
* belongs to view owners". The documented navigation entry is now
* `uiWorkspace.openSession(target)`: "Select a Session and show its
* Conversation as one UI navigation action". The workspace service carries the
* registry-global archive set.
*
* Reading a service method the runtime no longer ships yields `undefined`, and
* calling it throws — historically swallowed into a bare 'unavailable' notice,
* which is how the `sessions.open` removal silently disabled this button on
* every 0.1.7 runtime. Resolution is therefore structural: prefer the
* navigation owner, fall back to the legacy selector only while it actually
* exists, and report 'unavailable' only when neither is present.
*
* Outcomes are split so the UI can prompt precisely:
* - `opened` — staged and opened; the board closes over it.
* - `archived` — in the list but archived (hidden from the sidebar; its log
* survives, so it is distinguishable from deletion).
* - `missing` — absent from the live list: deleted.
* - `unavailable`— runtime session services absent (service timing / errors).
* - `unavailable`— no navigation entry is present on this runtime.
*
* Service resolution is deliberately LAZY (per click): plugin apply may run
* before the runtime provides `sessions`, and a once-captured undefined would
* permanently disable the jump. When the id misses, the list mirror may also
* simply lag (reconnect re-pull, late mount): one `refresh()` is awaited and
* the lookup retried before deciding.
* before the runtime provides these services, and a once-captured undefined
* would permanently disable the jump. When the id misses, the list mirror may
* also simply lag (reconnect re-pull, late mount): one `refresh()` is awaited
* and the lookup retried before deciding.
*
* @module dsh-taskboard/client/session-jump
*/
Expand All @@ -29,8 +43,12 @@ export type SessionJumpResult =

/** Narrow face of the runtime `sessions` service this module needs. */
export interface SessionsServiceFace {
/** Select a listed session as current (the window opens with it). */
open(id: string): void
/**
* Legacy selector, present through DSH 0.1.5 and gone from 0.1.6 on.
* Optional on purpose: a runtime without it must not turn the whole jump
* into a type error, and an absent method is detected, not called.
*/
open?(id: string): void
/** Re-pull the session list baseline (mirror catch-up). */
refresh(): Promise<void>
/** Live session list snapshot. */
Expand All @@ -51,12 +69,20 @@ export interface WorkspacesServiceFace {
}
}

/** Narrow face of the runtime `uiWorkspace` navigation service (0.1.6+). */
export interface UiWorkspaceFace {
/** Select a Session and show its Conversation as one UI navigation action. */
openSession(id: string): void
}

/** Lazy per-click service resolution (services may appear after apply). */
export interface SessionServiceAccess {
/** The runtime sessions service, when currently provided. */
getSessions(): SessionsServiceFace | undefined
/** The runtime workspaces service, when currently provided (optional). */
getWorkspaces(): WorkspacesServiceFace | undefined
/** The runtime navigation owner, when currently provided (optional). */
getUiWorkspace?(): UiWorkspaceFace | undefined
}

/**
Expand All @@ -71,6 +97,26 @@ export function createSessionJumper(access: SessionServiceAccess): (sessionId: s
const archived = workspaces?.list.getSnapshot().archivedSessionIds.includes(sessionId) ?? false
return archived ? 'archived' : 'openable'
}
/**
* Hand the id to whichever navigation entry this runtime actually ships.
* Structural check first (a missing method must never be called), so an
* absent entry becomes an outcome instead of a swallowed TypeError.
* @param sessions - the resolved sessions service.
* @param sessionId - the listed session to show.
* @returns true when some entry took the navigation.
*/
const navigate = (sessions: SessionsServiceFace, sessionId: string): boolean => {
const nav = access.getUiWorkspace?.()
if (typeof nav?.openSession === 'function') {
nav.openSession(sessionId)
return true
}
if (typeof sessions.open === 'function') {
sessions.open(sessionId)
return true
}
return false
}
return async (sessionId: string): Promise<SessionJumpResult> => {
const sessions = access.getSessions()
if (sessions === undefined) return 'unavailable'
Expand All @@ -84,8 +130,7 @@ export function createSessionJumper(access: SessionServiceAccess): (sessionId: s
}
if (state === 'archived') return 'archived'
if (state === 'absent') return 'missing'
sessions.open(sessionId)
return 'opened'
return navigate(sessions, sessionId) ? 'opened' : 'unavailable'
} catch {
return 'unavailable'
}
Expand Down
41 changes: 41 additions & 0 deletions tests/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,47 @@ describe('client half', () => {
controller.dispose()
})

// Regression guard for the DSH 0.1.7 runtime shape: the runtime's sessions
// service no longer carries `open()` (0.1.5 had one, 0.1.6+ removed it —
// "navigation belongs to view owners"). The documented navigation entry is
// uiWorkspace.openSession(); reading a missing `sessions.open` throws inside
// the jumper's try, so a runtime-shaped sessions service must still open.
it('session jump opens through the navigation service when sessions has no open()', async () => {
const { BoardController } = await import('../src/client/controller.ts')
const { createSessionJumper } = await import('../src/client/session-jump.ts')

const client = {
state: async () => ({ schemaVersion: 1, revision: 1, tasks: [] }),
workspaces: async () => [],
stream: () => () => {},
}
const controller = new BoardController(client as never)
controller.openBoard()

// Runtime-shaped services: probe-clean, no `open` key at all.
const sessions = {
refresh: async () => {},
list: { getSnapshot: () => ({ byId: { 's-live': {} } }) },
}
const workspaces = { list: { getSnapshot: () => ({ archivedSessionIds: [] as string[] }) } }
const navigated: string[] = []
const uiWorkspace = { openSession: (id: string) => { navigated.push(id) } }

controller.installSessionJumper(createSessionJumper({
getSessions: () => sessions as never,
getWorkspaces: () => workspaces as never,
getUiWorkspace: () => uiWorkspace as never,
}))

// The jump must not degrade to 'unavailable' on this runtime, and the
// board must close over the session it navigated to.
expect(await controller.openSession('s-live')).toBe('opened')
expect(navigated).toEqual(['s-live'])
expect(controller.getSnapshot().boardOpen).toBe(false)

controller.dispose()
})

it('controller: search filter, urgency sort, and persisted view state', async () => {
localStorage.clear()
const { BoardController } = await import('../src/client/controller.ts')
Expand Down