@fonlan/dsh-task-kanban 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,35 +1,15 @@
1
1
  /**
2
2
  * User-facing plugin settings (global parallel worker count, per-session-type
3
- * model/effort/preset defaults), persisted through the settings service
4
- * namespace `task-kanban`. The DSH settings service requires a lowercase
5
- * kebab-case namespace (/^[a-z][a-z0-9-]*$/), so the scoped package name
6
- * cannot be used here.
3
+ * model/effort/preset defaults), carried by this bundle's entry config: dsh
4
+ * >= 0.1.7 owns plugin settings as entry config (the plugin's `Config`
5
+ * schema), so the face reads the config it was applied with and persists
6
+ * updates through `settings.update`.
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis';
9
9
  import z from '@deepseek-ai/schemastery';
10
10
  import type { KanbanSessionKind, KanbanSettingsShape } from '../shared/card.js';
11
11
  export declare const KANBAN_SETTINGS_NS = "task-kanban";
12
- export declare const KanbanSettingsSchema: z<Schemastery.ObjectS<{
13
- maxParallelWorkers: z<number, number>;
14
- refinementModel: z<string, string>;
15
- refinementProvider: z<string, string>;
16
- refinementReasoningEffort: z<string, string>;
17
- refinementPreset: z<string, string>;
18
- phaseModel: z<string, string>;
19
- phaseProvider: z<string, string>;
20
- phaseReasoningEffort: z<string, string>;
21
- phasePreset: z<string, string>;
22
- }>, Schemastery.ObjectT<{
23
- maxParallelWorkers: z<number, number>;
24
- refinementModel: z<string, string>;
25
- refinementProvider: z<string, string>;
26
- refinementReasoningEffort: z<string, string>;
27
- refinementPreset: z<string, string>;
28
- phaseModel: z<string, string>;
29
- phaseProvider: z<string, string>;
30
- phaseReasoningEffort: z<string, string>;
31
- phasePreset: z<string, string>;
32
- }>>;
12
+ export declare const KanbanSettingsSchema: z<KanbanSettingsShape>;
33
13
  /** The per-session-type default slice a session kind resolves. */
34
14
  export interface KanbanSessionDefaults {
35
15
  model: string;
@@ -52,4 +32,4 @@ export interface KanbanSettingsFace {
52
32
  */
53
33
  sessionDefaults(kind: KanbanSessionKind): KanbanSessionDefaults;
54
34
  }
55
- export declare function registerSettings(ctx: Context): KanbanSettingsFace;
35
+ export declare function registerSettings(ctx: Context, config: KanbanSettingsShape): KanbanSettingsFace;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-task-kanban",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "DSH web plugin: a task kanban that refines requirements into phased plans, auto-executes them in git worktrees (one session per phase), and auto-merges into the main branch on completion.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -80,52 +80,52 @@
80
80
  "@deepseek-ai/cordis": "^4.0.2",
81
81
  "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
82
82
  "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2",
83
- "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
84
- "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
85
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
86
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
87
- "@deepseek-ai/dsh-skill": "^0.1.5-rc.2",
88
- "@deepseek-ai/schemastery": "^3.18.2",
89
83
  "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.2",
90
84
  "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
91
85
  "@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
92
86
  "@deepseek-ai/dsh-client-ui-layout": "^0.1.5-rc.2",
87
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
93
88
  "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.2",
94
89
  "@deepseek-ai/dsh-client-ui-session": "^0.1.5-rc.2",
90
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.2",
95
91
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
96
92
  "@deepseek-ai/dsh-client-ui-workspace": "^0.1.5-rc.2",
97
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
98
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.2",
99
- "react": "^18.2.0",
100
- "react-dom": "^18.2.0"
101
- },
102
- "devDependencies": {
103
- "@types/node": "^22.0.0",
104
- "@types/react": "~18.3.1",
105
- "@types/react-dom": "~18.3.0",
106
- "tsdown": "^0.22.14",
107
- "typescript": "^5.9.0",
108
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
109
- "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2",
110
- "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
111
- "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
112
93
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
113
94
  "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
114
95
  "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
115
96
  "@deepseek-ai/dsh-skill": "^0.1.5-rc.2",
97
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
116
98
  "@deepseek-ai/schemastery": "^3.18.2",
99
+ "react": "^18.2.0",
100
+ "react-dom": "^18.2.0"
101
+ },
102
+ "devDependencies": {
103
+ "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
104
+ "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2",
117
105
  "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.2",
118
106
  "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
119
107
  "@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
120
108
  "@deepseek-ai/dsh-client-ui-layout": "^0.1.5-rc.2",
109
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
121
110
  "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.2",
122
111
  "@deepseek-ai/dsh-client-ui-session": "^0.1.5-rc.2",
112
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
113
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.2",
123
114
  "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.5-rc.2",
124
115
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
125
116
  "@deepseek-ai/dsh-client-ui-workspace": "^0.1.5-rc.2",
126
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
127
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
128
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.2",
117
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
118
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
119
+ "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
120
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
121
+ "@deepseek-ai/dsh-skill": "^0.1.5-rc.2",
122
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
123
+ "@deepseek-ai/schemastery": "^3.18.3",
124
+ "@types/node": "^22.0.0",
125
+ "@types/react": "~18.3.1",
126
+ "@types/react-dom": "~18.3.0",
127
+ "tsdown": "^0.22.14",
128
+ "typescript": "^5.9.0",
129
129
  "vitest": "^3.2.0"
130
130
  },
