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.
- package/README.md +188 -178
- package/assets/.gitkeep +0 -0
- package/assets/brief.png +0 -0
- package/cordis.patch.yml +18 -18
- package/dsh.plugin.json +16 -16
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +18 -18
- package/lib/index.mjs.map +1 -1
- package/package.json +106 -94
- package/screenshots.json +5 -0
- package/src/client/BriefIcon.tsx +56 -56
- package/src/client/SummaryTab.tsx +73 -73
- package/src/client/TodoBoardTab.tsx +188 -188
- package/src/client/brief/board.ts +104 -104
- package/src/client/brief/dock-shadow.tsx +74 -74
- package/src/client/brief/use-todos.ts +27 -27
- package/src/client/file-open.ts +108 -108
- package/src/client/index.tsx +144 -144
- package/src/client/locales.ts +78 -78
- package/src/client/summary/DeliverablesSection.tsx +192 -192
- package/src/client/summary/deliverables.ts +93 -93
- package/src/client/use-projection.ts +117 -117
- package/src/index.ts +32 -32
- package/src/projection/deliverables-fold.ts +206 -206
- package/src/projection/keys.ts +27 -27
- package/src/projection/register.ts +99 -99
|
@@ -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
|
+
}
|