@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.
@@ -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
- .kb-board-actions { display: flex; gap: 8px; align-items: center; flex: none; }
28
- .kb-board-actions .kb-btn { flex: none; }
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: rgba(30, 30, 36, 0.95);
133
- border: 1px solid rgba(128, 128, 128, 0.4);
134
- color: #eee;
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: #1c1c22;
156
- border: 1px solid rgba(128, 128, 128, 0.35);
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: #e6e6e6;
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: #1c1c22;
172
- border: 1px solid rgba(128, 128, 128, 0.35);
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: #e6e6e6;
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
- .kb-plan-phases { margin: 0; padding-left: 20px; }
243
- .kb-plan-phase { margin-bottom: 10px; }
244
- .kb-plan-phase-current .kb-plan-phase-title { color: #60a5fa; }
245
- .kb-plan-phase-head { display: flex; gap: 8px; align-items: baseline; }
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-goal { font-size: 12px; opacity: 0.85; margin: 2px 0 6px; white-space: pre-wrap; }
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
- @media (prefers-color-scheme: light) {
284
- .kb-board { color: #1c1c22; background: #f5f5f7; }
285
- .kb-modal { background: #ffffff; color: #1c1c22; }
286
- .kb-detail-panel { background: #ffffff; color: #1c1c22; }
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
@@ -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
- function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError }: NewTaskModalProps): JSX.Element {
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.trim(), model, modelProvider)
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
- <textarea
337
- className="kb-input kb-textarea"
338
- value={requirement}
339
- placeholder={t('requirementPlaceholder')}
340
- onChange={(e) => setRequirement(e.target.value)}
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
- <li key={phase.id} className={'kb-plan-phase' + (i === card.currentPhase && card.status === 'running' ? ' kb-plan-phase-current' : '')}>
433
- <div className="kb-plan-phase-head">
434
- <span className="kb-plan-phase-title">{phase.title}</span>
435
- <span className="kb-plan-phase-id">{phase.id}</span>
436
- </div>
437
- <div className="kb-plan-phase-goal">{phase.goal}</div>
438
- <PhaseSessions phaseIndex={i} card={card} t={t} onOpenSession={onOpenSession} />
439
- </li>
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
- function PhaseSessions({ phaseIndex, card, t, onOpenSession }: {
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 | null {
636
+ }): JSX.Element {
502
637
  const attempts = card.sessions.phases.filter((a) => a.phaseIndex === phaseIndex)
503
- if (attempts.length === 0) return null
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-session-links">
686
+ <div className="kb-plan-phase-sessions">
506
687
  {attempts.map((a) => (
507
- <span key={a.sessionId} className="kb-session-attempt">
508
- <button type="button" className="kb-btn kb-btn-small" onClick={() => onOpenSession(a.sessionId)}>
509
- {t('openSession')} · {a.sessionId.slice(0, 13)}
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
  )