131
131
  "packageManager": "pnpm@11.21.0"
@@ -386,110 +386,3 @@
386
386
  font-size: 13px;
387
387
  line-height: 1.5;
388
388
  }
389
- .kb-footer-action {
390
- display: inline-flex;
391
- align-items: center;
392
- justify-content: center;
393
- gap: 6px;
394
- width: 32px;
395
- height: 32px;
396
- border-radius: 6px;
397
- border: none;
398
- background: transparent;
399
- /* same foreground as the settings trigger (its trigger css uses this var) */
400
- color: var(--dsw-alias-label-primary, inherit);
401
- cursor: pointer;
402
- }
403
- .kb-footer-action:hover { background: rgba(128, 128, 128, 0.18); }
404
- .kb-footer-action-active { background: rgba(59, 130, 246, 0.25); }
405
- /* Wide mode borrows the settings trigger's metrics (34px tall, 14px label,
406
- 18px icon) so both controls read as siblings on the same line. */
407
- .kb-footer-action-wide {
408
- width: auto;
409
- height: 34px;
410
- padding: 0 8px;
411
- border-radius: 12px;
412
- font-size: 14px;
413
- }
414
- .kb-footer-action-wide .kb-footer-label {
415
- font-size: 14px;
416
- line-height: 22px;
417
- }
418
- /* Theme following is handled by the --dsw-alias-* tokens above: they change
419
- value with the shell's selected light/dark appearance, so no OS-level
420
- prefers-color-scheme override is needed (and one would fight the app
421
- theme when the two disagree). */
422
- /* Same-line placement WITHOUT moving any DOM: the sidebar foot area becomes
423
- a reversed flex row, so its LAST child (the settings area) renders on the
424
- LEFT and the FIRST child (footer actions = the kanban button) on the RIGHT
425
- — exactly "settings, then kanban". The settings trigger flexes to fill the
426
- remaining width; the button keeps its content width. */
427
- .kb-foot-row {
428
- display: flex;
429
- flex-direction: row-reverse;
430
- align-items: stretch;
431
- gap: 4px;
432
- }
433
- .kb-foot-row > div:first-child {
434
- flex: none;
435
- width: auto;
436
- min-width: 0;
437
- display: flex;
438
- align-items: center;
439
- }
440
- .kb-foot-row > div:last-child {
441
- flex: 1;
442
- min-width: 0;
443
- }
444
- /* Rail (collapsed sidebar): column-reverse stacks the settings circle on top
445
- and the kanban icon below it, both centered; the kanban button borrows the
446
- settings trigger's rail chrome (36px circle) so the two match. */
447
- .kb-foot-rail {
448
- flex-direction: column-reverse;
449
- align-items: center;
450
- }
451
- .kb-foot-rail .kb-footer-action {
452
- width: 36px;
453
- height: 36px;
454
- border-radius: 50%;
455
- margin: 8px 0 10px;
456
- }
457
- .kb-footer-label {
458
- max-width: 110px;
459
- overflow: hidden;
460
- text-overflow: ellipsis;
461
- white-space: nowrap;
462
- }
463
- /* Narrow screens (mobile drawer / auto-collapse breakpoint): the drawer's
464
- foot area is ~256-280px but the footer actions row carries the kanban
465
- button PLUS mobile-nav's drawer actions (Files / session log), which
466
- together exceed the row and squeeze the settings trigger to zero width
467
- (row-reverse overflows left, clipping the button). Below the shell's
468
- auto-collapse breakpoint, stack the settings row above the actions and
469
- let the actions wrap so nothing is squeezed or clipped. */
470
- @media (max-width: 1023px) {
471
- .kb-foot-row {
472
- flex-direction: column-reverse;
473
- align-items: stretch;
474
- }
475
- .kb-foot-row > div:first-child {
476
- flex: none;
477
- width: 100%;
478
- display: flex;
479
- flex-wrap: wrap;
480
- justify-content: flex-start;
481
- gap: 4px;
482
- padding-top: 4px;
483
- }
484
- .kb-foot-row > div:last-child {
485
- flex: none;
486
- width: 100%;
487
- }
488
- /* The footer-actions entries are display:contents wrappers, so the kanban
489
- button itself is a flex item of the wrapping actions row; pull it to the
490
- first wrap position so it sits directly below the settings row (the
491
- mobile-nav drawer actions follow on their own wrapped line). */
492
- .kb-foot-row .kb-footer-action {
493
- order: -1;
494
- }
495
- }
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { KanbanCard, Lane, PhaseAttempt, PlanPhase } from '../shared/card'
3
3
  import { DROP_RULES, laneOf } from '../shared/lanes'
