dsh-brief-sidebar 0.3.0

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.
Files changed (40) hide show
  1. package/README.md +178 -0
  2. package/cordis.patch.yml +18 -0
  3. package/dsh.plugin.json +16 -0
  4. package/lib/client.js +960 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.mjs +5620 -0
  7. package/lib/index.mjs.map +1 -0
  8. package/lib/types/client/BriefIcon.d.ts +13 -0
  9. package/lib/types/client/SummaryTab.d.ts +18 -0
  10. package/lib/types/client/TodoBoardTab.d.ts +31 -0
  11. package/lib/types/client/brief/board.d.ts +50 -0
  12. package/lib/types/client/brief/dock-shadow.d.ts +49 -0
  13. package/lib/types/client/brief/use-todos.d.ts +13 -0
  14. package/lib/types/client/file-open.d.ts +50 -0
  15. package/lib/types/client/index.d.ts +23 -0
  16. package/lib/types/client/locales.d.ts +53 -0
  17. package/lib/types/client/summary/DeliverablesSection.d.ts +18 -0
  18. package/lib/types/client/summary/deliverables.d.ts +40 -0
  19. package/lib/types/client/use-projection.d.ts +54 -0
  20. package/lib/types/index.d.ts +27 -0
  21. package/lib/types/projection/deliverables-fold.d.ts +85 -0
  22. package/lib/types/projection/keys.d.ts +25 -0
  23. package/lib/types/projection/register.d.ts +9 -0
  24. package/package.json +94 -0
  25. package/src/client/BriefIcon.tsx +56 -0
  26. package/src/client/SummaryTab.tsx +73 -0
  27. package/src/client/TodoBoardTab.tsx +188 -0
  28. package/src/client/brief/board.ts +104 -0
  29. package/src/client/brief/dock-shadow.tsx +74 -0
  30. package/src/client/brief/use-todos.ts +27 -0
  31. package/src/client/file-open.ts +108 -0
  32. package/src/client/index.tsx +144 -0
  33. package/src/client/locales.ts +78 -0
  34. package/src/client/summary/DeliverablesSection.tsx +192 -0
  35. package/src/client/summary/deliverables.ts +93 -0
  36. package/src/client/use-projection.ts +117 -0
  37. package/src/index.ts +32 -0
  38. package/src/projection/deliverables-fold.ts +206 -0
  39. package/src/projection/keys.ts +27 -0
  40. package/src/projection/register.ts +99 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Opening a produced file's preview in the right Sidebar — the same channel
