dsh-taskboard 0.5.5 → 0.6.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 +22 -2
- package/lib/client.js +2604 -767
- package/lib/host/execution.js +3 -0
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +31 -1
- package/lib/host/routes.js.map +1 -1
- package/lib/host/session-sync.js +210 -10
- package/lib/host/session-sync.js.map +1 -1
- package/lib/host/store.js +9 -2
- package/lib/host/store.js.map +1 -1
- package/lib/index.js +100 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +19 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +75 -75
- package/src/client/api.ts +8 -0
- package/src/client/board/AlertModal.tsx +3 -1
- package/src/client/board/ImportModal.tsx +26 -24
- package/src/client/board/SettingsModal.tsx +66 -26
- package/src/client/board/SlashPromptInput.tsx +272 -0
- package/src/client/board/TaskBoard.tsx +53 -49
- package/src/client/board/TaskCard.tsx +33 -21
- package/src/client/board/TaskDetail.tsx +169 -104
- package/src/client/board/TaskFormModal.tsx +254 -202
- package/src/client/board/TemplateManager.tsx +32 -29
- package/src/client/board/labels.ts +36 -27
- package/src/client/controller.ts +62 -2
- package/src/client/i18n/en.ts +455 -0
- package/src/client/i18n/runtime.ts +155 -0
- package/src/client/i18n/zh.ts +460 -0
- package/src/client/index.ts +182 -42
- package/src/client/sidebar-entry.ts +13 -3
- package/src/client/styles.ts +131 -0
- package/src/host/execution.ts +13 -0
- package/src/host/routes.ts +49 -1
- package/src/host/session-sync.ts +334 -14
- package/src/host/store.ts +15 -1
- package/src/index.ts +115 -1
- package/src/shared/api.ts +47 -0
- package/src/shared/protocol.ts +41 -0
- package/src/shared/version.ts +1 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlashPromptInput: Rich text input component for task description & execution prompt.
|
|
3
|
+
* Features:
|
|
4
|
+
* - Slash autocomplete popup for commands and skills with keyboard navigation.
|
|
5
|
+
* - Clean text editing without image base64 pollution.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-taskboard/client/board/SlashPromptInput
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
|
10
|
+
import type { BoardController } from '../controller.ts'
|
|
11
|
+
import type { PromptCompletionItem } from '../../shared/api.ts'
|
|
12
|
+
import { useT, type Translate } from '../i18n/runtime.ts'
|
|
13
|
+
|
|
14
|
+
/** Default built-in slash commands (descriptions resolve through t at render,
|
|
15
|
+
* so they follow the GUI language live; host-provided items override by name). */
|
|
16
|
+
export const defaultCommands = (t: Translate): PromptCompletionItem[] => [
|
|
17
|
+
{ name: 'goal', kind: 'command', description: t('slash.cmd.goal.desc'), hint: t('slash.cmd.goal.hint') },
|
|
18
|
+
{ name: 'schedule', kind: 'command', description: t('slash.cmd.schedule.desc'), hint: t('slash.cmd.schedule.hint') },
|
|
19
|
+
{ name: 'plan', kind: 'command', description: t('slash.cmd.plan.desc') },
|
|
20
|
+
{ name: 'browser', kind: 'command', description: t('slash.cmd.browser.desc') },
|
|
21
|
+
{ name: 'grill-me', kind: 'command', description: t('slash.cmd.grill-me.desc') },
|
|
22
|
+
{ name: 'teamwork-preview', kind: 'command', description: t('slash.cmd.teamwork-preview.desc') },
|
|
23
|
+
{ name: 'learn', kind: 'command', description: t('slash.cmd.learn.desc') },
|
|
24
|
+
{ name: 'review', kind: 'command', description: t('slash.cmd.review.desc') },
|
|
25
|
+
{ name: 'security', kind: 'command', description: t('slash.cmd.security.desc') },
|
|
26
|
+
{ name: 'permission', kind: 'command', description: t('slash.cmd.permission.desc'), hint: t('slash.cmd.permission.hint') },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/** Default built-in skills (descriptions resolve through t at render). */
|
|
30
|
+
export const defaultSkills = (t: Translate): PromptCompletionItem[] => [
|
|
31
|
+
{ name: 'frontend-ui-engineering', kind: 'skill', description: t('slash.skill.frontend-ui-engineering') },
|
|
32
|
+
{ name: 'api-and-interface-design', kind: 'skill', description: t('slash.skill.api-and-interface-design') },
|
|
33
|
+
{ name: 'test-driven-development', kind: 'skill', description: t('slash.skill.test-driven-development') },
|
|
34
|
+
{ name: 'debugging-and-error-recovery', kind: 'skill', description: t('slash.skill.debugging-and-error-recovery') },
|
|
35
|
+
{ name: 'performance-optimization', kind: 'skill', description: t('slash.skill.performance-optimization') },
|
|
36
|
+
{ name: 'ci-cd-and-automation', kind: 'skill', description: t('slash.skill.ci-cd-and-automation') },
|
|
37
|
+
{ name: 'code-review-and-quality', kind: 'skill', description: t('slash.skill.code-review-and-quality') },
|
|
38
|
+
{ name: 'code-simplification', kind: 'skill', description: t('slash.skill.code-simplification') },
|
|
39
|
+
{ name: 'context-engineering', kind: 'skill', description: t('slash.skill.context-engineering') },
|
|
40
|
+
{ name: 'doubt-driven-development', kind: 'skill', description: t('slash.skill.doubt-driven-development') },
|
|
41
|
+
{ name: 'git-workflow-and-versioning', kind: 'skill', description: t('slash.skill.git-workflow-and-versioning') },
|
|
42
|
+
{ name: 'idea-refine', kind: 'skill', description: t('slash.skill.idea-refine') },
|
|
43
|
+
{ name: 'incremental-implementation', kind: 'skill', description: t('slash.skill.incremental-implementation') },
|
|
44
|
+
{ name: 'interview-me', kind: 'skill', description: t('slash.skill.interview-me') },
|
|
45
|
+
{ name: 'memory-leak-debugging', kind: 'skill', description: t('slash.skill.memory-leak-debugging') },
|
|
46
|
+
{ name: 'observability-and-instrumentation', kind: 'skill', description: t('slash.skill.observability-and-instrumentation') },
|
|
47
|
+
{ name: 'planning-and-task-breakdown', kind: 'skill', description: t('slash.skill.planning-and-task-breakdown') },
|
|
48
|
+
{ name: 'security-and-hardening', kind: 'skill', description: t('slash.skill.security-and-hardening') },
|
|
49
|
+
{ name: 'shipping-and-launch', kind: 'skill', description: t('slash.skill.shipping-and-launch') },
|
|
50
|
+
{ name: 'source-driven-development', kind: 'skill', description: t('slash.skill.source-driven-development') },
|
|
51
|
+
{ name: 'spec-driven-development', kind: 'skill', description: t('slash.skill.spec-driven-development') },
|
|
52
|
+
{ name: 'using-agent-skills', kind: 'skill', description: t('slash.skill.using-agent-skills') },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/** Props for SlashPromptInput. */
|
|
56
|
+
export interface SlashPromptInputProps {
|
|
57
|
+
value: string
|
|
58
|
+
onChange: (value: string) => void
|
|
59
|
+
controller?: BoardController
|
|
60
|
+
placeholder?: string
|
|
61
|
+
rows?: number
|
|
62
|
+
maxLength?: number
|
|
63
|
+
disabled?: boolean
|
|
64
|
+
autoFocus?: boolean
|
|
65
|
+
className?: string
|
|
66
|
+
ariaLabel?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Rich prompt textarea with / autocomplete for slash commands & skills.
|
|
71
|
+
*/
|
|
72
|
+
export function SlashPromptInput({
|
|
73
|
+
value,
|
|
74
|
+
onChange,
|
|
75
|
+
controller,
|
|
76
|
+
placeholder,
|
|
77
|
+
rows = 4,
|
|
78
|
+
maxLength = 8000,
|
|
79
|
+
disabled = false,
|
|
80
|
+
autoFocus = false,
|
|
81
|
+
className,
|
|
82
|
+
ariaLabel,
|
|
83
|
+
}: SlashPromptInputProps) {
|
|
84
|
+
const t = useT()
|
|
85
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
86
|
+
|
|
87
|
+
// Autocomplete state: only HOST-provided items are stateful; the built-in
|
|
88
|
+
// defaults are re-derived per render so their descriptions follow the
|
|
89
|
+
// active locale live (host items override defaults by name).
|
|
90
|
+
const [hostCompletions, setHostCompletions] = useState<{ commands: PromptCompletionItem[]; skills: PromptCompletionItem[] } | undefined>(undefined)
|
|
91
|
+
const completions = useMemo<{ commands: PromptCompletionItem[]; skills: PromptCompletionItem[] }>(() => {
|
|
92
|
+
const merge = (defaults: PromptCompletionItem[], host: PromptCompletionItem[] | undefined): PromptCompletionItem[] => {
|
|
93
|
+
const map = new Map<string, PromptCompletionItem>()
|
|
94
|
+
for (const d of defaults) map.set(d.name, d)
|
|
95
|
+
for (const h of host ?? []) map.set(h.name, h)
|
|
96
|
+
return Array.from(map.values())
|
|
97
|
+
}
|
|
98
|
+
return { commands: merge(defaultCommands(t), hostCompletions?.commands), skills: merge(defaultSkills(t), hostCompletions?.skills) }
|
|
99
|
+
}, [t, hostCompletions])
|
|
100
|
+
const [popupOpen, setPopupOpen] = useState(false)
|
|
101
|
+
const [slashQuery, setSlashQuery] = useState('')
|
|
102
|
+
const [slashStart, setSlashStart] = useState(-1)
|
|
103
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
104
|
+
|
|
105
|
+
// Fetch host completions if controller provided
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (controller === undefined) return
|
|
108
|
+
let alive = true
|
|
109
|
+
void controller.fetchPromptCompletions().then(res => {
|
|
110
|
+
if (!alive || res === undefined) return
|
|
111
|
+
setHostCompletions({
|
|
112
|
+
commands: res.commands.map(c => ({ ...c, kind: 'command' })),
|
|
113
|
+
skills: res.skills.map(s => ({ ...s, kind: 'skill' })),
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
return () => { alive = false }
|
|
117
|
+
}, [controller])
|
|
118
|
+
|
|
119
|
+
// Filter items based on query
|
|
120
|
+
const filteredItems = useMemo<PromptCompletionItem[]>(() => {
|
|
121
|
+
const q = slashQuery.toLowerCase().trim()
|
|
122
|
+
const all = [...completions.commands, ...completions.skills]
|
|
123
|
+
if (q.length === 0) return all
|
|
124
|
+
return all.filter(item => item.name.toLowerCase().includes(q) || (item.description !== undefined && item.description.toLowerCase().includes(q)))
|
|
125
|
+
}, [completions, slashQuery])
|
|
126
|
+
|
|
127
|
+
// Keep selected index in bounds
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (selectedIndex >= filteredItems.length) {
|
|
130
|
+
setSelectedIndex(Math.max(0, filteredItems.length - 1))
|
|
131
|
+
}
|
|
132
|
+
}, [filteredItems.length, selectedIndex])
|
|
133
|
+
|
|
134
|
+
// Detect slash typing on cursor movement or text change
|
|
135
|
+
const checkSlashTrigger = (): void => {
|
|
136
|
+
const el = textareaRef.current
|
|
137
|
+
if (el === null) return
|
|
138
|
+
const pos = el.selectionStart
|
|
139
|
+
const currentText = el.value.slice(0, pos)
|
|
140
|
+
|
|
141
|
+
// Check if cursor is right after a word starting with /
|
|
142
|
+
const lastSlash = currentText.lastIndexOf('/')
|
|
143
|
+
if (lastSlash >= 0) {
|
|
144
|
+
const charBefore = lastSlash > 0 ? (currentText[lastSlash - 1] ?? '\n') : '\n'
|
|
145
|
+
const isWordStart = /\s/.test(charBefore) || lastSlash === 0
|
|
146
|
+
const queryPart = currentText.slice(lastSlash + 1)
|
|
147
|
+
const noWhitespaceInQuery = !/\s/.test(queryPart)
|
|
148
|
+
|
|
149
|
+
if (isWordStart && noWhitespaceInQuery) {
|
|
150
|
+
setSlashStart(lastSlash)
|
|
151
|
+
setSlashQuery(queryPart)
|
|
152
|
+
setPopupOpen(true)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
setPopupOpen(false)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Insert picked completion item
|
|
160
|
+
const applyCompletion = (item: PromptCompletionItem): void => {
|
|
161
|
+
const el = textareaRef.current
|
|
162
|
+
if (el === null || slashStart < 0) return
|
|
163
|
+
const pos = el.selectionStart
|
|
164
|
+
const before = value.slice(0, slashStart)
|
|
165
|
+
const after = value.slice(pos)
|
|
166
|
+
const inserted = `/${item.name} `
|
|
167
|
+
const nextText = before + inserted + after
|
|
168
|
+
onChange(nextText)
|
|
169
|
+
setPopupOpen(false)
|
|
170
|
+
|
|
171
|
+
// Restore focus & cursor position
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
if (textareaRef.current !== null) {
|
|
174
|
+
const nextPos = slashStart + inserted.length
|
|
175
|
+
textareaRef.current.focus()
|
|
176
|
+
textareaRef.current.setSelectionRange(nextPos, nextPos)
|
|
177
|
+
}
|
|
178
|
+
}, 0)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Keyboard navigation for slash popup
|
|
182
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
183
|
+
if (popupOpen && filteredItems.length > 0) {
|
|
184
|
+
if (e.key === 'ArrowDown') {
|
|
185
|
+
e.preventDefault()
|
|
186
|
+
setSelectedIndex(prev => (prev + 1) % filteredItems.length)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (e.key === 'ArrowUp') {
|
|
190
|
+
e.preventDefault()
|
|
191
|
+
setSelectedIndex(prev => (prev - 1 + filteredItems.length) % filteredItems.length)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
195
|
+
const picked = filteredItems[selectedIndex]
|
|
196
|
+
if (picked !== undefined) {
|
|
197
|
+
e.preventDefault()
|
|
198
|
+
applyCompletion(picked)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (e.key === 'Escape') {
|
|
203
|
+
e.preventDefault()
|
|
204
|
+
setPopupOpen(false)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div className={`dsh-atb-prompt-wrap ${className ?? ''}`}>
|
|
212
|
+
<div className="dsh-atb-prompt-inner">
|
|
213
|
+
<textarea
|
|
214
|
+
ref={textareaRef}
|
|
215
|
+
className="dsh-atb-prompt-input"
|
|
216
|
+
value={value}
|
|
217
|
+
rows={rows}
|
|
218
|
+
maxLength={maxLength}
|
|
219
|
+
disabled={disabled}
|
|
220
|
+
autoFocus={autoFocus}
|
|
221
|
+
placeholder={placeholder}
|
|
222
|
+
aria-label={ariaLabel}
|
|
223
|
+
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
224
|
+
onChange(e.target.value)
|
|
225
|
+
checkSlashTrigger()
|
|
226
|
+
}}
|
|
227
|
+
onKeyUp={checkSlashTrigger}
|
|
228
|
+
onClick={checkSlashTrigger}
|
|
229
|
+
onKeyDown={handleKeyDown}
|
|
230
|
+
/>
|
|
231
|
+
|
|
232
|
+
{/* Slash Autocomplete Popup */}
|
|
233
|
+
{popupOpen && filteredItems.length > 0 && (
|
|
234
|
+
<div className="dsh-atb-slash-popup" role="listbox" aria-label={t('slash.aria')}>
|
|
235
|
+
<div className="dsh-atb-slash-head">
|
|
236
|
+
<span className="dsh-atb-slash-title">{t('slash.title')}</span>
|
|
237
|
+
<span className="dsh-atb-slash-hint">{t('slash.hint')}</span>
|
|
238
|
+
</div>
|
|
239
|
+
<div className="dsh-atb-slash-list">
|
|
240
|
+
{filteredItems.map((item, idx) => (
|
|
241
|
+
<div
|
|
242
|
+
key={`${item.kind}-${item.name}`}
|
|
243
|
+
role="option"
|
|
244
|
+
aria-selected={idx === selectedIndex}
|
|
245
|
+
className="dsh-atb-slash-item"
|
|
246
|
+
data-active={idx === selectedIndex ? 'true' : undefined}
|
|
247
|
+
data-kind={item.kind}
|
|
248
|
+
onClick={() => applyCompletion(item)}
|
|
249
|
+
onMouseEnter={() => setSelectedIndex(idx)}
|
|
250
|
+
>
|
|
251
|
+
<span className="dsh-atb-slash-badge" data-kind={item.kind}>
|
|
252
|
+
{item.kind === 'command' ? t('slash.badge.command') : t('slash.badge.skill')}
|
|
253
|
+
</span>
|
|
254
|
+
<span className="dsh-atb-slash-name">/{item.name}</span>
|
|
255
|
+
{item.hint && <span className="dsh-atb-slash-param">{item.hint}</span>}
|
|
256
|
+
{item.description && <span className="dsh-atb-slash-desc">{item.description}</span>}
|
|
257
|
+
</div>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{/* Bottom helper toolbar */}
|
|
265
|
+
<div className="dsh-atb-prompt-foot">
|
|
266
|
+
<span className="dsh-atb-prompt-tip">
|
|
267
|
+
{t('slash.tipA')} <code>/</code> {t('slash.tipB')}
|
|
268
|
+
</span>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
@@ -9,7 +9,8 @@ import type { BoardController, ControllerState } from '../controller.ts'
|
|
|
9
9
|
import type { TaskRecord, TaskStatus, Urgency } from '../../shared/protocol.ts'
|
|
10
10
|
import { MAIN_STATUSES, canTransition } from '../../shared/protocol.ts'
|
|
11
11
|
import { PLUGIN_VERSION } from '../../shared/version.ts'
|
|
12
|
-
import {
|
|
12
|
+
import { COLUMN_KEYS, URGENCY_KEYS } from './labels.ts'
|
|
13
|
+
import { useT } from '../i18n/runtime.ts'
|
|
13
14
|
import { fmtTime, isStaleClaim } from './format.ts'
|
|
14
15
|
import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
|
|
15
16
|
import { TaskDetail } from './TaskDetail.tsx'
|
|
@@ -43,6 +44,7 @@ export function filterTasks(state: ControllerState, tasks: TaskRecord[]): TaskRe
|
|
|
43
44
|
* @param controller - the controller.
|
|
44
45
|
*/
|
|
45
46
|
export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
47
|
+
const t = useT()
|
|
46
48
|
const state = useSyncExternalStore(
|
|
47
49
|
cb => controller.subscribe(cb),
|
|
48
50
|
() => controller.getSnapshot(),
|
|
@@ -66,8 +68,8 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
66
68
|
return (
|
|
67
69
|
<div className="dsh-atb-board">
|
|
68
70
|
<div className="dsh-atb-toolbar">
|
|
69
|
-
<h2 className="dsh-atb-title">
|
|
70
|
-
<span className="dsh-atb-count">{live.length
|
|
71
|
+
<h2 className="dsh-atb-title">{t('board.title')}</h2>
|
|
72
|
+
<span className="dsh-atb-count">{t('board.count.tasks', { n: live.length, rev: state.ledger.revision })}</span>
|
|
71
73
|
<div className="dsh-atb-newmenu">
|
|
72
74
|
<button
|
|
73
75
|
type="button"
|
|
@@ -79,13 +81,13 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
79
81
|
if (next) controller.prepareTemplateMenu()
|
|
80
82
|
}}
|
|
81
83
|
>
|
|
82
|
-
|
|
84
|
+
{t('board.action.newTask')}
|
|
83
85
|
</button>
|
|
84
86
|
{newMenuOpen && (
|
|
85
87
|
<>
|
|
86
88
|
<div className="dsh-atb-newmenu-backdrop" onClick={closeMenu} />
|
|
87
89
|
<div className="dsh-atb-newmenu-list">
|
|
88
|
-
<button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.setComposer(true) }}
|
|
90
|
+
<button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.setComposer(true) }}>{t('board.action.blankTask')}</button>
|
|
89
91
|
{state.templates.map(t => (
|
|
90
92
|
<button
|
|
91
93
|
key={t.id}
|
|
@@ -98,7 +100,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
98
100
|
</button>
|
|
99
101
|
))}
|
|
100
102
|
<div className="dsh-atb-newmenu-sep" />
|
|
101
|
-
<button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.openTemplateManager() }}
|
|
103
|
+
<button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.openTemplateManager() }}>{t('board.action.manageTemplates')}</button>
|
|
102
104
|
</div>
|
|
103
105
|
</>
|
|
104
106
|
)}
|
|
@@ -107,7 +109,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
107
109
|
<input
|
|
108
110
|
className="dsh-atb-input dsh-atb-search"
|
|
109
111
|
value={state.search}
|
|
110
|
-
placeholder=
|
|
112
|
+
placeholder={t('board.search.placeholder')}
|
|
111
113
|
spellCheck={false}
|
|
112
114
|
onChange={e => controller.setSearch(e.target.value)}
|
|
113
115
|
/>
|
|
@@ -116,20 +118,20 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
116
118
|
value={state.filters.workspaceId ?? ''}
|
|
117
119
|
onChange={e => controller.setWorkspaceFilter(e.target.value === '' ? undefined : e.target.value)}
|
|
118
120
|
>
|
|
119
|
-
<option value=""
|
|
121
|
+
<option value="">{t('board.filter.allProjects')}</option>
|
|
120
122
|
{state.workspaces.map(ws => <option key={ws.id} value={ws.id}>{ws.title || ws.path}</option>)}
|
|
121
123
|
</select>
|
|
122
124
|
<select
|
|
123
125
|
className="dsh-atb-select"
|
|
124
126
|
value={state.sortBy}
|
|
125
|
-
title=
|
|
127
|
+
title={t('board.sort.title')}
|
|
126
128
|
onChange={e => controller.setSortBy(e.target.value as typeof state.sortBy)}
|
|
127
129
|
>
|
|
128
|
-
<option value="default"
|
|
129
|
-
<option value="updated"
|
|
130
|
-
<option value="urgency"
|
|
131
|
-
<option value="created"
|
|
132
|
-
<option value="title"
|
|
130
|
+
<option value="default">{t('board.sort.default')}</option>
|
|
131
|
+
<option value="updated">{t('board.sort.updated')}</option>
|
|
132
|
+
<option value="urgency">{t('board.sort.urgency')}</option>
|
|
133
|
+
<option value="created">{t('board.sort.created')}</option>
|
|
134
|
+
<option value="title">{t('board.sort.byTitle')}</option>
|
|
133
135
|
</select>
|
|
134
136
|
{(['urgent', 'normal', 'relaxed'] as const).map(u => (
|
|
135
137
|
<button
|
|
@@ -141,23 +143,23 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
141
143
|
onClick={() => controller.toggleUrgency(u)}
|
|
142
144
|
>
|
|
143
145
|
<span className="dsh-atb-dot" data-urgency={u} />
|
|
144
|
-
{
|
|
146
|
+
{t(URGENCY_KEYS[u])}
|
|
145
147
|
</button>
|
|
146
148
|
))}
|
|
147
149
|
<button type="button" className="dsh-atb-btn" onClick={() => controller.toggleSecondary()}>
|
|
148
|
-
{state.secondaryOpen ? '
|
|
150
|
+
{state.secondaryOpen ? t('board.action.backToBoard') : t('board.action.otherTasks')}
|
|
149
151
|
</button>
|
|
150
|
-
<button type="button" className="dsh-atb-btn" title=
|
|
151
|
-
<button type="button" className="dsh-atb-btn" title=
|
|
152
|
-
<button type="button" className="dsh-atb-btn" title=
|
|
152
|
+
<button type="button" className="dsh-atb-btn" title={t('board.action.settingsTitle')} onClick={() => controller.openSettings()}>{t('board.action.settings')}</button>
|
|
153
|
+
<button type="button" className="dsh-atb-btn" title={t('board.action.diagTitle')} onClick={() => controller.openDiagnostics()}>{t('board.action.diag')}</button>
|
|
154
|
+
<button type="button" className="dsh-atb-btn" title={t('board.action.importTitle')} onClick={() => controller.openImport()}>{t('board.action.import')}</button>
|
|
153
155
|
<div className="dsh-atb-newmenu">
|
|
154
156
|
<button
|
|
155
157
|
type="button"
|
|
156
158
|
className="dsh-atb-btn"
|
|
157
|
-
title=
|
|
159
|
+
title={t('board.action.exportTitle')}
|
|
158
160
|
onClick={() => setExportOpen(!exportOpen)}
|
|
159
161
|
>
|
|
160
|
-
|
|
162
|
+
{t('board.action.export')}
|
|
161
163
|
</button>
|
|
162
164
|
{exportOpen && (
|
|
163
165
|
<>
|
|
@@ -166,18 +168,18 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
166
168
|
<button
|
|
167
169
|
type="button"
|
|
168
170
|
className="dsh-atb-newmenu-opt"
|
|
169
|
-
title=
|
|
171
|
+
title={t('board.export.jsonTitle')}
|
|
170
172
|
onClick={() => { closeExport(); controller.exportJson() }}
|
|
171
173
|
>
|
|
172
|
-
|
|
174
|
+
{t('board.export.json')}
|
|
173
175
|
</button>
|
|
174
176
|
<button
|
|
175
177
|
type="button"
|
|
176
178
|
className="dsh-atb-newmenu-opt"
|
|
177
|
-
title=
|
|
179
|
+
title={t('board.export.csvTitle')}
|
|
178
180
|
onClick={() => { closeExport(); controller.exportCsv() }}
|
|
179
181
|
>
|
|
180
|
-
|
|
182
|
+
{t('board.export.csv')}
|
|
181
183
|
</button>
|
|
182
184
|
</div>
|
|
183
185
|
</>
|
|
@@ -221,7 +223,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
221
223
|
const task = state.ledger.tasks.find(t => t.id === id)
|
|
222
224
|
if (task === undefined || task.status === status) return
|
|
223
225
|
if (!canTransition(task.status, status)) {
|
|
224
|
-
showAlert(
|
|
226
|
+
showAlert(t('board.drag.forbidden', { from: t(COLUMN_KEYS[task.status]), to: t(COLUMN_KEYS[status]) }))
|
|
225
227
|
return
|
|
226
228
|
}
|
|
227
229
|
void controller.move(id, task.version, status)
|
|
@@ -229,7 +231,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
229
231
|
>
|
|
230
232
|
<div className="dsh-atb-colhead">
|
|
231
233
|
<span className="dsh-atb-dot" data-status={status} />
|
|
232
|
-
{
|
|
234
|
+
{t(COLUMN_KEYS[status])}
|
|
233
235
|
<span className="dsh-atb-colcount">{columnTasks.length}</span>
|
|
234
236
|
</div>
|
|
235
237
|
<div className="dsh-atb-cards">
|
|
@@ -243,7 +245,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
243
245
|
onAlert={showAlert}
|
|
244
246
|
/>
|
|
245
247
|
))}
|
|
246
|
-
{columnTasks.length === 0 && <div className="dsh-atb-empty"
|
|
248
|
+
{columnTasks.length === 0 && <div className="dsh-atb-empty">{t('board.empty')}</div>}
|
|
247
249
|
</div>
|
|
248
250
|
</div>
|
|
249
251
|
)
|
|
@@ -281,6 +283,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
281
283
|
|
|
282
284
|
/** ⚙ Health-diagnostics panel (plan §3.6): ledger basics + orphan worktrees + one-click cleanup. */
|
|
283
285
|
function DiagnosticsPanel({ controller }: { controller: BoardController }) {
|
|
286
|
+
const t = useT()
|
|
284
287
|
const state = controller.getSnapshot()
|
|
285
288
|
const diag = state.diagnostics
|
|
286
289
|
const wsName = (id: string): string => {
|
|
@@ -289,52 +292,52 @@ function DiagnosticsPanel({ controller }: { controller: BoardController }) {
|
|
|
289
292
|
}
|
|
290
293
|
return (
|
|
291
294
|
<div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) controller.closeDiagnostics() }}>
|
|
292
|
-
<div className="dsh-atb-modal dsh-atb-diag" role="dialog" aria-modal="true" aria-label=
|
|
295
|
+
<div className="dsh-atb-modal dsh-atb-diag" role="dialog" aria-modal="true" aria-label={t('diag.title')}>
|
|
293
296
|
<div className="dsh-atb-modal-head">
|
|
294
297
|
<span className="dsh-atb-modal-headicon">⚙</span>
|
|
295
298
|
<div className="dsh-atb-modal-headtext">
|
|
296
|
-
<h3
|
|
297
|
-
<p
|
|
299
|
+
<h3>{t('diag.title')}</h3>
|
|
300
|
+
<p>{t('diag.subtitle')}</p>
|
|
298
301
|
</div>
|
|
299
|
-
<button type="button" className="dsh-atb-modal-close" aria-label=
|
|
302
|
+
<button type="button" className="dsh-atb-modal-close" aria-label={t('shared.close')} onClick={() => controller.closeDiagnostics()}>✕</button>
|
|
300
303
|
</div>
|
|
301
304
|
<div className="dsh-atb-modal-body">
|
|
302
305
|
{diag === undefined
|
|
303
|
-
? <div className="dsh-atb-empty2"
|
|
306
|
+
? <div className="dsh-atb-empty2">{t('shared.loading')}</div>
|
|
304
307
|
: (
|
|
305
308
|
<>
|
|
306
309
|
<div className="dsh-atb-diag-grid">
|
|
307
|
-
<div className="dsh-atb-diag-item"><b>{diag.revision}</b><span
|
|
308
|
-
<div className="dsh-atb-diag-item"><b>{diag.tasks}</b><span
|
|
309
|
-
<div className="dsh-atb-diag-item" data-bad={diag.staleRunning > 0 ? 'true' : undefined}><b>{diag.staleRunning}</b><span
|
|
310
|
-
<div className="dsh-atb-diag-item" data-bad={diag.orphanWorktrees.length > 0 ? 'true' : undefined}><b>{diag.orphanWorktrees.length}</b><span
|
|
310
|
+
<div className="dsh-atb-diag-item"><b>{diag.revision}</b><span>{t('diag.revision')}</span></div>
|
|
311
|
+
<div className="dsh-atb-diag-item"><b>{diag.tasks}</b><span>{t('diag.tasks')}</span></div>
|
|
312
|
+
<div className="dsh-atb-diag-item" data-bad={diag.staleRunning > 0 ? 'true' : undefined}><b>{diag.staleRunning}</b><span>{t('diag.running')}</span></div>
|
|
313
|
+
<div className="dsh-atb-diag-item" data-bad={diag.orphanWorktrees.length > 0 ? 'true' : undefined}><b>{diag.orphanWorktrees.length}</b><span>{t('diag.orphans')}</span></div>
|
|
311
314
|
</div>
|
|
312
315
|
<div className="dsh-atb-diag-sec">
|
|
313
|
-
<h4
|
|
316
|
+
<h4>{t('diag.orphans.heading')}</h4>
|
|
314
317
|
{diag.orphanWorktrees.length === 0
|
|
315
|
-
? <div className="dsh-atb-empty2"
|
|
318
|
+
? <div className="dsh-atb-empty2">{t('diag.orphans.none')}</div>
|
|
316
319
|
: (
|
|
317
320
|
<div className="dsh-atb-diag-orphans">
|
|
318
321
|
{diag.orphanWorktrees.map(o => (
|
|
319
322
|
<div key={o.path} className="dsh-atb-diag-orphan">
|
|
320
323
|
<span className="dsh-atb-diag-orphan-path" title={o.path}>{wsName(o.workspaceId)} · {o.taskId}</span>
|
|
321
|
-
<button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => void controller.cleanupOrphan(o.workspaceId, o.taskId)}
|
|
324
|
+
<button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => void controller.cleanupOrphan(o.workspaceId, o.taskId)}>{t('diag.orphans.cleanup')}</button>
|
|
322
325
|
</div>
|
|
323
326
|
))}
|
|
324
327
|
</div>
|
|
325
328
|
)}
|
|
326
|
-
<div className="dsh-atb-empty2"
|
|
329
|
+
<div className="dsh-atb-empty2">{t('diag.orphans.hint')}</div>
|
|
327
330
|
</div>
|
|
328
331
|
<div className="dsh-atb-diag-sec">
|
|
329
|
-
<h4>gitignore
|
|
332
|
+
<h4>{t('diag.gitignore.heading')}</h4>
|
|
330
333
|
{(diag.gitIgnoreSuggestions ?? []).length === 0
|
|
331
|
-
? <div className="dsh-atb-empty2"
|
|
334
|
+
? <div className="dsh-atb-empty2">{t('diag.gitignore.none')}</div>
|
|
332
335
|
: (
|
|
333
336
|
<div className="dsh-atb-diag-orphans">
|
|
334
337
|
{diag.gitIgnoreSuggestions.map(s => (
|
|
335
338
|
<div key={s.workspaceId} className="dsh-atb-diag-orphan">
|
|
336
339
|
<span className="dsh-atb-diag-orphan-path" title={s.workspacePath}>
|
|
337
|
-
{wsName(s.workspaceId)} ·
|
|
340
|
+
{wsName(s.workspaceId)} · {t('diag.gitignore.suggestA')} <code>.dsh-worktrees/</code>{t('diag.gitignore.suggestB')}
|
|
338
341
|
</span>
|
|
339
342
|
</div>
|
|
340
343
|
))}
|
|
@@ -351,20 +354,21 @@ function DiagnosticsPanel({ controller }: { controller: BoardController }) {
|
|
|
351
354
|
|
|
352
355
|
/** Secondary tab: tasks grouped into canceled / archived / trashed columns. */
|
|
353
356
|
function SecondaryTab({ controller, tasks }: { controller: BoardController; tasks: TaskRecord[] }) {
|
|
357
|
+
const t = useT()
|
|
354
358
|
// Trashed takes precedence (a trashed task still carries its old status,
|
|
355
359
|
// but what matters to the user is the pending purge).
|
|
356
360
|
const trashed = tasks.filter(t => t.trashedAt !== undefined)
|
|
357
361
|
const archived = tasks.filter(t => t.trashedAt === undefined && t.status === 'archived')
|
|
358
362
|
const canceled = tasks.filter(t => t.trashedAt === undefined && t.status === 'canceled')
|
|
359
363
|
const groups = [
|
|
360
|
-
{ label: '
|
|
361
|
-
{ label: '
|
|
362
|
-
{ label: '
|
|
364
|
+
{ label: t('status.column.canceled'), dot: 'canceled', rows: canceled },
|
|
365
|
+
{ label: t('status.column.archived'), dot: 'archived', rows: archived },
|
|
366
|
+
{ label: t('board.group.trashed'), dot: 'trashed', rows: trashed },
|
|
363
367
|
]
|
|
364
368
|
if (trashed.length + archived.length + canceled.length === 0) {
|
|
365
369
|
return (
|
|
366
370
|
<div className="dsh-atb-secondary">
|
|
367
|
-
<div className="dsh-atb-empty"
|
|
371
|
+
<div className="dsh-atb-empty">{t('board.secondary.empty')}</div>
|
|
368
372
|
</div>
|
|
369
373
|
)
|
|
370
374
|
}
|
|
@@ -381,7 +385,7 @@ function SecondaryTab({ controller, tasks }: { controller: BoardController; task
|
|
|
381
385
|
{group.rows.map(task => (
|
|
382
386
|
<TaskCard key={task.id} task={task} controller={controller} />
|
|
383
387
|
))}
|
|
384
|
-
{group.rows.length === 0 && <div className="dsh-atb-empty"
|
|
388
|
+
{group.rows.length === 0 && <div className="dsh-atb-empty">{t('board.empty')}</div>}
|
|
385
389
|
</div>
|
|
386
390
|
</div>
|
|
387
391
|
))}
|