4
4
  import { api, gatewaySkillList, type SkillOption } from './api'
5
- import { exitBoard, getClient } from './kanban-state'
5
+ import { getClient, leaveKanbanPanel } from './kanban-state'
6
6
  import { en, zh } from './locales'
7
7
  import './board.css'
8
8
 
@@ -158,7 +158,10 @@ export function BoardRoot(props: BoardProps): JSX.Element {
158
158
 
159
159
  const openSession = useCallback((sessionId: string) => {
160
160
  const ctx = getClient()
161
- exitBoard()
161
+ // Return the center column to the Conversation first: `sessions.open` is
162
+ // wrapped by bindSessionNavigation as well, but a runtime without that
163
+ // binding must still leave the board before the session renders.
164
+ leaveKanbanPanel(ctx)
162
165
  if (ctx === null) return
163
166
  try {
164
167
  ;(ctx.sessions as { open(id: string): void }).open(sessionId)
@@ -0,0 +1,74 @@
1
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
2
+
3
+ /** Props every shell icon accepts: square edge in px, color rides currentColor. */
4
+ export interface IconProps {
5
+ size?: number
6
+ className?: string
7
+ }
8
+
9
+ /** One shell icon component. */
10
+ export type IconComponent = (props: IconProps) => JSX.Element
11
+
12
+ /** The runtime export table, read by capability instead of by one hard-coded name. */
13
+ const table = primitives as unknown as Record<string, IconComponent | undefined>
14
+
15
+ /** First name the runtime actually provides (a missing export is `undefined`). */
16
+ function firstDefined(...names: string[]): IconComponent | undefined {
17
+ for (const name of names) {
18
+ let candidate: IconComponent | undefined
19
+ try {
20
+ candidate = table[name]
21
+ } catch {
22
+ // Some module namespaces reject unknown names instead of yielding
23
+ // `undefined`; a rename must never throw on its way to the fallback.
24
+ continue
25
+ }
26
+ if (typeof candidate === 'function') return candidate
27
+ }
28
+ return undefined
29
+ }
30
+
31
+ /**
32
+ * Self-contained 16×16 checklist glyph (the 0.1.7 artwork) used when the shell
33
+ * primitives expose none of the names above: the footer entry must render
34
+ * something even on a runtime whose icon exports we do not know.
35
+ */
36
+ function ChecklistFallback({ size = 14, className }: IconProps): JSX.Element {
37
+ return (
38
+ <svg
39
+ width={size}
40
+ height={size}
41
+ className={className}
42
+ viewBox="0 0 16 16"
43
+ fill="none"
44
+ xmlns="http://www.w3.org/2000/svg"
45
+ aria-hidden="true"
46
+ strokeWidth={1}
47
+ >
48
+ <path d="M3.75 6.25C4.7165 6.25 5.5 5.4665 5.5 4.5C5.5 3.5335 4.7165 2.75 3.75 2.75C2.7835 2.75 2 3.5335 2 4.5C2 5.4665 2.7835 6.25 3.75 6.25Z" stroke="currentColor" />
49
+ <path d="M7.5 4.5H13.5" stroke="currentColor" />
50
+ <path d="M3.75 13.25C4.7165 13.25 5.5 12.4665 5.5 11.5C5.5 10.5335 4.7165 9.75 3.75 9.75C2.7835 9.75 2 10.5335 2 11.5C2 12.4665 2.7835 13.25 3.75 13.25Z" stroke="currentColor" />
51
+ <path d="M7.5 11.5H13.5" stroke="currentColor" />
52
+ </svg>
53
+ )
54
+ }
55
+
56
+ /**
57
+ * The checklist icon used by the sidebar panel row entry.
58
+ *
59
+ * DSH 0.1.7 renamed every size-suffixed shell icon (`IconChecklistOutline14`)
60
+ * into stroke-weight variants (`…Regular` 1px / `…Medium` 1.3px) inside
61
+ * `@deepseek-ai/dsh-client-ui-primitives`. A stale named import resolves to
62
+ * `undefined` and React then throws "Element type is invalid" while rendering
63
+ * the slot entry — the entry is dropped and the button silently disappears
64
+ * (exactly what upgrading to 0.1.7 did to this plugin). Resolve the name at
65
+ * runtime, newest first, so the next rename degrades to another shipped
66
+ * variant instead of taking the button down with it.
67
+ */
68
+ export const ChecklistIcon: IconComponent =
69
+ firstDefined(
70
+ 'IconChecklistOutlineRegular',
71
+ 'IconChecklistOutlineMedium',
72
+ 'IconChecklistOutline',
73
+ 'IconChecklistOutline14',
74
+ ) ?? ChecklistFallback
@@ -6,9 +6,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
6
6
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
7
7
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
8
8
  import { BoardRoot } from './board'
9
- import { KanbanFooterButton, KanbanSettingsSection } from './sections'
9
+ import { KanbanPanelIcon, KanbanSettingsSection } from './sections'
10
10
  import { LOCALE_NS, zh, en } from './locales'
11
- import { setClient, setBoardRoot, bindSessionNavigation } from './kanban-state'
11
+ import { KANBAN_PANEL_ID, setClient, bindSessionNavigation } from './kanban-state'
12
12
 
13
13
  /** The settings sidebar entry this plugin's page owns (must stay stable). */
14
14
  const SETTINGS_SECTION_ID = 'task-kanban'
@@ -19,8 +19,7 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale']
19
19
  /** Client plugin body. */
20
20
  export function apply(ctx: ClientContext): void {
21
21
  setClient(ctx)
22
- setBoardRoot(BoardRoot as unknown as (props: Record<string, unknown>) => React.ReactNode)
23
- // Selecting a session while the board is open must leave the board first.
22
+ // Selecting a session while the board is open must return to the Conversation.
24
23
  bindSessionNavigation(ctx)
25
24
 
26
25
  // Live translate bound to the active locale (labels re-read it per call).
@@ -30,14 +29,30 @@ export function apply(ctx: ClientContext): void {
30
29
  return () => off()
31
30
  }, 'task-kanban: dictionaries')
32
31
 
33
- ctx.slots.inject('sidebar.footer.action', () =>
32
+ // The board is a GLOBAL MAIN PANEL: the shell renders one `main` entry per
33
+ // selected panel id, so keying it (instead of replacing `main.conversation`)
34
+ // keeps the Conversation mounted and selectable beside the board, and the
35
+ // sidebar's own panel row owns the button that selects it (the
36
+ // `sidebar.panellist` entry below). Nothing of this plugin sits in the
37
+ // sidebar foot any more, so the Settings trigger can never be squeezed.
38
+ ctx.slots.inject('main', () =>
34
39
  ctx.slots.register({
35
- name: 'sidebar.footer.action',
36
- id: 'task-kanban',
37
- order: 60,
40
+ name: 'main',
41
+ key: KANBAN_PANEL_ID,
42
+ locale: LOCALE_NS,
43
+ } as never, BoardRoot as never),
44
+ )
45
+
46
+ // Sidebar panel row: the shell renders the row (label, tooltip, active state)
47
+ // and calls `layout.selectPanel(id)` on click; the entry supplies the glyph.
48
+ ctx.slots.inject('sidebar.panellist', () =>
49
+ ctx.slots.register({
50
+ name: 'sidebar.panellist',
51
+ id: KANBAN_PANEL_ID,
52
+ order: 20,
38
53
  label: () => t('kanban'),
39
54
  locale: LOCALE_NS,
40
- }, KanbanFooterButton as never),
55
+ }, KanbanPanelIcon as never),
41
56
  )
42
57
 
43
58
  // The plugin's own settings page (设置 -> 侧栏「任务看板」) rides the
@@ -53,4 +68,4 @@ export function apply(ctx: ClientContext): void {
53
68
  locale: LOCALE_NS,
54
69
  }, KanbanSettingsSection as never),
55
70
  )
56
- }
71
+ }
@@ -1,44 +1,41 @@
1
- import { useSyncExternalStore } from 'react'
2
1
  import type { Context } from '@deepseek-ai/cordis'