3
+ * the conversation flow uses for its produced-file chips and inline mentions
4
+ * (ui-chat `openFile` → `ctx.sidebarRight.openResource` with a
5
+ * `dsh-resource://file/…` address).
6
+ *
7
+ * The address grammar is ported verbatim from the harness's browser-safe
8
+ * `@deepseek-ai/dsh-util-workspace-path` (`file-address.ts` / `index.ts`),
9
+ * because the plugin does not compile against that package: a relative path,
10
+ * or an absolute path inside the session's workspace, is addressed under the
11
+ * session's scope (`dsh-resource://file/session/<id>/<path>`); an absolute
12
+ * path elsewhere keeps its absolute spelling in the same session's address.
13
+ * Which tab type claims the address is the Sidebar's decision, not ours.
14
+ *
15
+ * The `sidebarRight` service is consumed structurally and treated as OPTIONAL:
16
+ * where it is absent the section renders plain rows instead of links, the same
17
+ * graceful degradation as the deliverables projection itself.
18
+ */
19
+ import type { Context } from '@deepseek-ai/cordis'
20
+
21
+ /** Structural view of the right-Sidebar service this plugin needs. */
22
+ export interface SidebarRightLike {
23
+ openResource(url: string, options?: { params?: { line?: number } }): void
24
+ }
25
+
26
+ /** Structural view of the sessions service slice the opener reads. */
27
+ interface SessionsListLike {
28
+ list?: { getSnapshot(): { byId?: Record<string, { cwd?: string }> } }
29
+ }
30
+
31
+ /** The address scheme every file resource opens with. */
32
+ const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
33
+
34
+ /** Whether a path uses a Windows drive or UNC prefix. */
35
+ function isWindowsStylePath(value: string): boolean {
36
+ return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\')
37
+ }
38
+
39
+ /**
40
+ * Whether a path is absolute in either spelling the Host accepts: POSIX (`/a/b`)
41
+ * or Windows drive or UNC.
42
+ */
43
+ export function isAbsoluteWorkspacePath(path: string): boolean {
44
+ return path.startsWith('/') || isWindowsStylePath(path)
45
+ }
46
+
47
+ /** Component-encode one id or path segment, keeping `:` literal for drive letters. */
48
+ function encodeSegment(segment: string): string {
49
+ return encodeURIComponent(segment).replace(/%3A/gi, ':')
50
+ }
51
+
52
+ /** Encode a `/`-separated path segment by segment. */
53
+ function encodePath(path: string): string {
54
+ return path.split('/').map(encodeSegment).join('/')
55
+ }
56
+
57
+ /**
58
+ * Build the address of a file read through one Session.
59
+ * @param sessionId - the Session whose Host workspace resolves the path.
60
+ * @param path - absolute or workspace-relative path; backslashes are normalized
61
+ * to `/`, and leading `./` prefixes are dropped.
62
+ */
63
+ function sessionFileAddress(sessionId: string, path: string): string {
64
+ const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '')
65
+ return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`
66
+ }
67
+
68
+ /**
69
+ * The address for a path as a caller holds it.
70
+ * @param sessionId - the Session the path is read in.
71
+ * @param cwd - that Session's workspace root, when known.
72
+ * @param path - absolute or workspace-relative path, in either separator spelling.
73
+ */
74
+ export function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string {
75
+ const normalized = path.replace(/\\/g, '/')
76
+ if (!isAbsoluteWorkspacePath(normalized)) return sessionFileAddress(sessionId, normalized)
77
+ const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '')
78
+ if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '')
79
+ if (root !== '' && normalized.startsWith(`${root}/`)) {
80
+ return sessionFileAddress(sessionId, normalized.slice(root.length + 1))
81
+ }
82
+ return sessionFileAddress(sessionId, normalized)
83
+ }
84
+
85
+ /**
86
+ * Build a sidebar file opener for one session, or `undefined` when the
87
+ * environment cannot open previews (no `sidebarRight` service, no session).
88
+ * The cwd is re-read per call inside the opener, so a workspace switch between
89
+ * renders is picked up on the next click.
90
+ *
91
+ * @param ctx - the client root context.
92
+ * @param sessionId - the session whose workspace resolves paths.
93
+ * @returns an opener that routes a produced path to the Sidebar preview.
94
+ */
95
+ export function sidebarFileOpener(
96
+ ctx: Context | undefined,
97
+ sessionId: string | undefined,
98
+ ): ((path: string) => void) | undefined {
99
+ if (ctx === undefined || sessionId === undefined) return undefined
100
+ const bar = ctx.get('sidebarRight') as SidebarRightLike | undefined
101
+ if (bar === undefined || typeof bar.openResource !== 'function') return undefined
102
+
103
+ return (path: string) => {
104
+ const sessions = ctx.get('sessions') as SessionsListLike | undefined
105
+ const cwd = sessions?.list?.getSnapshot?.().byId?.[sessionId]?.cwd
106
+ bar.openResource(fileAddressFor(sessionId, cwd, path))
107
+ }
108
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Browser half: the task board tab, plus the shadow that hides the official
3
+ * composer-band todo panel.
4
+ *
5
+ * Registration order matters and is deliberate:
6
+ *
7
+ * 1. **Dictionaries first.** Every later registration can render translated
8
+ * copy, and a dictionary that lands after the first paint would show raw
9
+ * keys for a frame.
10
+ * 2. **Soft-dependency check.** `inject` already waits for `betterSidebar`, so
11
+ * the guard only fires in a composition that relaxes the inject list. It is
12
+ * kept because it is what stops the plugin from hiding the official panel
13
+ * when it has no board to replace it with.
14
+ * 3. **Shadow the official dock.** Deliberately BEFORE the tab registration:
15
+ * the panel is the only other carrier of the list, so the replacement has to
16
+ * exist in the same activation.
17
+ * 4. **Register the tab.**
18
+ *
19
+ * Every registration rides `ctx.effect(fn, label)` so its disposer is revoked on
20
+ * fiber teardown (HMR / disable), which is also what makes the shadow
21
+ * reversible: unload the plugin and the official dock renders again.
22
+ *
23
+ * Contract notes for the two surfaces touched here:
24
+ * - better-sidebar tabs are hosted by DSH's native right Sidebar, so the tab
25
+ * body receives `TabComponentProps` (`ctx` + `scope` + `visible`) and NOT the
26
+ * framework's session-scoped slot props.
27
+ * - `conversation.input.dock` is the DSH slot the official todo panel occupies;
28
+ * see `brief/dock-shadow.tsx` for why a lower priority wins the cell.
29
+ */
30
+ import { createElement } from 'react'
31
+ import type { Context } from '@deepseek-ai/cordis'
32
+ import type { BetterSidebarService, TabComponentProps } from 'dsh-better-sidebar/client/service'
33
+ import { SummaryTab } from './SummaryTab'
34
+ import { BriefIcon } from './BriefIcon'
35
+ import { NS, dictionaries } from './locales'
36
+ import { readTodos, unfinishedCount } from './brief/board'
37
+ import { registerTodoDockShadow } from './brief/dock-shadow'
38
+ import { TODOS_KEY } from './brief/use-todos'
39
+ import { projectionSnapshot } from './use-projection'
40
+
41
+ /**
42
+ * Tab type id. Package-prefixed so it cannot collide with a built-in type.
43
+ * Unchanged since the 0.1.0 board tab: the summary tab REPLACES the board tab
44
+ * in place, so an opened or pinned tab survives the upgrade.
45
+ */
46
+ export const TAB_ID = 'dsh-brief-sidebar:board'
47
+
48
+ /**
49
+ * Position in the host's new-tab guide.
50
+ *
51
+ * Built-ins on this host generation sit at editor 10 / git 20 / subagent 30 /
52
+ * sidechat 35 / terminal 40 / browser 50. 15 places a per-session task board
53
+ * right after the editor, which is where a reader looks for session state.
54
+ */
55
+ export const TAB_ORDER = 15
56
+
57
+ /** Services required before `apply` runs. */
58
+ export const inject = ['betterSidebar', 'locale', 'slots']
59
+
60
+ /**
61
+ * Structural view of the DSH locale service — only the two members this plugin
62
+ * calls. Declared locally on purpose: importing the real type would pull the
63
+ * whole client type graph into a browser-only plugin for two signatures.
64
+ */
65
+ interface LocaleLike {
66
+ register(ns: string, dicts: Record<string, Record<string, string>>): () => void
67
+ bind(ns: string): (key: string) => string
68
+ }
69
+
70
+ /**
71
+ * An effect body must return a disposer (cordis rejects `undefined` with a
72
+ * `TypeError`), so a path that could not register anything returns this no-op
73
+ * instead of nothing.
74
+ */
75
+ const NOOP = (): void => {}
76
+
77
+ /**
78
+ * Browser-face apply.
79
+ *
80
+ * @param ctx - the client root context.
81
+ */
82
+ export function apply(ctx: Context): void {
83
+ // 1. Plugin-owned dictionaries, in one registration.
84
+ const locale: LocaleLike | undefined = ctx.get('locale')
85
+ if (locale !== undefined) {
86
+ ctx.effect(() => locale.register(NS, dictionaries), 'dsh-brief-sidebar: dictionaries')
87
+ }
88
+
89
+ /**
90
+ * Translate one of our keys. `bind` returns a cached function that reads the
91
+ * ACTIVE locale at call time, so a language switch needs no re-apply; an
92
+ * unknown namespace or key falls back to the key itself rather than throwing
93
+ * inside a render.
94
+ */
95
+ const t = (key: string): string => {
96
+ if (locale === undefined) return key
97
+ try {
98
+ return locale.bind(NS)(key) || key
99
+ } catch {
100
+ return key
101
+ }
102
+ }
103
+
104
+ // 2. Soft dependency: without better-sidebar there is no place to show the
105
+ // list, so the plugin stays inert and the official panel keeps rendering.
106
+ const bar: BetterSidebarService | undefined = ctx.get('betterSidebar')
107
+ if (bar === undefined) return
108
+
109
+ // 3. Hide the official composer-band panel (reversible on unload).
110
+ ctx.effect(() => registerTodoDockShadow(ctx) ?? NOOP, 'dsh-brief-sidebar: dock shadow')
111
+
112
+ // 4. The board.
113
+ ctx.effect(
114
+ () =>
115
+ bar.registerTab({
116
+ id: TAB_ID,
117
+ title: () => t('tab.title'),
118
+ description: () => t('tab.desc'),
119
+ icon: (size: number) => BriefIcon(size),
120
+ order: TAB_ORDER,
121
+ // Shorthand for `dedupeKey: () => id`: reopening focuses the existing tab.
122
+ single: true,
123
+ /**
124
+ * Unfinished count on the tab strip. Called on every tab-bar render, so
125
+ * it reads one snapshot and allocates no subscription; a completed list
126
+ * shows no badge at all.
127
+ */
128
+ badge: (badgeCtx, badgeScope) => {
129
+ const todos = readTodos(projectionSnapshot(badgeCtx, badgeScope.sessionId, TODOS_KEY))
130
+ if (todos === undefined) return null
131
+ const pending = unfinishedCount(todos)
132
+ return pending > 0 ? pending : null
133
+ },
134
+ component: (props: TabComponentProps) =>
135
+ createElement(SummaryTab, {
136
+ t,
137
+ ctx: props.ctx,
138
+ scope: props.scope,
139
+ visible: props.visible,
140
+ }),
141
+ }),
142
+ 'dsh-brief-sidebar: tab',
143
+ )
144
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Plugin-owned i18n dictionaries.
3
+ *
4
+ * Consumer plugins must NOT reach into better-sidebar's internal `t()` or its
5
+ * `betterSidebar` dictionary namespace, so this plugin registers its own
6
+ * namespace through the DSH `locale` service. `dsh-brief-sidebar` is outside the
7
+ * `LocaleNamespaceMap` merge table, which is exactly what the untyped
8
+ * `register(ns, dicts)` overload is for — but the dictionaries themselves stay
9
+ * strictly paired: `zh` is the key-set source of truth and `en` is typed as
10
+ * `Record<keyof typeof zh, string>`, so a missing or extra English key is a
11
+ * COMPILE error rather than a runtime fallback to the raw key.
12
+ *
13
+ * Every key referenced anywhere in `src/**` must exist in both dictionaries;
14
+ * `tests/purity.spec.ts` asserts that, so a typo in a `t('…')` call cannot
15
+ * ship as an untranslated literal.
16
+ */
17
+
18
+ /** The namespace owned by this plugin (own vocabulary, own lifecycle). */
19
+ export const NS = 'dsh-brief-sidebar'
20
+
21
+ /** Simplified Chinese dictionary — the key-set source of truth. */
22
+ export const zh = {
23
+ 'tab.title': '概要',
24
+ 'tab.desc': '当前会话的任务、产物与规划文件(只读)',
25
+ 'section.progress': '进展',
26
+ 'section.deliverables': '产物',
27
+ 'board.empty': '当前没有任务',
28
+ 'board.emptyHint': '模型调用 todo_write 之后,任务会出现在这里。',
29
+ 'board.unavailable': '任务暂不可用',
30
+ 'board.unavailableHint': '这个会话还没有收到 todo 数据;宿主推来第一帧后会自动显示。',
31
+ 'status.pending': '待处理',
32
+ 'status.inProgress': '进行中',
33
+ 'status.completed': '已完成',
34
+ 'deliverables.empty': '本回合暂无改动文件',
35
+ 'deliverables.emptyHint': '会话里成功写入或修改文件后,它们会实时出现在这里。',
36
+ 'deliverables.sessionTotal': '本会话共 {count} 个文件',
37
+ 'deliverable.codeplanTag': '规划',
38
+ }
39
+
40
+ /** The key union: what a `t('…')` call may name inside this namespace. */
41
+ export type BriefKey = keyof typeof zh
42
+
43
+ /** English dictionary, checked complete against the zh key set. */
44
+ export const en: Record<BriefKey, string> = {
45
+ 'tab.title': 'Summary',
46
+ 'tab.desc': 'Tasks, produced files and plan artifacts of this session (read-only)',
47
+ 'section.progress': 'Progress',
48
+ 'section.deliverables': 'Produced files',
49
+ 'board.empty': 'No tasks yet',
50
+ 'board.emptyHint': 'Tasks show up here once the model calls todo_write.',
51
+ 'board.unavailable': 'Tasks unavailable',
52
+ 'board.unavailableHint':
53
+ 'This session has not received any todo data yet; the board fills in on the first frame.',
54
+ 'status.pending': 'Pending',
55
+ 'status.inProgress': 'In progress',
56
+ 'status.completed': 'Done',
57
+ 'deliverables.empty': 'No files changed in this turn',
58
+ 'deliverables.emptyHint': 'Files written or edited successfully in the session appear here live.',
59
+ 'deliverables.sessionTotal': '{count} files in this session',
60
+ 'deliverable.codeplanTag': 'Plan',
61
+ }
62
+
63
+ /**
64
+ * Both locales in the shape the locale service consumes. Registered in ONE
65
+ * call: the registry rejects a duplicate `(namespace, locale)` pair and the
66
+ * per-locale form would leave a half-registered namespace if the second call
67
+ * threw.
68
+ */
69
+ export const dictionaries: Record<string, Record<string, string>> = { zh, en }
70
+
71
+ /**
72
+ * Expand the one `{count}` placeholder the deliverables totals line uses. The
73
+ * locale binder is a plain key → string lookup (no interpolation machinery),
74
+ * mirroring how `progressLine` composes its summary from parts.
75
+ */
76
+ export function expandCount(template: string, count: number): string {
77
+ return template.replace('{count}', String(count))
78
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The deliverables section: the `dshSummaryDeliverables` projection drawn as
3
+ * one part of the summary tab.
4
+ *
5
+ * Unlike the progress section this is an OPTIONAL capability: where the host
6
+ * half never registered the unit (older composition, key absent), the section
7
+ * hides entirely — absence is not an error state here, and the official
8
+ * "本次产出" row in the conversation flow still covers the turn-end view.
9
+ * When the projection is live, the latest turn's produced paths update
10
+ * mid-turn: every successful mutation result publishes a frame, so the reader
11
+ * sees files appear while the turn is still running, which is exactly the gap
12
+ * the official turn-tail row has.
13
+ *
14
+ * Codeplan artifacts (paths under `.agents/plans/<任务名>/`) are ordinary
15
+ * produced entries — they are marked with a pill, not split into a separate
16
+ * section.
17
+ *
18
+ * Colours come from `--dsw-alias-*` tokens only (skin contract).
19
+ */
20
+ import { createElement } from 'react'
21
+ import type { CSSProperties, ReactNode } from 'react'
22
+ import type { Context } from '@deepseek-ai/cordis'
23
+ import { DELIVERABLES_KEY } from '../../projection/keys'
24
+ import { useProjectionValue } from '../use-projection'
25
+ import { sidebarFileOpener } from '../file-open'
26
+ import { basename, readDeliverables, splitCodeplanPath } from './deliverables'
27
+ import { expandCount } from '../locales'
28
+
29
+ export interface DeliverablesSectionProps {
30
+ /** The DSH locale lookup, passed down from `apply`. */
31
+ t: (key: string) => string
32
+ /** The client root context (the tab body's only way to reach services). */
33
+ ctx?: Context
34
+ /** The session this tab is scoped to. */
35
+ scope?: { readonly sessionId?: string }
36
+ }
37
+
38
+ const SECTION_STYLE: CSSProperties = {
39
+ flexShrink: 0,
40
+ borderBottom: '1px solid var(--dsw-alias-border-secondary)',
41
+ }
42
+
43
+ const HEADER_STYLE: CSSProperties = {
44
+ padding: '10px 12px 0',
45
+ color: 'var(--dsw-alias-label-secondary)',
46
+ fontSize: 12,
47
+ fontWeight: 600,
48
+ }
49
+
50
+ const LIST_STYLE: CSSProperties = {
51
+ listStyle: 'none',
52
+ margin: 0,
53
+ padding: '6px 0',
54
+ display: 'flex',
55
+ flexDirection: 'column',
56
+ }
57
+
58
+ const ROW_STYLE: CSSProperties = {
59
+ display: 'flex',
60
+ alignItems: 'flex-start',
61
+ gap: 8,
62
+ padding: '6px 12px',
63
+ }
64
+
65
+ const TOTAL_STYLE: CSSProperties = {
66
+ padding: '6px 12px 10px',
67
+ color: 'var(--dsw-alias-label-tertiary)',
68
+ fontSize: 12,
69
+ }
70
+
71
+ const PATH_STYLE: CSSProperties = {
72
+ minWidth: 0,
73
+ overflowWrap: 'anywhere',
74
+ color: 'var(--dsw-alias-label-secondary)',
75
+ }
76
+
77
+ /** Row-as-button reset: the row itself is the click target, content unchanged. */
78
+ const BUTTON_STYLE: CSSProperties = {
79
+ ...PATH_STYLE,
80
+ background: 'none',
81
+ border: 'none',
82
+ font: 'inherit',
83
+ fontSize: 'inherit',
84
+ color: 'var(--dsw-alias-label-secondary)',
85
+ padding: 0,
86
+ textAlign: 'left',
87
+ cursor: 'pointer',
88
+ }
89
+
90
+ /** The codeplan pill: same form as the status pills, tertiary emphasis. */
91
+ const TAG_STYLE: CSSProperties = {
92
+ flexShrink: 0,
93
+ color: 'var(--dsw-alias-label-tertiary)',
94
+ border: '1px solid var(--dsw-alias-label-tertiary)',
95
+ borderRadius: 999,
96
+ padding: '0 6px',
97
+ fontSize: 11,
98
+ lineHeight: '16px',
99
+ whiteSpace: 'nowrap',
100
+ }
101
+
102
+ /**
103
+ * One produced-file row. When the sidebar opener is available the row is a
104
+ * button that routes the path to the Sidebar preview (the same channel the
105
+ * conversation's produced-file chips use); otherwise it degrades to a plain
106
+ * span. A codeplan artifact carries the pill either way.
107
+ */
108
+ function PathRow(props: {
109
+ path: string
110
+ t: (key: string) => string
111
+ open?: (path: string) => void
112
+ }): ReactNode {
113
+ const { path, t, open } = props
114
+ const plan = splitCodeplanPath(path)
115
+ const title = plan === null ? path : `${t('deliverable.codeplanTag')}: ${plan.task} · ${path}`
116
+ const label = basename(path)
117
+ const content = plan === null ? null : createElement('span', { style: TAG_STYLE }, t('deliverable.codeplanTag'))
118
+ if (open === undefined) {
119
+ return createElement(
120
+ 'li',
121
+ { style: ROW_STYLE },
122
+ content,
123
+ createElement('span', { style: PATH_STYLE, title }, label),
124
+ )
125
+ }
126
+ return createElement(
127
+ 'li',
128
+ { style: ROW_STYLE },
129
+ content,
130
+ createElement('button', { style: BUTTON_STYLE, title, onClick: () => { open(path) } }, label),
131
+ )
132
+ }
133
+
134
+ /**
135
+ * The deliverables section. The subscription is unconditional (a plain
136
+ * listener, not IO).
137
+ * @param props - translation, client context, session scope.
138
+ */
139
+ export function DeliverablesSection(props: DeliverablesSectionProps): ReactNode {
140
+ const { t, ctx, scope } = props
141
+
142
+ const view = readDeliverables(useProjectionValue(ctx, scope?.sessionId, DELIVERABLES_KEY))
143
+
144
+ // Capability absent — the optional unit never registered. Hide, don't nag.
145
+ if (view === undefined) return null
146
+
147
+ // Optional click-to-preview: rows degrade to plain text where the sidebar
148
+ // service is absent (the opener resolves the session cwd per click).
149
+ const open = sidebarFileOpener(ctx, scope?.sessionId)
150
+
151
+ const latestPaths = view.latest?.paths ?? []
152
+
153
+ if (latestPaths.length === 0 && view.sessionTotal === 0) {
154
+ return createElement(
155
+ 'div',
156
+ { style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'deliverables-section' },
157
+ createElement('div', { style: HEADER_STYLE }, t('section.deliverables')),
158
+ createElement(
159
+ 'div',
160
+ {
161
+ style: {
162
+ margin: '0 auto',
163
+ padding: 24,
164
+ maxWidth: 460,
165
+ textAlign: 'center',
166
+ color: 'var(--dsw-alias-label-tertiary)',
167
+ lineHeight: 1.7,
168
+ fontSize: 12,
169
+ },
170
+ },
171
+ createElement('div', { style: { marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' } }, t('deliverables.empty')),
172
+ createElement('div', null, t('deliverables.emptyHint')),
173
+ ),
174
+ )
175
+ }
176
+
177
+ return createElement(
178
+ 'div',
179
+ { style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'deliverables-section' },
180
+ createElement('div', { style: HEADER_STYLE }, t('section.deliverables')),
181
+ createElement(
182
+ 'ul',
183
+ { style: LIST_STYLE },
184
+ latestPaths.map(path => createElement(PathRow, { key: path, path, t, open })),
185
+ ),
186
+ createElement(
187
+ 'div',
188
+ { style: TOTAL_STYLE },
189
+ expandCount(t('deliverables.sessionTotal'), view.sessionTotal),
190
+ ),
191
+ )
192
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The `dshSummaryDeliverables` projection — reading and classifying it.
3
+ *
4
+ * The Host computes this projection (this plugin's own node half registers the
5
+ * unit) and pushes finished whole values; the client never folds it. This
6
+ * module is the read side of that contract and is deliberately PURE — no
7
+ * React, no context, no IO — so the narrowing and the codeplan classification
8
+ * can be tested directly.
9
+ */
10
+ import type { DeliverablesView } from '../../projection/keys'
11
+
12
+ /**
13
+ * Narrow a raw projection value into a deliverables view.
14
+ *
15
+ * Two outcomes are kept apart on purpose, mirroring the store's contract:
16
+ * - `undefined` — the capability is ABSENT (the host half never registered —
17
+ * e.g. an older composition — or no frame carried the key yet);
18
+ * - a view — the projection is live. A view with `latest: null` and
19
+ * `sessionTotal: 0` IS the empty state; the fold starts publishing as soon
20
+ * as the unit registers, so "present but empty" is a real value here, not a
21
+ * sentinel.
22
+ *
23
+ * @param value - the raw `unknown` snapshot of the deliverables face.
24
+ * @returns the validated view, or `undefined` when the capability is absent.
25
+ */
26
+ export function readDeliverables(value: unknown): DeliverablesView | undefined {
27
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
28
+ const raw = value as { latest?: unknown; sessionPaths?: unknown; sessionTotal?: unknown }
29
+ if (raw.latest === undefined || raw.sessionPaths === undefined || raw.sessionTotal === undefined) {
30
+ return undefined
31
+ }
32
+ if (!isBucket(raw.latest) || !Array.isArray(raw.sessionPaths) || typeof raw.sessionTotal !== 'number') {
33
+ return undefined
34
+ }
35
+ const paths = raw.sessionPaths.filter((path): path is string => typeof path === 'string')
36
+ return {
37
+ latest: raw.latest === null ? null : { turn: raw.latest.turn, paths: raw.latest.paths },
38
+ sessionPaths: paths,
39
+ sessionTotal: raw.sessionTotal,
40
+ }
41
+ }
42
+
43
+ /** Whether a value is a well-formed latest-turn bucket (or explicitly null). */
44
+ function isBucket(value: unknown): value is { turn: number; paths: readonly string[] } | null {
45
+ if (value === null) return true
46
+ if (typeof value !== 'object') return false
47
+ const bucket = value as { turn?: unknown; paths?: unknown }
48
+ return typeof bucket.turn === 'number' && Array.isArray(bucket.paths)
49
+ }
50
+
51
+ /**
52
+ * The path prefix that identifies a codeplan artifact. The codeplan skill
53
+ * writes its four fixed artifacts under `$workspace\.agents\plans\<任务名>\`;
54
+ * produced paths may spell the separators either way.
55
+ */
56
+ const CODEPLAN_SEGMENT = '.agents/plans/'
57
+
58
+ /**
59
+ * Normalize path separators so one spelling of the codeplan prefix matches.
60
+ * Only classification uses this; the displayed path keeps the tool's exact
61
+ * spelling.
62
+ */
63
+ function normalized(path: string): string {
64
+ return path.replaceAll('\\', '/')
65
+ }
66
+
67
+ /** Whether a produced path is a codeplan artifact. */
68
+ export function isCodeplanPath(path: string): boolean {
69
+ return normalized(path).includes(CODEPLAN_SEGMENT)
70
+ }
71
+
72
+ /**
73
+ * Split a codeplan path into its task name and file name.
74
+ *
75
+ * @param path - a produced path; need not actually be a codeplan artifact.
76
+ * @returns task and file names, or `null` when the path is not under the
77
+ * codeplan plans directory (or names no file beneath the task folder).
78
+ */
79
+ export function splitCodeplanPath(path: string): { task: string; file: string } | null {
80
+ const marker = normalized(path).indexOf(CODEPLAN_SEGMENT)
81
+ if (marker < 0) return null
82
+ const rest = normalized(path).slice(marker + CODEPLAN_SEGMENT.length)
83
+ const slash = rest.indexOf('/')
84
+ if (slash <= 0 || slash === rest.length - 1) return null
85
+ return { task: rest.slice(0, slash), file: rest.slice(slash + 1) }
86
+ }
87
+
88
+ /** The final path segment, under either separator. */
89
+ export function basename(path: string): string {
90
+ const normalizedPath = normalized(path)
91
+ const slash = normalizedPath.lastIndexOf('/')
92
+ return slash < 0 ? normalizedPath : normalizedPath.slice(slash + 1)
93
+ }