dsh-brief-sidebar 0.3.0 → 0.3.1

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,192 +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
- }
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
+ }
@@ -1,93 +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
- }
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
+ }