@fonlan/dsh-task-kanban 0.1.0 → 0.2.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.
- package/README.md +2 -1
- package/lib/client.js +330 -93
- package/lib/client.js.map +1 -1
- package/lib/index.js +271 -13
- package/lib/types/server/prompts.d.ts +7 -0
- package/lib/types/server/runner.d.ts +44 -0
- package/lib/types/server/skills.d.ts +71 -0
- package/lib/types/server/task-store.d.ts +1 -0
- package/lib/types/shared/card.d.ts +2 -0
- package/package.json +6 -1
- package/src/client/api.ts +43 -2
- package/src/client/board.css +126 -28
- package/src/client/board.tsx +210 -32
- package/src/client/kanban-state.ts +65 -23
- package/src/client/locales.ts +16 -2
- package/src/index.ts +1 -1
- package/src/server/prompts.ts +24 -0
- package/src/server/rpc.ts +2 -1
- package/src/server/runner.ts +214 -14
- package/src/server/skills.ts +105 -0
- package/src/server/task-store.ts +2 -0
- package/src/shared/card.ts +2 -0
package/src/client/board.css
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/* @fonlan/dsh-task-kanban board styles (kb- prefixed, plain CSS)
|
|
1
|
+
/* @fonlan/dsh-task-kanban board styles (kb- prefixed, plain CSS).
|
|
2
|
+
Surfaces use the DSH theme tokens (--dsw-alias-*) so they follow the
|
|
3
|
+
shell's selected light/dark appearance; the var() fallbacks cover shells
|
|
4
|
+
that do not define them. */
|
|
2
5
|
.kb-board {
|
|
3
6
|
height: 100%;
|
|
4
7
|
display: flex;
|
|
@@ -6,8 +9,8 @@
|
|
|
6
9
|
box-sizing: border-box;
|
|
7
10
|
padding: 12px 16px;
|
|
8
11
|
overflow: hidden;
|
|
9
|
-
color: var(--dsh-fg, #e6e6e6);
|
|
10
|
-
background: var(--dsh-bg, #141418);
|
|
12
|
+
color: var(--dsw-alias-label-primary, var(--dsh-fg, #e6e6e6));
|
|
13
|
+
background: var(--dsw-alias-bg-base, var(--dsh-bg, #141418));
|
|
11
14
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
12
15
|
}
|
|
13
16
|
.kb-empty {
|
|
@@ -18,14 +21,20 @@
|
|
|
18
21
|
.kb-board-header {
|
|
19
22
|
display: flex;
|
|
20
23
|
align-items: center;
|
|
21
|
-
justify-content: space-between;
|
|
22
24
|
gap: 12px;
|
|
23
25
|
padding-bottom: 10px;
|
|
24
26
|
border-bottom: 1px solid rgba(128, 128, 128, 0.25);
|
|
25
27
|
flex: 0 0 auto;
|
|
26
28
|
}
|
|
27
|
-
|
|
28
|
-
.kb-board-
|
|
29
|
+
/* Left-aligned group: workspace selector + new-task button sit adjacent. */
|
|
30
|
+
.kb-board-title {
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: 8px;
|
|
34
|
+
min-width: 0;
|
|
35
|
+
flex: 0 1 auto;
|
|
36
|
+
}
|
|
37
|
+
.kb-board-title .kb-btn { flex: none; }
|
|
29
38
|
.kb-ws-select {
|
|
30
39
|
max-width: 320px;
|
|
31
40
|
min-width: 0;
|
|
@@ -81,7 +90,7 @@
|
|
|
81
90
|
padding: 8px 10px;
|
|
82
91
|
font-weight: 600;
|
|
83
92
|
font-size: 13px;
|
|
84
|
-
color: var(--dsh-fg-soft, #b8b8c0);
|
|
93
|
+
color: var(--dsw-alias-label-secondary, var(--dsh-fg-soft, #b8b8c0));
|
|
85
94
|
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
|
86
95
|
flex: 0 0 auto;
|
|
87
96
|
}
|
|
@@ -121,6 +130,11 @@
|
|
|
121
130
|
.kb-card-running .kb-card-badge { background: rgba(59, 130, 246, 0.3); }
|
|
122
131
|
.kb-card-error .kb-card-badge { background: rgba(239, 68, 68, 0.3); }
|
|
123
132
|
.kb-card-phase { opacity: 0.8; }
|
|
133
|
+
.kb-card-skill {
|
|
134
|
+
background: rgba(139, 92, 246, 0.22);
|
|
135
|
+
border-radius: 4px;
|
|
136
|
+
padding: 1px 6px;
|
|
137
|
+
}
|
|
124
138
|
.kb-card-model { opacity: 0.55; }
|
|
125
139
|
.kb-card-error-text { margin-top: 6px; font-size: 12px; color: #f87171; word-break: break-all; }
|
|
126
140
|
.kb-card-worktree { margin-top: 4px; font-size: 11px; opacity: 0.5; word-break: break-all; }
|
|
@@ -129,9 +143,9 @@
|
|
|
129
143
|
left: 50%;
|
|
130
144
|
bottom: 24px;
|
|
131
145
|
transform: translateX(-50%);
|
|
132
|
-
background:
|
|
133
|
-
border: 1px solid
|
|
134
|
-
color:
|
|
146
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
147
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
148
|
+
color: var(--dsw-alias-label-primary);
|
|
135
149
|
padding: 8px 16px;
|
|
136
150
|
border-radius: 8px;
|
|
137
151
|
font-size: 13px;
|
|
@@ -152,11 +166,11 @@
|
|
|
152
166
|
max-width: 90vw;
|
|
153
167
|
max-height: 85vh;
|
|
154
168
|
overflow-y: auto;
|
|
155
|
-
background:
|
|
156
|
-
border: 1px solid
|
|
169
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
170
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
157
171
|
border-radius: 12px;
|
|
158
172
|
padding: 18px;
|
|
159
|
-
color:
|
|
173
|
+
color: var(--dsw-alias-label-primary);
|
|
160
174
|
}
|
|
161
175
|
.kb-modal-wide { width: 640px; }
|
|
162
176
|
/* Task-detail bottom panel: docks at the bottom of the board page, fills the
|
|
@@ -168,11 +182,11 @@
|
|
|
168
182
|
display: flex;
|
|
169
183
|
flex-direction: column;
|
|
170
184
|
margin-top: 10px;
|
|
171
|
-
background:
|
|
172
|
-
border: 1px solid
|
|
185
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
186
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
173
187
|
border-radius: 12px;
|
|
174
188
|
overflow: hidden;
|
|
175
|
-
color:
|
|
189
|
+
color: var(--dsw-alias-label-primary);
|
|
176
190
|
}
|
|
177
191
|
.kb-detail-panel-head {
|
|
178
192
|
display: flex;
|
|
@@ -210,6 +224,53 @@
|
|
|
210
224
|
.kb-modal-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; gap: 10px; }
|
|
211
225
|
.kb-field { display: block; margin-bottom: 12px; }
|
|
212
226
|
.kb-field > span { display: block; font-size: 12px; opacity: 0.75; margin-bottom: 4px; }
|
|
227
|
+
.kb-field-hint {
|
|
228
|
+
display: block;
|
|
229
|
+
font-size: 11px;
|
|
230
|
+
opacity: 0.55;
|
|
231
|
+
margin-top: 4px;
|
|
232
|
+
}
|
|
233
|
+
.kb-field-hint-skill {
|
|
234
|
+
opacity: 0.9;
|
|
235
|
+
color: #a78bfa;
|
|
236
|
+
}
|
|
237
|
+
.kb-skill-menu-wrap { position: relative; }
|
|
238
|
+
.kb-skill-menu {
|
|
239
|
+
position: absolute;
|
|
240
|
+
top: 100%;
|
|
241
|
+
left: 0;
|
|
242
|
+
right: 0;
|
|
243
|
+
z-index: 20;
|
|
244
|
+
margin: 2px 0 0;
|
|
245
|
+
padding: 4px;
|
|
246
|
+
list-style: none;
|
|
247
|
+
max-height: 180px;
|
|
248
|
+
overflow-y: auto;
|
|
249
|
+
border-radius: 8px;
|
|
250
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
251
|
+
background: var(--dsw-alias-bg-overlay);
|
|
252
|
+
color: var(--dsw-alias-label-primary);
|
|
253
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
|
|
254
|
+
}
|
|
255
|
+
.kb-skill-menu-item {
|
|
256
|
+
display: flex;
|
|
257
|
+
flex-direction: column;
|
|
258
|
+
gap: 2px;
|
|
259
|
+
padding: 6px 8px;
|
|
260
|
+
border-radius: 6px;
|
|
261
|
+
cursor: pointer;
|
|
262
|
+
font-size: 12px;
|
|
263
|
+
color: var(--dsw-alias-label-primary);
|
|
264
|
+
}
|
|
265
|
+
.kb-skill-menu-item-active { background: var(--dsw-alias-interactive-bg-hover); }
|
|
266
|
+
.kb-skill-menu-name { font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
267
|
+
.kb-skill-menu-desc {
|
|
268
|
+
color: var(--dsw-alias-label-tertiary);
|
|
269
|
+
font-size: 11px;
|
|
270
|
+
white-space: nowrap;
|
|
271
|
+
overflow: hidden;
|
|
272
|
+
text-overflow: ellipsis;
|
|
273
|
+
}
|
|
213
274
|
.kb-field-row { display: flex; align-items: center; gap: 10px; justify-content: space-between; }
|
|
214
275
|
.kb-input {
|
|
215
276
|
width: 100%;
|
|
@@ -226,6 +287,14 @@
|
|
|
226
287
|
.kb-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
227
288
|
.kb-detail-section { margin-bottom: 12px; }
|
|
228
289
|
.kb-detail-label { font-size: 12px; opacity: 0.65; margin-bottom: 4px; }
|
|
290
|
+
.kb-detail-skill {
|
|
291
|
+
display: inline-block;
|
|
292
|
+
font-size: 11px;
|
|
293
|
+
background: rgba(139, 92, 246, 0.22);
|
|
294
|
+
border-radius: 4px;
|
|
295
|
+
padding: 1px 6px;
|
|
296
|
+
margin-bottom: 4px;
|
|
297
|
+
}
|
|
229
298
|
.kb-detail-text { white-space: pre-wrap; word-break: break-all; font-size: 13px; }
|
|
230
299
|
.kb-detail-error .kb-detail-text { color: #f87171; }
|
|
231
300
|
.kb-detail-status {
|
|
@@ -239,16 +308,46 @@
|
|
|
239
308
|
.kb-status-error { background: rgba(239, 68, 68, 0.3) !important; color: #f87171; }
|
|
240
309
|
.kb-status-merged { background: rgba(34, 197, 94, 0.3) !important; }
|
|
241
310
|
.kb-plan-summary { font-size: 13px; margin-bottom: 10px; }
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
.kb-plan-
|
|
311
|
+
/* Phase cards: each plan phase is a card with an ordered number (kept from the
|
|
312
|
+
parent <ol>) and three labeled sections (goal / sessions / conclusion) split
|
|
313
|
+
by section titles and thin divider lines. */
|
|
314
|
+
.kb-plan-phases { margin: 0; padding-left: 24px; }
|
|
315
|
+
.kb-plan-phase {
|
|
316
|
+
margin-bottom: 10px;
|
|
317
|
+
padding: 8px 10px;
|
|
318
|
+
background: rgba(128, 128, 128, 0.08);
|
|
319
|
+
border: 1px solid rgba(128, 128, 128, 0.2);
|
|
320
|
+
border-radius: 8px;
|
|
321
|
+
}
|
|
322
|
+
.kb-plan-phase-head {
|
|
323
|
+
display: flex;
|
|
324
|
+
gap: 8px;
|
|
325
|
+
align-items: baseline;
|
|
326
|
+
padding-bottom: 6px;
|
|
327
|
+
margin-bottom: 6px;
|
|
328
|
+
border-bottom: 1px solid rgba(128, 128, 128, 0.16);
|
|
329
|
+
}
|
|
246
330
|
.kb-plan-phase-title { font-weight: 600; font-size: 13px; }
|
|
247
331
|
.kb-plan-phase-id { font-size: 11px; opacity: 0.5; }
|
|
248
|
-
.kb-plan-phase-
|
|
332
|
+
.kb-plan-phase-body { display: flex; flex-direction: column; }
|
|
333
|
+
/* Section isolation: label on top, thin divider between adjacent sections. */
|
|
334
|
+
.kb-plan-phase-section { display: flex; flex-direction: column; }
|
|
335
|
+
.kb-plan-phase-section + .kb-plan-phase-section {
|
|
336
|
+
margin-top: 6px;
|
|
337
|
+
padding-top: 6px;
|
|
338
|
+
border-top: 1px solid rgba(128, 128, 128, 0.14);
|
|
339
|
+
}
|
|
340
|
+
.kb-plan-phase-section-title { font-size: 11px; opacity: 0.55; margin-bottom: 3px; }
|
|
341
|
+
.kb-plan-phase-goal { font-size: 12px; opacity: 0.85; white-space: pre-wrap; word-break: break-all; }
|
|
342
|
+
.kb-plan-phase-sessions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
343
|
+
.kb-plan-phase-conclusion { font-size: 12px; opacity: 0.85; white-space: pre-wrap; word-break: break-all; }
|
|
344
|
+
.kb-plan-phase-conclusion-item + .kb-plan-phase-conclusion-item { margin-top: 6px; }
|
|
345
|
+
/* Running phase keeps the blue highlight; completed phases get success color. */
|
|
346
|
+
.kb-plan-phase-current { border-color: rgba(59, 130, 246, 0.6); background: rgba(59, 130, 246, 0.08); }
|
|
347
|
+
.kb-plan-phase-current .kb-plan-phase-title { color: #60a5fa; }
|
|
348
|
+
.kb-plan-phase-completed { border-color: rgba(34, 197, 94, 0.45); background: rgba(34, 197, 94, 0.06); }
|
|
349
|
+
.kb-plan-phase-completed .kb-plan-phase-title { color: #4ade80; }
|
|
249
350
|
.kb-session-links { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
250
|
-
.kb-session-attempt { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
|
251
|
-
.kb-session-summary { font-size: 12px; opacity: 0.75; }
|
|
252
351
|
.kb-settings { display: flex; flex-direction: column; gap: 12px; max-width: 420px; }
|
|
253
352
|
.kb-settings-saved { font-size: 12px; color: #4ade80; }
|
|
254
353
|
.kb-footer-action {
|
|
@@ -280,11 +379,10 @@
|
|
|
280
379
|
font-size: 14px;
|
|
281
380
|
line-height: 22px;
|
|
282
381
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
382
|
+
/* Theme following is handled by the --dsw-alias-* tokens above: they change
|
|
383
|
+
value with the shell's selected light/dark appearance, so no OS-level
|
|
384
|
+
prefers-color-scheme override is needed (and one would fight the app
|
|
385
|
+
theme when the two disagree). */
|
|
288
386
|
/* Same-line placement WITHOUT moving any DOM: the sidebar foot area becomes
|
|
289
387
|
a reversed flex row, so its LAST child (the settings area) renders on the
|
|
290
388
|
LEFT and the FIRST child (footer actions = the kanban button) on the RIGHT
|
package/src/client/board.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
-
import type { KanbanCard, Lane } from '../shared/card'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { KanbanCard, Lane, PhaseAttempt, PlanPhase } from '../shared/card'
|
|
3
3
|
import { DROP_RULES, laneOf } from '../shared/lanes'
|
|
4
|
-
import { api } from './api'
|
|
4
|
+
import { api, gatewaySkillList, type SkillOption } from './api'
|
|
5
5
|
import { exitBoard, getClient } from './kanban-state'
|
|
6
6
|
import { en, zh } from './locales'
|
|
7
7
|
import './board.css'
|
|
@@ -9,12 +9,28 @@ import './board.css'
|
|
|
9
9
|
interface BoardProps {
|
|
10
10
|
t?: (key: string, params?: Record<string, unknown>) => string
|
|
11
11
|
useWorkspaces?: (selector: (state: unknown) => unknown) => unknown
|
|
12
|
+
useSessions?: (selector: (state: unknown) => unknown) => unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Session-list snapshot shape the standard `useSessions` feed carries. */
|
|
16
|
+
interface SessionListStateLike {
|
|
17
|
+
current?: string
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
function firstLine(text: string): string {
|
|
15
21
|
return text.split('\n').find((l) => l.trim() !== '')?.trim() ?? ''
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
/** The leading `/skill-name` gesture at the start of the requirement input. */
|
|
25
|
+
const SKILL_GESTURE = /^\s*\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/
|
|
26
|
+
|
|
27
|
+
/** Extract a leading `/skill-name` token plus the cleaned remainder. */
|
|
28
|
+
function parseSkillGesture(raw: string): { skill?: string; requirement: string } {
|
|
29
|
+
const m = SKILL_GESTURE.exec(raw)
|
|
30
|
+
if (m === null) return { requirement: raw.trim() }
|
|
31
|
+
return { skill: m[1], requirement: raw.slice(m[0].length).trim() }
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
function statusLabel(card: KanbanCard, t: (key: string) => string): string {
|
|
19
35
|
if (card.status === 'error') {
|
|
20
36
|
switch (card.error?.kind) {
|
|
@@ -64,6 +80,9 @@ export function BoardRoot(props: BoardProps): JSX.Element {
|
|
|
64
80
|
const useWorkspaces = props.useWorkspaces
|
|
65
81
|
const wsState = useWorkspaces !== undefined ? (useWorkspaces((s: unknown) => s) as { items?: WorkspaceRow[] } | undefined) : undefined
|
|
66
82
|
const items: WorkspaceRow[] = wsState?.items ?? []
|
|
83
|
+
const useSessions = props.useSessions
|
|
84
|
+
const sessionState = useSessions !== undefined ? (useSessions((s: unknown) => s) as SessionListStateLike | undefined) : undefined
|
|
85
|
+
const currentSessionId = sessionState?.current
|
|
67
86
|
|
|
68
87
|
const [selectedPath, setSelectedPath] = useState<string>(() => {
|
|
69
88
|
try {
|
|
@@ -170,8 +189,6 @@ export function BoardRoot(props: BoardProps): JSX.Element {
|
|
|
170
189
|
<option key={w.workspaceId} value={w.path}>{w.title}</option>
|
|
171
190
|
))}
|
|
172
191
|
</select>
|
|
173
|
-
</div>
|
|
174
|
-
<div className="kb-board-actions">
|
|
175
192
|
<button type="button" className="kb-btn kb-btn-primary" onClick={() => setNewTaskOpen(true)}>{t('newTask')}</button>
|
|
176
193
|
</div>
|
|
177
194
|
</div>
|
|
@@ -211,6 +228,7 @@ export function BoardRoot(props: BoardProps): JSX.Element {
|
|
|
211
228
|
t={t}
|
|
212
229
|
workspaces={items}
|
|
213
230
|
selectedPath={selectedPath}
|
|
231
|
+
sessionId={currentSessionId}
|
|
214
232
|
onClose={() => setNewTaskOpen(false)}
|
|
215
233
|
onCreated={() => {
|
|
216
234
|
setNewTaskOpen(false)
|
|
@@ -264,6 +282,7 @@ function CardView({ card, t, onToggle }: CardViewProps): JSX.Element {
|
|
|
264
282
|
<div className="kb-card-meta">
|
|
265
283
|
<span className="kb-card-badge">{badge}</span>
|
|
266
284
|
{phaseInfo !== '' && <span className="kb-card-phase">{phaseInfo}</span>}
|
|
285
|
+
{card.skill !== undefined && card.skill !== '' && <span className="kb-card-skill">/ {card.skill}</span>}
|
|
267
286
|
{card.model !== '' && <span className="kb-card-model">{card.model}</span>}
|
|
268
287
|
</div>
|
|
269
288
|
{card.status === 'error' && card.error !== undefined && (
|
|
@@ -280,18 +299,27 @@ interface NewTaskModalProps {
|
|
|
280
299
|
t: (key: string, params?: Record<string, unknown>) => string
|
|
281
300
|
workspaces: WorkspaceRow[]
|
|
282
301
|
selectedPath: string
|
|
302
|
+
/** Current session id, scoping the host skill.list catalog lookup. */
|
|
303
|
+
sessionId?: string
|
|
283
304
|
onClose: () => void
|
|
284
305
|
onCreated: () => void
|
|
285
306
|
onError: (message: string) => void
|
|
286
307
|
}
|
|
287
308
|
|
|
288
|
-
|
|
309
|
+
/** An in-progress leading `/query` token (menu-open state). */
|
|
310
|
+
const SKILL_MENU_RE = /^\s*\/([a-z0-9-]*)$/
|
|
311
|
+
|
|
312
|
+
function NewTaskModal({ t, workspaces, selectedPath, sessionId, onClose, onCreated, onError }: NewTaskModalProps): JSX.Element {
|
|
289
313
|
const [requirement, setRequirement] = useState('')
|
|
290
314
|
const [project, setProject] = useState(selectedPath)
|
|
291
315
|
const [model, setModel] = useState('')
|
|
292
316
|
const [modelProvider, setModelProvider] = useState('')
|
|
293
317
|
const [models, setModels] = useState<Array<{ provider: string; id: string; name?: string }>>([])
|
|
318
|
+
const [skills, setSkills] = useState<SkillOption[]>([])
|
|
319
|
+
const [menuQuery, setMenuQuery] = useState<string | null>(null)
|
|
320
|
+
const [menuIndex, setMenuIndex] = useState(0)
|
|
294
321
|
const [busy, setBusy] = useState(false)
|
|
322
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
|
295
323
|
|
|
296
324
|
useEffect(() => {
|
|
297
325
|
let alive = true
|
|
@@ -308,6 +336,73 @@ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError
|
|
|
308
336
|
return () => { alive = false }
|
|
309
337
|
}, [])
|
|
310
338
|
|
|
339
|
+
// Load the host skill catalog once (scoped to the current session), the
|
|
340
|
+
// same feed DSH's chat input uses for its /-autocomplete.
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (sessionId === undefined) return
|
|
343
|
+
let alive = true
|
|
344
|
+
gatewaySkillList(sessionId)
|
|
345
|
+
.then((list) => { if (alive) setSkills(list) })
|
|
346
|
+
.catch(() => { if (alive) setSkills([]) })
|
|
347
|
+
return () => { alive = false }
|
|
348
|
+
}, [sessionId])
|
|
349
|
+
|
|
350
|
+
const matches = skills.filter((s) => s.name.startsWith(menuQuery ?? ''))
|
|
351
|
+
const menuVisible = menuQuery !== null && matches.length > 0
|
|
352
|
+
const selected = menuVisible ? matches[Math.min(menuIndex, matches.length - 1)] : undefined
|
|
353
|
+
|
|
354
|
+
/** Re-evaluate the leading-token menu state from the current value + caret. */
|
|
355
|
+
const syncMenu = (value: string, caret: number): void => {
|
|
356
|
+
const head = value.slice(0, caret)
|
|
357
|
+
const m = SKILL_MENU_RE.exec(head)
|
|
358
|
+
if (m !== null) {
|
|
359
|
+
setMenuQuery(m[1])
|
|
360
|
+
setMenuIndex(0)
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
setMenuQuery(null)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const selectSkill = (name: string): void => {
|
|
367
|
+
const el = textareaRef.current
|
|
368
|
+
const caret = el?.selectionStart ?? requirement.length
|
|
369
|
+
const head = requirement.slice(0, caret)
|
|
370
|
+
const m = SKILL_MENU_RE.exec(head)
|
|
371
|
+
// Replace the leading `/query` token with `/name `.
|
|
372
|
+
const inserted = `/${name} `
|
|
373
|
+
const newValue = m !== null ? inserted + requirement.slice(caret) : inserted + requirement
|
|
374
|
+
setRequirement(newValue)
|
|
375
|
+
setMenuQuery(null)
|
|
376
|
+
requestAnimationFrame(() => {
|
|
377
|
+
const next = textareaRef.current
|
|
378
|
+
if (next !== null) {
|
|
379
|
+
next.focus()
|
|
380
|
+
const pos = inserted.length
|
|
381
|
+
next.setSelectionRange(pos, pos)
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
387
|
+
if (!menuVisible) return
|
|
388
|
+
if (e.key === 'ArrowDown') {
|
|
389
|
+
e.preventDefault()
|
|
390
|
+
setMenuIndex((i) => (i + 1) % matches.length)
|
|
391
|
+
} else if (e.key === 'ArrowUp') {
|
|
392
|
+
e.preventDefault()
|
|
393
|
+
setMenuIndex((i) => (i - 1 + matches.length) % matches.length)
|
|
394
|
+
} else if (e.key === 'Enter') {
|
|
395
|
+
e.preventDefault()
|
|
396
|
+
if (selected !== undefined) selectSkill(selected.name)
|
|
397
|
+
} else if (e.key === 'Escape') {
|
|
398
|
+
e.preventDefault()
|
|
399
|
+
setMenuQuery(null)
|
|
400
|
+
} else if (e.key === 'Tab') {
|
|
401
|
+
e.preventDefault()
|
|
402
|
+
if (selected !== undefined) selectSkill(selected.name)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
311
406
|
const submit = async (): Promise<void> => {
|
|
312
407
|
if (requirement.trim() === '') {
|
|
313
408
|
onError(t('requirementLabel') + ' ' + t('error'))
|
|
@@ -317,9 +412,10 @@ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError
|
|
|
317
412
|
onError(t('noWorkspace'))
|
|
318
413
|
return
|
|
319
414
|
}
|
|
415
|
+
const parsed = parseSkillGesture(requirement)
|
|
320
416
|
setBusy(true)
|
|
321
417
|
try {
|
|
322
|
-
await api.create(project, requirement
|
|
418
|
+
await api.create(project, parsed.requirement, model, modelProvider, parsed.skill)
|
|
323
419
|
onCreated()
|
|
324
420
|
} catch (error) {
|
|
325
421
|
onError(error instanceof Error ? error.message : String(error))
|
|
@@ -327,18 +423,52 @@ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError
|
|
|
327
423
|
}
|
|
328
424
|
}
|
|
329
425
|
|
|
426
|
+
const activeSkill = parseSkillGesture(requirement).skill
|
|
427
|
+
|
|
330
428
|
return (
|
|
331
429
|
<div className="kb-modal-backdrop" onClick={busy ? undefined : onClose}>
|
|
332
430
|
<div className="kb-modal" onClick={(e) => e.stopPropagation()}>
|
|
333
431
|
<div className="kb-modal-title">{t('newTask')}</div>
|
|
334
432
|
<label className="kb-field">
|
|
335
433
|
<span>{t('requirementLabel')}</span>
|
|
336
|
-
<
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
434
|
+
<div className="kb-skill-menu-wrap">
|
|
435
|
+
<textarea
|
|
436
|
+
ref={textareaRef}
|
|
437
|
+
className="kb-input kb-textarea"
|
|
438
|
+
value={requirement}
|
|
439
|
+
placeholder={t('requirementPlaceholder')}
|
|
440
|
+
onChange={(e) => {
|
|
441
|
+
const value = e.target.value
|
|
442
|
+
setRequirement(value)
|
|
443
|
+
syncMenu(value, e.target.selectionStart ?? value.length)
|
|
444
|
+
}}
|
|
445
|
+
onKeyDown={onKeyDown}
|
|
446
|
+
/>
|
|
447
|
+
{menuVisible && (
|
|
448
|
+
<ul className="kb-skill-menu" role="listbox">
|
|
449
|
+
{matches.map((s, i) => (
|
|
450
|
+
<li
|
|
451
|
+
key={s.name}
|
|
452
|
+
role="option"
|
|
453
|
+
aria-selected={i === menuIndex}
|
|
454
|
+
className={'kb-skill-menu-item' + (i === menuIndex ? ' kb-skill-menu-item-active' : '')}
|
|
455
|
+
onMouseDown={(e) => { e.preventDefault(); selectSkill(s.name) }}
|
|
456
|
+
onMouseEnter={() => setMenuIndex(i)}
|
|
457
|
+
>
|
|
458
|
+
<span className="kb-skill-menu-name">/{s.name}</span>
|
|
459
|
+
<span className="kb-skill-menu-desc">
|
|
460
|
+
{s.modelInvocable ? s.description : `${t('skillMenuUserOnly')} · ${s.description}`}
|
|
461
|
+
</span>
|
|
462
|
+
</li>
|
|
463
|
+
))}
|
|
464
|
+
</ul>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
{activeSkill !== undefined ? (
|
|
468
|
+
<span className="kb-field-hint kb-field-hint-skill">{t('skillHint', { skill: activeSkill })}</span>
|
|
469
|
+
) : (
|
|
470
|
+
<span className="kb-field-hint">{t('skillGestureHint')}</span>
|
|
471
|
+
)}
|
|
342
472
|
</label>
|
|
343
473
|
<label className="kb-field">
|
|
344
474
|
<span>{t('project')}</span>
|
|
@@ -419,6 +549,9 @@ function DetailPanel({ card, t, onClose, onChanged, onToast, onOpenSession }: De
|
|
|
419
549
|
<div className="kb-detail-panel-body">
|
|
420
550
|
<div className="kb-detail-section">
|
|
421
551
|
<div className="kb-detail-label">{t('requirement')}</div>
|
|
552
|
+
{card.skill !== undefined && card.skill !== '' && (
|
|
553
|
+
<div className="kb-detail-skill">{t('skillBadge', { skill: card.skill })}</div>
|
|
554
|
+
)}
|
|
422
555
|
<div className="kb-detail-text">{card.requirement}</div>
|
|
423
556
|
</div>
|
|
424
557
|
|
|
@@ -429,14 +562,14 @@ function DetailPanel({ card, t, onClose, onChanged, onToast, onOpenSession }: De
|
|
|
429
562
|
<div className="kb-plan-summary">{card.plan.summary}</div>
|
|
430
563
|
<ol className="kb-plan-phases">
|
|
431
564
|
{card.plan.phases.map((phase, i) => (
|
|
432
|
-
<
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
565
|
+
<PhaseCard
|
|
566
|
+
key={phase.id}
|
|
567
|
+
phase={phase}
|
|
568
|
+
phaseIndex={i}
|
|
569
|
+
card={card}
|
|
570
|
+
t={t}
|
|
571
|
+
onOpenSession={onOpenSession}
|
|
572
|
+
/>
|
|
440
573
|
))}
|
|
441
574
|
</ol>
|
|
442
575
|
</div>
|
|
@@ -493,23 +626,68 @@ function DetailPanel({ card, t, onClose, onChanged, onToast, onOpenSession }: De
|
|
|
493
626
|
)
|
|
494
627
|
}
|
|
495
628
|
|
|
496
|
-
|
|
629
|
+
/** One plan phase rendered as a card with goal / sessions / conclusion sections. */
|
|
630
|
+
function PhaseCard({ phase, phaseIndex, card, t, onOpenSession }: {
|
|
631
|
+
phase: PlanPhase
|
|
497
632
|
phaseIndex: number
|
|
498
633
|
card: KanbanCard
|
|
499
|
-
t: (key: string) => string
|
|
634
|
+
t: (key: string, params?: Record<string, unknown>) => string
|
|
500
635
|
onOpenSession: (sessionId: string) => void
|
|
501
|
-
}): JSX.Element
|
|
636
|
+
}): JSX.Element {
|
|
502
637
|
const attempts = card.sessions.phases.filter((a) => a.phaseIndex === phaseIndex)
|
|
503
|
-
|
|
638
|
+
const summaries = attempts
|
|
639
|
+
.filter((a) => a.summary !== undefined && a.summary.trim() !== '')
|
|
640
|
+
.map((a) => a.summary as string)
|
|
641
|
+
const isCurrent = phaseIndex === card.currentPhase && card.status === 'running'
|
|
642
|
+
// A phase with at least one concluded attempt counts as completed.
|
|
643
|
+
const isCompleted = summaries.length > 0
|
|
644
|
+
const cls = 'kb-plan-phase'
|
|
645
|
+
+ (isCurrent ? ' kb-plan-phase-current' : '')
|
|
646
|
+
+ (isCompleted ? ' kb-plan-phase-completed' : '')
|
|
647
|
+
return (
|
|
648
|
+
<li className={cls}>
|
|
649
|
+
<div className="kb-plan-phase-head">
|
|
650
|
+
<span className="kb-plan-phase-title">{phase.title}</span>
|
|
651
|
+
<span className="kb-plan-phase-id">{phase.id}</span>
|
|
652
|
+
</div>
|
|
653
|
+
<div className="kb-plan-phase-body">
|
|
654
|
+
<div className="kb-plan-phase-section">
|
|
655
|
+
<div className="kb-plan-phase-section-title">{t('phaseGoal')}</div>
|
|
656
|
+
<div className="kb-plan-phase-goal">{phase.goal}</div>
|
|
657
|
+
</div>
|
|
658
|
+
{attempts.length > 0 && (
|
|
659
|
+
<div className="kb-plan-phase-section">
|
|
660
|
+
<div className="kb-plan-phase-section-title">{t('phaseSessions')}</div>
|
|
661
|
+
<PhaseSessions attempts={attempts} t={t} onOpenSession={onOpenSession} />
|
|
662
|
+
</div>
|
|
663
|
+
)}
|
|
664
|
+
{summaries.length > 0 && (
|
|
665
|
+
<div className="kb-plan-phase-section">
|
|
666
|
+
<div className="kb-plan-phase-section-title">{t('phaseConclusion')}</div>
|
|
667
|
+
<div className="kb-plan-phase-conclusion">
|
|
668
|
+
{summaries.map((s, idx) => (
|
|
669
|
+
<div key={idx} className="kb-plan-phase-conclusion-item">{s}</div>
|
|
670
|
+
))}
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
</li>
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Open-session buttons for one phase's attempts (retries append more). */
|
|
680
|
+
function PhaseSessions({ attempts, t, onOpenSession }: {
|
|
681
|
+
attempts: PhaseAttempt[]
|
|
682
|
+
t: (key: string) => string
|
|
683
|
+
onOpenSession: (sessionId: string) => void
|
|
684
|
+
}): JSX.Element {
|
|
504
685
|
return (
|
|
505
|
-
<div className="kb-
|
|
686
|
+
<div className="kb-plan-phase-sessions">
|
|
506
687
|
{attempts.map((a) => (
|
|
507
|
-
<
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
</button>
|
|
511
|
-
{a.summary !== undefined && <span className="kb-session-summary">{a.summary}</span>}
|
|
512
|
-
</span>
|
|
688
|
+
<button key={a.sessionId} type="button" className="kb-btn kb-btn-small" onClick={() => onOpenSession(a.sessionId)}>
|
|
689
|
+
{t('openSession')} · {a.sessionId.slice(0, 13)}
|
|
690
|
+
</button>
|
|
513
691
|
))}
|
|
514
692
|
</div>
|
|
515
693
|
)
|