3
2
  type ClientContext = Context
4
3
 
5
4
  /**
6
- * HMR-safe shared state store keyed on the global symbol registry.
5
+ * Panel id shared by this plugin's main-panel key (`main`) and its sidebar
6
+ * entry (`sidebar.panellist`): the shell selects the panel by this id, so the
7
+ * two registrations must agree.
8
+ */
9
+ export const KANBAN_PANEL_ID = 'task-kanban'
10
+
11
+ /**
12
+ * Structural view of the client shell's panel-navigation face (`ctx.layout`).
13
+ * Declared locally (no value import) so the plugin keeps working on a runtime
14
+ * whose layout types differ.
15
+ */
16
+ interface LayoutLike {
17
+ selectPanel(panelId: string | null): void
18
+ }
19
+
20
+ /**
21
+ * HMR-safe owner context, keyed on the global symbol registry.
7
22
  *
8
23
  * The client-hmr hot swap re-evaluates this module (a fresh `lib/client.js`
9
- * bundle) WITHOUT reloading the page. A module-level singleton would then be
10
- * duplicated per evaluation: the session-navigation wrapper installed by the
11
- * OLD module copy keeps calling the OLD copy's `exitBoard`, which reads the
12
- * OLD copy's `boardOpen`/`boardDisposer` — while a board opened after the swap
13
- * writes the NEW copy's state cell. The wrapper would then no-op and clicking
14
- * a sidebar session would never leave the board.
15
- *
16
- * Keeping every field on one `globalThis`-backed record (via `Symbol.for`, so
17
- * all bundle copies resolve the same key) makes every copy read/write the same
18
- * state cell, so exit works regardless of which module copy performed the
19
- * enter.
24
+ * bundle) WITHOUT reloading the page; a module-level cell would then be
25
+ * duplicated per evaluation and an old copy's helper would read a stale owner.
26
+ * `Symbol.for` makes every bundle copy share one cell.
20
27
  */
21
28
  const STATE_KEY = Symbol.for('@fonlan/dsh-task-kanban/state')
22
29
 
23
30
  interface KanbanState {
24
31
  client: ClientContext | null
25
- boardRoot: ((props: Record<string, unknown>) => React.ReactNode) | null
26
- boardDisposer: (() => void) | null
27
- boardOpen: boolean
28
- listeners: Set<() => void>
29
32
  }
30
33
 
31
34
  function getState(): KanbanState {
32
35
  const g = globalThis as unknown as Record<symbol, KanbanState | undefined>
33
36
  let state = g[STATE_KEY]
34
37
  if (state === undefined) {
35
- state = {
36
- client: null,
37
- boardRoot: null,
38
- boardDisposer: null,
39
- boardOpen: false,
40
- listeners: new Set(),
41
- }
38
+ state = { client: null }
42
39
  g[STATE_KEY] = state
43
40
  }
44
41
  return state
@@ -51,80 +48,49 @@ export function getClient(): ClientContext | null {
51
48
  return getState().client
52
49
  }
53
50
 
54
- export function setBoardRoot(component: (props: Record<string, unknown>) => React.ReactNode): void {
55
- getState().boardRoot = component
56
- }
57
- export function getBoardRoot(): ((props: Record<string, unknown>) => React.ReactNode) | null {
58
- return getState().boardRoot
59
- }
60
-
61
- function notify(): void {
62
- for (const listener of getState().listeners) listener()
63
- }
64
- export function isBoardOpen(): boolean {
65
- return getState().boardOpen
66
- }
67
- function subscribe(cb: () => void): () => void {
68
- const listeners = getState().listeners
69
- listeners.add(cb)
70
- return () => { listeners.delete(cb) }
71
- }
72
- export function useBoardOpen(): boolean {
73
- return useSyncExternalStore(subscribe, isBoardOpen)
51
+ /** The shell's panel face, when this runtime provides it. */
52
+ function layoutOf(ctx: ClientContext | null): LayoutLike | null {
53
+ if (ctx === null) return null
54
+ try {
55
+ const layout = (ctx as unknown as { layout?: LayoutLike }).layout
56
+ return typeof layout?.selectPanel === 'function' ? layout : null
57
+ } catch {
58
+ return null
59
+ }
74
60
  }
75
61
 
76
- export function enterBoard(): void {
77
- const state = getState()
78
- const ctx = state.client
79
- const root = state.boardRoot
80
- if (ctx === null || root === null || state.boardDisposer !== null) return
62
+ /** Select the kanban panel. The sidebar's own entry does this; kept for in-app callers. */
63
+ export function showKanbanPanel(ctx: ClientContext | null = getClient()): void {
81
64
  try {
82
- // DSH 0.1.5 moved the replaceable shell from the legacy `conversation`
83
- // seat to `main.conversation`. Register through `inject` so the board
84
- // waits for that owner if the client plugin load order is reversed.
85
- state.boardDisposer = ctx.slots.inject('main.conversation', () =>
86
- ctx.slots.register({ name: 'main.conversation', priority: -1, locale: 'task-kanban' } as never, root as never),
87
- )
88
- state.boardOpen = true
89
- notify()
65
+ layoutOf(ctx)?.selectPanel(KANBAN_PANEL_ID)
90
66
  } catch (error) {
91
- console.error('[@fonlan/dsh-task-kanban] cannot open the board:', error)
67
+ console.error('[@fonlan/dsh-task-kanban] cannot select the kanban panel:', error)
92
68
  }
93
69
  }
94
70
 
95
- export function exitBoard(): void {
96
- const state = getState()
97
- if (state.boardDisposer !== null) {
98
- state.boardDisposer()
99
- state.boardDisposer = null
100
- }
101
- if (state.boardOpen) {
102
- state.boardOpen = false
103
- notify()
71
+ /** Hand the center column back to the Conversation. */
72
+ export function leaveKanbanPanel(ctx: ClientContext | null = getClient()): void {
73
+ try {
74
+ layoutOf(ctx)?.selectPanel(null)
75
+ } catch (error) {
76
+ console.error('[@fonlan/dsh-task-kanban] cannot leave the kanban panel:', error)
104
77
  }
105
78
  }
106
79
 
107
- export function toggleBoard(): void {
108
- if (isBoardOpen()) exitBoard()
109
- else enterBoard()
110
- }
111
-
112
80
  /**
113
- * Route session navigation through the board: opening any session while the
114
- * board is open leaves the board first, so the conversation view can render
115
- * for the newly selected session.
81
+ * Route session navigation through the panel selection: opening any session
82
+ * while the board is the active panel first returns the center column to the
83
+ * Conversation, so the session view renders.
116
84
  *
117
85
  * Every sidebar path that selects a session funnels through `sessions.open`
118
86
  * (session rows, search results, fork results, New Session) or
119
87
  * `sessions.openSubagent` (catalog children) — including re-clicking the
120
88
  * already-current session, which never changes `list.current` and therefore
121
- * cannot be caught by a list-store subscription. Wrapping the two entry
122
- * points covers all of them in one place.
89
+ * cannot be caught by a list-store subscription. Wrapping the two entry points
90
+ * covers all of them in one place.
123
91
  *
124
92
  * The wrapper is idempotent: a `kbBound` marker on the wrapper prevents a
125
- * second bind (plugin re-apply / HMR) from stacking another layer. Because the
126
- * wrapper's exit goes through the shared state store, a wrapper left over from
127
- * a previous (HMR-replaced) module copy still closes the board correctly.
93
+ * second bind (plugin re-apply / HMR) from stacking another layer.
128
94
  */
129
95
  export function bindSessionNavigation(ctx: ClientContext): void {
130
96
  const sessions = ctx.sessions
@@ -133,7 +99,7 @@ export function bindSessionNavigation(ctx: ClientContext): void {
133
99
  const open = sessions.open
134
100
  if (typeof open === 'function' && (open as unknown as { kbBound?: boolean }).kbBound !== true) {
135
101
  const bound = ((id: Parameters<typeof open>[0]) => {
136
- exitBoard()
102
+ leaveKanbanPanel(ctx)
137
103
  return open.call(sessions, id)
138
104
  }) as typeof open
139
105
  ;(bound as unknown as { kbBound?: boolean }).kbBound = true
@@ -143,10 +109,10 @@ export function bindSessionNavigation(ctx: ClientContext): void {
143
109
  const openSubagent = sessions.openSubagent
144
110
  if (typeof openSubagent === 'function' && (openSubagent as unknown as { kbBound?: boolean }).kbBound !== true) {
145
111
  const bound = ((address: Parameters<typeof openSubagent>[0]) => {
146
- exitBoard()
112
+ leaveKanbanPanel(ctx)
147
113
  return openSubagent.call(sessions, address)
148
114
  }) as typeof openSubagent
149
115
  ;(bound as unknown as { kbBound?: boolean }).kbBound = true
150
116
  sessions.openSubagent = bound
151
117
  }
152
- }
118
+ }