@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.5
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.i18n.yaml +2 -2
- package/README.md +42 -11
- package/README.zh.md +42 -11
- package/lib/client.js +4958 -472
- package/lib/client.js.map +1 -1
- package/lib/index.js +2841 -121
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +4 -1
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/board-card-seat.d.ts +34 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +103 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +37 -1
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/tasks.d.ts +84 -3
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification.d.ts +386 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +71 -0
- package/lib/types/host/verification-runner.d.ts +108 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +52 -8
- package/lib/types/host-runner.d.ts +23 -1
- package/lib/types/host-service.d.ts +88 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +49 -5
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +27 -26
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +304 -65
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +12 -7
- package/src/client/board/NewTaskModal.tsx +17 -1
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +71 -26
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +203 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/status-key.ts +27 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-card-seat.ts +102 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +43 -5
- package/src/client/locales.ts +208 -4
- package/src/client/native-panel.tsx +18 -2
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +94 -3
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/store.ts +16 -0
- package/src/core/tasks.ts +100 -6
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +10 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +20 -18
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +331 -0
- package/src/host/verification-runner.ts +535 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +143 -32
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +60 -11
- package/src/host-service.ts +253 -10
- package/src/index.ts +204 -16
- package/src/protocol.ts +102 -8
- package/src/tool-surface.ts +95 -0
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Task card: the board's column item.
|
|
3
|
-
* never executes anything directly (detail holds the Run
|
|
2
|
+
* Task card: the board's column item. The stretched open control opens the
|
|
3
|
+
* task detail — it never executes anything directly (detail holds the Run
|
|
4
|
+
* button).
|
|
5
|
+
*
|
|
6
|
+
* The card is an `article` with one stretched `button` rather than a button
|
|
7
|
+
* itself: interactive content inside a button is invalid HTML, and the card
|
|
8
|
+
* carries its own quick actions (open the execution session, a provider's
|
|
9
|
+
* link such as the tracker issue). The article owns the pointer click and
|
|
10
|
+
* ignores clicks that land on a quick action; the stretched open button is the
|
|
11
|
+
* keyboard and accessibility target, and its click bubbles into the same
|
|
12
|
+
* handler, so the detail opens exactly once either way.
|
|
4
13
|
*
|
|
5
14
|
* Memoized: the card re-renders only when its own task record changes, so a
|
|
6
15
|
* status/filter update on one card (or scrolling) never re-renders every
|
|
7
|
-
* card on the board. The per-card
|
|
8
|
-
* by the board, so the memo boundary is effective.
|
|
16
|
+
* card on the board. The per-card callbacks are built with a stable task
|
|
17
|
+
* reference by the board, so the memo boundary is effective.
|
|
9
18
|
*/
|
|
10
|
-
import { memo } from 'react'
|
|
19
|
+
import { memo, useMemo } from 'react'
|
|
11
20
|
import type { TaskRecord } from '../../core/tasks.ts'
|
|
12
|
-
import { executionLabel, tagTone } from '../../core/tasks.ts'
|
|
21
|
+
import { executionLabel, hasOpenExecution, tagTone } from '../../core/tasks.ts'
|
|
13
22
|
import { t } from '../locales.ts'
|
|
23
|
+
import { useTaskBoardSeats } from '../seats.tsx'
|
|
24
|
+
import { cardOutcome, cardTimeline, RESULT_KEY, splitTitleKind } from './card-view.ts'
|
|
25
|
+
import { IconClock, IconSession } from './icons.tsx'
|
|
26
|
+
import { verificationRunningKey } from './status-key.ts'
|
|
14
27
|
import css from '../board.module.css'
|
|
28
|
+
import { markdownExcerpt, markdownToPlainText } from './task-markdown.tsx'
|
|
15
29
|
|
|
16
30
|
/**
|
|
17
31
|
* Built formatters, keyed by time zone ('' = the browser's own). Constructing
|
|
@@ -56,11 +70,43 @@ export function formatTime(ms: number, timeZone?: string): string {
|
|
|
56
70
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
57
71
|
}
|
|
58
72
|
|
|
73
|
+
/** Cached YYYY-MM-DD formatters, keyed by time zone ('' = the browser's own). */
|
|
74
|
+
const CARD_DATE_FORMATS = new Map<string, Intl.DateTimeFormat>()
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Card time label: relative within a day, otherwise the calendar date read in
|
|
78
|
+
* the Host time zone (the same zone the tooltip and the detail view use), so a
|
|
79
|
+
* card never shows a browser-local date beside a Host-zone tooltip.
|
|
80
|
+
* @param ms - the instant.
|
|
81
|
+
* @param timeZone - Host IANA zone; absent uses the browser zone.
|
|
82
|
+
* @param now - the current instant.
|
|
83
|
+
*/
|
|
84
|
+
export function formatCardTime(ms: number, timeZone?: string, now: number = Date.now()): string {
|
|
85
|
+
const minutes = Math.floor((now - ms) / 60000)
|
|
86
|
+
if (minutes < 1) return t('time.justNow')
|
|
87
|
+
if (minutes < 60) return `${minutes}m`
|
|
88
|
+
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h`
|
|
89
|
+
const key = timeZone ?? ''
|
|
90
|
+
try {
|
|
91
|
+
let format = CARD_DATE_FORMATS.get(key)
|
|
92
|
+
if (format === undefined) {
|
|
93
|
+
format = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', ...(timeZone === undefined ? {} : { timeZone }) })
|
|
94
|
+
CARD_DATE_FORMATS.set(key, format)
|
|
95
|
+
}
|
|
96
|
+
return format.format(new Date(ms))
|
|
97
|
+
} catch {
|
|
98
|
+
// An unsupported zone id: never cached; fall back to the UTC calendar date.
|
|
99
|
+
return new Date(ms).toISOString().slice(0, 10)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
59
103
|
function TaskCardInner({
|
|
60
104
|
task,
|
|
61
105
|
pending,
|
|
62
106
|
timeZone,
|
|
63
107
|
onClick,
|
|
108
|
+
onOpenSession,
|
|
109
|
+
compact = false,
|
|
64
110
|
subtaskCount = 0,
|
|
65
111
|
isSubtask = false,
|
|
66
112
|
subtasksDone = 0,
|
|
@@ -71,6 +117,10 @@ function TaskCardInner({
|
|
|
71
117
|
pending: boolean
|
|
72
118
|
timeZone?: string
|
|
73
119
|
onClick: () => void
|
|
120
|
+
/** Open an execution session; absent renders the session mark inert. */
|
|
121
|
+
onOpenSession?: (sessionId: string) => void
|
|
122
|
+
/** Single-line density used by long settled columns. */
|
|
123
|
+
compact?: boolean
|
|
74
124
|
/** Direct subtasks of this card, shown as a badge (0 hides it). */
|
|
75
125
|
subtaskCount?: number
|
|
76
126
|
/** Whether this card is itself a subtask. */
|
|
@@ -82,16 +132,70 @@ function TaskCardInner({
|
|
|
82
132
|
/** Direct subtasks that failed, for the roll-up badge. */
|
|
83
133
|
subtasksFailed?: number
|
|
84
134
|
}) {
|
|
85
|
-
const
|
|
135
|
+
const { cardDecoration } = useTaskBoardSeats()
|
|
86
136
|
const runs = task.executions.length
|
|
137
|
+
const summary = useMemo(() => markdownToPlainText(task.title) || task.title, [task.title])
|
|
138
|
+
const title = useMemo(() => splitTitleKind(summary), [summary])
|
|
139
|
+
const excerpt = useMemo(() => (compact ? '' : markdownExcerpt(task.description)), [task.description, compact])
|
|
87
140
|
const archived = task.archivedAt !== undefined
|
|
88
|
-
|
|
141
|
+
// The lock is an open execution, not the column: a card parked in 'running'
|
|
142
|
+
// by hand stays draggable, and an executing card never is.
|
|
143
|
+
const busy = hasOpenExecution(task)
|
|
144
|
+
const isDraggable = !archived && !busy && !pending
|
|
145
|
+
const outcome = cardOutcome(task, archived)
|
|
146
|
+
const latest = outcome.latest
|
|
147
|
+
// Forced acceptance owns the running label.
|
|
148
|
+
const runningKey = verificationRunningKey(latest?.verification)
|
|
149
|
+
const timeline = cardTimeline(task, archived)
|
|
150
|
+
const resultText = latest?.result !== undefined ? t(RESULT_KEY[latest.result]) : ''
|
|
151
|
+
const sessionId = latest?.sessionId
|
|
152
|
+
const tagNames = (task.tags ?? []).map(tag => tag.name)
|
|
89
153
|
|
|
90
|
-
|
|
154
|
+
const ariaLabel = [
|
|
155
|
+
title.kind === undefined ? summary : `${title.kind}: ${title.text}`,
|
|
156
|
+
!archived && pending ? t('board.pending') : '',
|
|
157
|
+
!archived && busy ? t(runningKey ?? 'detail.result.running') : '',
|
|
158
|
+
!archived && !busy && resultText !== '' ? resultText : '',
|
|
159
|
+
outcome.declared ? t('card.declared') : '',
|
|
160
|
+
subtasksFailed > 0 ? t('card.subtasksFailed', { count: String(subtasksFailed) }) : '',
|
|
161
|
+
tagNames.length > 0 ? t('card.tagsLabel', { tags: tagNames.join(', ') }) : '',
|
|
162
|
+
].filter(Boolean).join(' · ')
|
|
163
|
+
// The tooltip names what the clamped surface cannot show — the full title and
|
|
164
|
+
// the absolute instant — not the whole excerpt again.
|
|
165
|
+
const tooltip = [
|
|
166
|
+
summary,
|
|
167
|
+
t(timeline.key, { time: formatHostTimestamp(timeline.at, timeZone) }),
|
|
168
|
+
outcome.declared ? t('card.declaredHint') : '',
|
|
169
|
+
].filter(Boolean).join('\n')
|
|
170
|
+
|
|
171
|
+
const sessionMark = sessionId !== undefined && (onOpenSession !== undefined ? (
|
|
91
172
|
<button
|
|
92
173
|
type="button"
|
|
174
|
+
className={`${css.cardSession} ${css.cardAction}`}
|
|
175
|
+
data-dsh-part="card-session"
|
|
176
|
+
title={t('card.openSession')}
|
|
177
|
+
aria-label={t('card.openSession')}
|
|
178
|
+
onClick={() => { onOpenSession(sessionId) }}
|
|
179
|
+
>
|
|
180
|
+
<IconSession size={14} />
|
|
181
|
+
</button>
|
|
182
|
+
) : (
|
|
183
|
+
<span className={css.cardSession} title={sessionId}>
|
|
184
|
+
<IconSession size={12} />
|
|
185
|
+
</span>
|
|
186
|
+
))
|
|
187
|
+
const decoration = <span className={css.cardDecoration}>{cardDecoration({ task })}</span>
|
|
188
|
+
const timeLabel = <span className={css.cardTime} data-dsh-part="card-time">{t(timeline.key, { time: formatCardTime(timeline.at, timeZone) })}</span>
|
|
189
|
+
const kindBadge = title.kind !== undefined && (
|
|
190
|
+
<span className={css.cardKind} data-dsh-part="card-kind">{title.kind}</span>
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<article
|
|
93
195
|
className={css.card}
|
|
94
196
|
data-status={archived ? 'archived' : task.status}
|
|
197
|
+
data-tone={outcome.tone}
|
|
198
|
+
data-compact={compact || undefined}
|
|
95
199
|
data-dsh-part="card"
|
|
96
200
|
data-pending={pending || undefined}
|
|
97
201
|
draggable={isDraggable}
|
|
@@ -99,82 +203,111 @@ function TaskCardInner({
|
|
|
99
203
|
event.dataTransfer.setData('text/plain', task.id)
|
|
100
204
|
event.dataTransfer.effectAllowed = 'move'
|
|
101
205
|
} : undefined}
|
|
102
|
-
|
|
103
|
-
|
|
206
|
+
title={tooltip}
|
|
207
|
+
onClick={(event) => {
|
|
208
|
+
// A quick action (session, provider link) handles its own click.
|
|
209
|
+
const target = event.target as Element
|
|
210
|
+
if (target.closest('a, button:not([data-dsh-part="card-open"])') !== null) return
|
|
211
|
+
onClick()
|
|
212
|
+
}}
|
|
104
213
|
>
|
|
105
|
-
<
|
|
106
|
-
{
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
<span
|
|
110
|
-
key={tag.name}
|
|
111
|
-
className={css.cardTag}
|
|
112
|
-
data-tag-tone={tagTone(tag.name)}
|
|
113
|
-
data-dsh-part="tag-badge"
|
|
114
|
-
data-tag-hint={tag.promptPrefix === undefined ? undefined : tag.promptPrefix}
|
|
115
|
-
title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
|
|
116
|
-
>
|
|
117
|
-
{tag.name}
|
|
118
|
-
</span>
|
|
119
|
-
))}
|
|
120
|
-
</span>
|
|
121
|
-
)}
|
|
122
|
-
{task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
|
|
123
|
-
<span className={css.cardMeta}>
|
|
124
|
-
{isSubtask && (
|
|
125
|
-
<span className={css.cardSubtask} data-dsh-part="subtask-badge">{t('card.subtask')}</span>
|
|
126
|
-
)}
|
|
127
|
-
{subtaskCount > 0 && (
|
|
128
|
-
// A board that hides subtask cards still has to report them: the badge
|
|
129
|
-
// rolls up the direct children and takes the tone of the worst state,
|
|
130
|
-
// so a hidden failing tree cannot look green.
|
|
131
|
-
<span
|
|
132
|
-
className={css.cardSubtask}
|
|
133
|
-
data-dsh-part="subtask-count"
|
|
134
|
-
data-tone={subtasksFailed > 0 ? 'failed' : subtasksRunning > 0 ? 'running' : subtasksDone === subtaskCount ? 'done' : undefined}
|
|
135
|
-
title={t('card.subtasksBreakdown', {
|
|
136
|
-
total: String(subtaskCount),
|
|
137
|
-
done: String(subtasksDone),
|
|
138
|
-
running: String(subtasksRunning),
|
|
139
|
-
failed: String(subtasksFailed),
|
|
140
|
-
})}
|
|
141
|
-
>
|
|
142
|
-
{t('card.subtasks', { count: String(subtaskCount) })}
|
|
143
|
-
{subtasksFailed > 0 ? ' · ' + t('card.subtasksFailed', { count: String(subtasksFailed) }) : ''}
|
|
144
|
-
{subtasksFailed === 0 && subtasksRunning > 0 ? ' · ' + t('card.subtasksRunning', { count: String(subtasksRunning) }) : ''}
|
|
145
|
-
</span>
|
|
146
|
-
)}
|
|
147
|
-
<span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
|
|
148
|
-
{task.freeze !== undefined && (
|
|
149
|
-
<span className={css.cardSchedule} title={task.freeze.goal}>{t('card.frozen')}</span>
|
|
150
|
-
)}
|
|
151
|
-
{!archived && task.schedule?.enabled === true && (
|
|
152
|
-
<span
|
|
153
|
-
className={css.cardSchedule}
|
|
154
|
-
title={task.schedule.nextRunAt !== undefined
|
|
155
|
-
? `${t('card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
|
|
156
|
-
: t('card.scheduled')}
|
|
157
|
-
>
|
|
158
|
-
{t('card.scheduled')}
|
|
159
|
-
</span>
|
|
160
|
-
)}
|
|
161
|
-
{latest !== undefined && (
|
|
162
|
-
<span className={css.cardRun} data-result={archived ? undefined : latest.result}>
|
|
163
|
-
{runs} {t('board.runs')}
|
|
164
|
-
</span>
|
|
165
|
-
)}
|
|
166
|
-
{latest?.sessionId !== undefined && (
|
|
167
|
-
<span className={css.cardSession} title={latest.sessionId}>⌁</span>
|
|
168
|
-
)}
|
|
169
|
-
{!archived && (task.status === 'running' || pending) && <span className={css.cardSpinner} aria-hidden="true" />}
|
|
214
|
+
<button type="button" className={css.cardOpen} data-dsh-part="card-open" aria-label={ariaLabel} />
|
|
215
|
+
<span className={css.cardTitleRow}>
|
|
216
|
+
{kindBadge}
|
|
217
|
+
<span className={css.cardTitle} data-dsh-part="card-title">{title.text}</span>
|
|
170
218
|
</span>
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
{
|
|
219
|
+
{compact ? (
|
|
220
|
+
<span className={css.cardMeta}>
|
|
221
|
+
{decoration}
|
|
222
|
+
{timeLabel}
|
|
223
|
+
{!archived && (busy || pending) && <span className={css.cardSpinner} aria-hidden="true" />}
|
|
175
224
|
</span>
|
|
225
|
+
) : (
|
|
226
|
+
<>
|
|
227
|
+
{task.tags !== undefined && task.tags.length > 0 && (
|
|
228
|
+
<span className={css.cardTags}>
|
|
229
|
+
{task.tags.map(tag => (
|
|
230
|
+
<span
|
|
231
|
+
key={tag.name}
|
|
232
|
+
className={css.cardTag}
|
|
233
|
+
data-tag-tone={tagTone(tag.name)}
|
|
234
|
+
data-dsh-part="tag-badge"
|
|
235
|
+
data-tag-hint={tag.promptPrefix === undefined ? undefined : tag.promptPrefix}
|
|
236
|
+
title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
|
|
237
|
+
>
|
|
238
|
+
{tag.name}
|
|
239
|
+
</span>
|
|
240
|
+
))}
|
|
241
|
+
</span>
|
|
242
|
+
)}
|
|
243
|
+
{excerpt !== '' && <span className={css.cardExcerpt} data-dsh-part="card-excerpt">{excerpt}</span>}
|
|
244
|
+
<span className={css.cardMeta}>
|
|
245
|
+
{decoration}
|
|
246
|
+
{isSubtask && (
|
|
247
|
+
<span className={css.cardSubtask} data-dsh-part="subtask-badge">{t('card.subtask')}</span>
|
|
248
|
+
)}
|
|
249
|
+
{subtaskCount > 0 && (
|
|
250
|
+
// A board that hides subtask cards still has to report them: the badge
|
|
251
|
+
// rolls up the direct children and takes the tone of the worst state,
|
|
252
|
+
// so a hidden failing tree cannot look green.
|
|
253
|
+
<span
|
|
254
|
+
className={css.cardSubtask}
|
|
255
|
+
data-dsh-part="subtask-count"
|
|
256
|
+
data-tone={subtasksFailed > 0 ? 'failed' : subtasksRunning > 0 ? 'running' : subtasksDone === subtaskCount ? 'done' : undefined}
|
|
257
|
+
title={t('card.subtasksBreakdown', {
|
|
258
|
+
total: String(subtaskCount),
|
|
259
|
+
done: String(subtasksDone),
|
|
260
|
+
running: String(subtasksRunning),
|
|
261
|
+
failed: String(subtasksFailed),
|
|
262
|
+
})}
|
|
263
|
+
>
|
|
264
|
+
{t('card.subtasks', { count: String(subtaskCount) })}
|
|
265
|
+
{subtasksFailed > 0 ? ' · ' + t('card.subtasksFailed', { count: String(subtasksFailed) }) : ''}
|
|
266
|
+
{subtasksFailed === 0 && subtasksRunning > 0 ? ' · ' + t('card.subtasksRunning', { count: String(subtasksRunning) }) : ''}
|
|
267
|
+
</span>
|
|
268
|
+
)}
|
|
269
|
+
{timeLabel}
|
|
270
|
+
{task.freeze !== undefined && (
|
|
271
|
+
<span className={css.cardSchedule} title={task.freeze.goal}>{t('card.frozen')}</span>
|
|
272
|
+
)}
|
|
273
|
+
{!archived && task.schedule?.enabled === true && (
|
|
274
|
+
<span
|
|
275
|
+
className={css.cardSchedule}
|
|
276
|
+
title={task.schedule.nextRunAt !== undefined
|
|
277
|
+
? `${t('card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
|
|
278
|
+
: t('card.scheduled')}
|
|
279
|
+
>
|
|
280
|
+
<IconClock size={12} />
|
|
281
|
+
{t('card.scheduled')}
|
|
282
|
+
</span>
|
|
283
|
+
)}
|
|
284
|
+
{latest !== undefined && (
|
|
285
|
+
<span
|
|
286
|
+
className={css.cardRun}
|
|
287
|
+
data-dsh-part="card-run"
|
|
288
|
+
data-result={archived || latest.result === undefined ? undefined : latest.result}
|
|
289
|
+
data-declared={outcome.declared || undefined}
|
|
290
|
+
title={outcome.declared ? t('card.declaredHint') : undefined}
|
|
291
|
+
>
|
|
292
|
+
{!busy && resultText !== '' ? `${resultText} · ` : ''}
|
|
293
|
+
{runs} {t('board.runs')}
|
|
294
|
+
{outcome.declared ? ` · ${t('card.declared')}` : ''}
|
|
295
|
+
</span>
|
|
296
|
+
)}
|
|
297
|
+
{sessionMark}
|
|
298
|
+
{!archived && (busy || pending) && <span className={css.cardSpinner} aria-hidden="true" />}
|
|
299
|
+
</span>
|
|
300
|
+
{!archived && pending && <span className={css.cardRunningLabel}>{t('board.pending')}…</span>}
|
|
301
|
+
{!archived && latest !== undefined && executionLabel(latest) === 'running' && (
|
|
302
|
+
<span className={css.cardRunningLabel}>
|
|
303
|
+
{runningKey !== undefined
|
|
304
|
+
? t(runningKey)
|
|
305
|
+
: latest.ownResult === undefined ? t('detail.result.running') : t('detail.subtasks.waiting')}…
|
|
306
|
+
</span>
|
|
307
|
+
)}
|
|
308
|
+
</>
|
|
176
309
|
)}
|
|
177
|
-
</
|
|
310
|
+
</article>
|
|
178
311
|
)
|
|
179
312
|
}
|
|
180
313
|
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* delete (with confirmation), manual status moves, and a jump to the
|
|
5
5
|
* execution's session transcript.
|
|
6
6
|
*/
|
|
7
|
-
import { useEffect, useState } from 'react'
|
|
7
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
8
8
|
import type { BoardController, ControllerSnapshot } from '../../core/controller.ts'
|
|
9
|
+
import type { TaskBoardExtensionActionRequest, TaskBoardExtensionDispatch } from '../../core/extension.ts'
|
|
10
|
+
import { useTaskBoardSeats } from '../seats.tsx'
|
|
9
11
|
import { isValidCron } from '../../core/schedule.ts'
|
|
10
|
-
import { MANUAL_STATUSES, TASK_PERMISSIONS, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
12
|
+
import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
11
13
|
import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
|
|
12
14
|
import { requiresPermissionConfirmation } from '../../core/handover.ts'
|
|
13
15
|
import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
|
|
@@ -17,11 +19,15 @@ import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
|
|
|
17
19
|
import css from '../board.module.css'
|
|
18
20
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
19
21
|
import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
|
|
22
|
+
import { IconClose, IconPlus, IconSession } from './icons.tsx'
|
|
20
23
|
import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
|
|
21
24
|
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
25
|
+
import { useDialog, usePresence, type OverlayPhase } from './overlay.tsx'
|
|
22
26
|
import { inheritPresetLabel, presetLabel } from './preset-label.ts'
|
|
23
27
|
import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
|
|
24
28
|
import { STATUS_KEY } from './status-key.ts'
|
|
29
|
+
import { VerificationReport } from './VerificationReport.tsx'
|
|
30
|
+
import { ClampedMarkdown } from './ClampedMarkdown.tsx'
|
|
25
31
|
|
|
26
32
|
/** Execution outcome → locale key. */
|
|
27
33
|
const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> = {
|
|
@@ -47,6 +53,7 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
|
|
|
47
53
|
{t('detail.execution.initiator', { session: execution.initiatedBy })}
|
|
48
54
|
</span>
|
|
49
55
|
)}
|
|
56
|
+
{execution.verification !== undefined && <VerificationReport verification={execution.verification} />}
|
|
50
57
|
{execution.sessionId !== undefined && (
|
|
51
58
|
<button
|
|
52
59
|
type="button"
|
|
@@ -54,7 +61,8 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
|
|
|
54
61
|
onClick={() => { onOpen(execution.sessionId as string) }}
|
|
55
62
|
title={execution.sessionId}
|
|
56
63
|
>
|
|
57
|
-
{t('detail.viewSession')}
|
|
64
|
+
{t('detail.viewSession')}
|
|
65
|
+
<IconSession size={13} />
|
|
58
66
|
</button>
|
|
59
67
|
)}
|
|
60
68
|
{execution.error !== undefined && execution.error !== '' && (
|
|
@@ -172,6 +180,16 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
|
|
|
172
180
|
<span>{t('exec.goalRun')}</span>
|
|
173
181
|
</label>
|
|
174
182
|
<p className={css.detailText}>{t('exec.goalRunHint')}</p>
|
|
183
|
+
<label className={css.scheduleToggle}>
|
|
184
|
+
<input
|
|
185
|
+
type="checkbox"
|
|
186
|
+
checked={task.skipVerification === true}
|
|
187
|
+
disabled={pending}
|
|
188
|
+
onChange={event => { controller.updateTask(task.id, { skipVerification: event.target.checked }) }}
|
|
189
|
+
/>
|
|
190
|
+
<span>{t('exec.skipVerification')}</span>
|
|
191
|
+
</label>
|
|
192
|
+
<p className={css.detailText}>{t('exec.skipVerificationHint')}</p>
|
|
175
193
|
<label className={css.scheduleToggle}>
|
|
176
194
|
<input
|
|
177
195
|
type="checkbox"
|
|
@@ -347,6 +365,8 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
|
|
|
347
365
|
}) {
|
|
348
366
|
const [showAdd, setShowAdd] = useState(false)
|
|
349
367
|
const [showLink, setShowLink] = useState(false)
|
|
368
|
+
const addPresence = usePresence(showAdd)
|
|
369
|
+
const linkPresence = usePresence(showLink)
|
|
350
370
|
const tasks = snapshot.tasks
|
|
351
371
|
const parent = task.parentId === undefined ? undefined : tasks.find(candidate => candidate.id === task.parentId)
|
|
352
372
|
const children = directSubtasks(tasks, task.id)
|
|
@@ -388,8 +408,8 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
|
|
|
388
408
|
<button
|
|
389
409
|
type="button"
|
|
390
410
|
className={css.linkButton}
|
|
391
|
-
disabled={pending || child
|
|
392
|
-
title={child
|
|
411
|
+
disabled={pending || hasOpenExecution(child)}
|
|
412
|
+
title={hasOpenExecution(child) ? t('detail.subtasks.runningLock') : undefined}
|
|
393
413
|
onClick={() => { void controller.setParent(child.id, null) }}
|
|
394
414
|
>
|
|
395
415
|
{t('detail.subtasks.detach')}
|
|
@@ -407,7 +427,8 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
|
|
|
407
427
|
? (
|
|
408
428
|
<div className={css.subtaskAddRow}>
|
|
409
429
|
<button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowAdd(true) }}>
|
|
410
|
-
|
|
430
|
+
<IconPlus size={14} />
|
|
431
|
+
{t('detail.subtasks.add')}
|
|
411
432
|
</button>
|
|
412
433
|
<button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowLink(true) }}>
|
|
413
434
|
{t('detail.subtasks.link')}
|
|
@@ -416,18 +437,19 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
|
|
|
416
437
|
)
|
|
417
438
|
: <p className={css.detailMeta}>{t('detail.subtasks.hostOnly')}</p>)
|
|
418
439
|
: <p className={css.detailMeta}>{t('detail.subtasks.depthLimit', { depth: String(limit) })}</p>)}
|
|
419
|
-
{
|
|
420
|
-
<NewTaskModal controller={controller} parentTask={task} onClose={() => { setShowAdd(false) }} />
|
|
440
|
+
{addPresence.mounted && (
|
|
441
|
+
<NewTaskModal controller={controller} parentTask={task} phase={addPresence.phase} onClose={() => { setShowAdd(false) }} />
|
|
421
442
|
)}
|
|
422
|
-
{
|
|
423
|
-
<LinkSubtaskModal controller={controller} parent={task} onClose={() => { setShowLink(false) }} />
|
|
443
|
+
{linkPresence.mounted && (
|
|
444
|
+
<LinkSubtaskModal controller={controller} parent={task} phase={linkPresence.phase} onClose={() => { setShowLink(false) }} />
|
|
424
445
|
)}
|
|
425
446
|
</section>
|
|
426
447
|
)
|
|
427
448
|
}
|
|
428
449
|
|
|
450
|
+
|
|
429
451
|
/** Task detail overlay. */
|
|
430
|
-
export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
452
|
+
export function TaskDetail({ controller, task, phase = 'open' }: { controller: BoardController; task: TaskRecord; phase?: OverlayPhase }) {
|
|
431
453
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
432
454
|
const [showEdit, setShowEdit] = useState(false)
|
|
433
455
|
const [showEditTags, setShowEditTags] = useState(false)
|
|
@@ -443,18 +465,36 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
443
465
|
setShowDuplicate(false)
|
|
444
466
|
}, [task.id])
|
|
445
467
|
const current = latest
|
|
468
|
+
const seats = useTaskBoardSeats()
|
|
469
|
+
const dispatch = useCallback(
|
|
470
|
+
(request: TaskBoardExtensionActionRequest): Promise<boolean> => controller.dispatchExtension(request),
|
|
471
|
+
[controller],
|
|
472
|
+
) as TaskBoardExtensionDispatch
|
|
446
473
|
const snapshot = controller.getSnapshot()
|
|
447
|
-
|
|
474
|
+
// Busy means the runner is executing this card, which is an execution the
|
|
475
|
+
// detail view can see; the column alone never locks a card.
|
|
476
|
+
const busy = hasOpenExecution(current)
|
|
448
477
|
const archived = current.archivedAt !== undefined
|
|
449
478
|
const pending = snapshot.pendingTaskIds.includes(current.id)
|
|
450
479
|
const transportError = snapshot.transportError
|
|
451
480
|
const timeZone = snapshot.host?.scheduler.timeZone
|
|
452
481
|
const permissionPending = requiresPermissionConfirmation(current, snapshot.host?.sessionDefaultPermission)
|
|
453
482
|
const subtaskChildren = directSubtasks(snapshot.tasks, current.id)
|
|
483
|
+
const dialog = useDialog<HTMLDivElement>(() => { controller.closeTask() }, phase)
|
|
484
|
+
// One presence per nested overlay: the detail view sits under up to four of
|
|
485
|
+
// them, and each has to run its own exit leg instead of the surface being
|
|
486
|
+
// pulled out of the DOM the moment its flag flips.
|
|
487
|
+
const editOpen = showEdit && !archived && canEditTaskContent(current)
|
|
488
|
+
const tagsOpen = showEditTags && !archived && !busy
|
|
489
|
+
const duplicateOpen = showDuplicate && !archived
|
|
490
|
+
const confirmPresence = usePresence(confirmDelete)
|
|
491
|
+
const editPresence = usePresence(editOpen)
|
|
492
|
+
const tagsPresence = usePresence(tagsOpen)
|
|
493
|
+
const duplicatePresence = usePresence(duplicateOpen)
|
|
454
494
|
|
|
455
495
|
return (
|
|
456
|
-
<div className={css.modalBackdrop}
|
|
457
|
-
<div className={css.detail} role="dialog" aria-label={t('detail.title')}>
|
|
496
|
+
<div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
|
|
497
|
+
<div ref={dialog.attach} className={css.detail} role="dialog" aria-modal="true" aria-label={t('detail.title')} tabIndex={-1}>
|
|
458
498
|
<header className={css.detailHeader}>
|
|
459
499
|
<h2 className={css.detailTitle}>{current.title}</h2>
|
|
460
500
|
<span className={css.statusBadge} data-status={archived ? 'archived' : current.status}>
|
|
@@ -464,9 +504,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
464
504
|
type="button"
|
|
465
505
|
className={css.iconButton}
|
|
466
506
|
aria-label={t('detail.close')}
|
|
507
|
+
title={t('detail.close')}
|
|
467
508
|
onClick={() => { controller.closeTask() }}
|
|
468
509
|
>
|
|
469
|
-
|
|
510
|
+
<IconClose size={16} />
|
|
470
511
|
</button>
|
|
471
512
|
</header>
|
|
472
513
|
|
|
@@ -481,7 +522,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
481
522
|
)}
|
|
482
523
|
<section className={css.detailSection}>
|
|
483
524
|
<h4>{t('detail.description')}</h4>
|
|
484
|
-
<
|
|
525
|
+
<ClampedMarkdown source={current.description !== '' ? current.description : '—'} />
|
|
485
526
|
</section>
|
|
486
527
|
|
|
487
528
|
<SubtaskSection controller={controller} task={current} pending={pending} archived={archived} snapshot={snapshot} />
|
|
@@ -555,9 +596,11 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
555
596
|
<p className={css.detailMeta}>{t('detail.permissionConfirmed', { time: formatHostTimestamp(current.permissionConfirmedAt, timeZone) })}</p>
|
|
556
597
|
)}
|
|
557
598
|
|
|
599
|
+
{seats.detailSection({ task: current, dispatch })}
|
|
600
|
+
|
|
558
601
|
<section className={css.detailSection}>
|
|
559
602
|
<h4>{t('detail.prompt')}</h4>
|
|
560
|
-
<
|
|
603
|
+
<ClampedMarkdown source={current.prompt !== '' ? current.prompt : current.title} />
|
|
561
604
|
</section>
|
|
562
605
|
|
|
563
606
|
{!archived && (
|
|
@@ -600,7 +643,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
600
643
|
key={status}
|
|
601
644
|
type="button"
|
|
602
645
|
className={css.ghostButton}
|
|
603
|
-
disabled={
|
|
646
|
+
disabled={pending || !canMoveTask(current, status)}
|
|
604
647
|
onClick={() => { controller.moveTask(current.id, status) }}
|
|
605
648
|
>
|
|
606
649
|
{t(`status.move.${status}` as TaskBoardKey)}
|
|
@@ -623,7 +666,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
623
666
|
{t('detail.edit')}
|
|
624
667
|
</button>
|
|
625
668
|
)}
|
|
626
|
-
{!archived && !canEditTaskContent(current) &&
|
|
669
|
+
{!archived && !canEditTaskContent(current) && !busy && (
|
|
627
670
|
<button
|
|
628
671
|
type="button"
|
|
629
672
|
className={css.ghostButton}
|
|
@@ -648,7 +691,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
648
691
|
<button
|
|
649
692
|
type="button"
|
|
650
693
|
className={css.primaryButton}
|
|
651
|
-
disabled={
|
|
694
|
+
disabled={busy || pending}
|
|
652
695
|
title={subtaskChildren.length > 0
|
|
653
696
|
? t('detail.subtasks.runHint', { count: String(subtaskChildren.length) })
|
|
654
697
|
: undefined}
|
|
@@ -701,12 +744,13 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
701
744
|
</footer>
|
|
702
745
|
</div>
|
|
703
746
|
|
|
704
|
-
{
|
|
747
|
+
{confirmPresence.mounted && (
|
|
705
748
|
<ConfirmDialog
|
|
706
749
|
title={t('delete.title')}
|
|
707
750
|
message={t('delete.confirm', { name: current.title })}
|
|
708
751
|
confirmLabel={t('delete.ok')}
|
|
709
752
|
danger
|
|
753
|
+
phase={confirmPresence.phase}
|
|
710
754
|
onCancel={() => { setConfirmDelete(false) }}
|
|
711
755
|
onConfirm={() => {
|
|
712
756
|
setConfirmDelete(false)
|
|
@@ -715,18 +759,19 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
715
759
|
/>
|
|
716
760
|
)}
|
|
717
761
|
|
|
718
|
-
{
|
|
719
|
-
<EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
|
|
762
|
+
{editPresence.mounted && (
|
|
763
|
+
<EditTaskModal controller={controller} task={current} phase={editPresence.phase} onClose={() => { setShowEdit(false) }} />
|
|
720
764
|
)}
|
|
721
765
|
|
|
722
|
-
{
|
|
723
|
-
<EditTagsModal controller={controller} task={current} onClose={() => { setShowEditTags(false) }} />
|
|
766
|
+
{tagsPresence.mounted && (
|
|
767
|
+
<EditTagsModal controller={controller} task={current} phase={tagsPresence.phase} onClose={() => { setShowEditTags(false) }} />
|
|
724
768
|
)}
|
|
725
769
|
|
|
726
|
-
{
|
|
770
|
+
{duplicatePresence.mounted && (
|
|
727
771
|
<NewTaskModal
|
|
728
772
|
controller={controller}
|
|
729
773
|
initialTask={current}
|
|
774
|
+
phase={duplicatePresence.phase}
|
|
730
775
|
onClose={() => { setShowDuplicate(false) }}
|
|
731
776
|
onDuplicateSuccess={async (sourceId) => {
|
|
732
777
|
await controller.archiveTask(sourceId)
|
|
@@ -8,6 +8,8 @@ import { useState, type ReactNode } from 'react'
|
|
|
8
8
|
import { TAG_NAME_MAX_LENGTH, TAG_PROMPT_MAX_LENGTH, TASK_TAG_LIMIT, normalizeTags, type TaskTag } from '../../core/tasks.ts'
|
|
9
9
|
import { t } from '../locales.ts'
|
|
10
10
|
import css from '../board.module.css'
|
|
11
|
+
import { IconClose, IconPlus } from './icons.tsx'
|
|
12
|
+
import { useDialog, type OverlayPhase } from './overlay.tsx'
|
|
11
13
|
|
|
12
14
|
/** DOM id shared by the tag-name inputs and their datalist (one board at a time). */
|
|
13
15
|
const TAG_NAME_LIST_ID = 'dsh-task-board-tag-names'
|
|
@@ -22,6 +24,7 @@ export function ModalShell({
|
|
|
22
24
|
onSubmit,
|
|
23
25
|
onClose,
|
|
24
26
|
secondaryAction,
|
|
27
|
+
phase = 'open',
|
|
25
28
|
children,
|
|
26
29
|
}: {
|
|
27
30
|
ariaLabel: string
|
|
@@ -33,14 +36,20 @@ export function ModalShell({
|
|
|
33
36
|
onClose: () => void
|
|
34
37
|
/** Optional second action beside the primary submit (e.g. create and run). */
|
|
35
38
|
secondaryAction?: { label: string; onSubmit: () => void }
|
|
39
|
+
/** Which leg of the enter/exit motion pair the surface is on. */
|
|
40
|
+
phase?: OverlayPhase
|
|
36
41
|
children: ReactNode
|
|
37
42
|
}) {
|
|
43
|
+
const dialog = useDialog<HTMLFormElement>(onClose, phase)
|
|
38
44
|
return (
|
|
39
|
-
<div className={css.modalBackdrop}
|
|
45
|
+
<div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
|
|
40
46
|
<form
|
|
47
|
+
ref={dialog.attach}
|
|
41
48
|
className={css.modal}
|
|
42
49
|
role="dialog"
|
|
50
|
+
aria-modal="true"
|
|
43
51
|
aria-label={ariaLabel}
|
|
52
|
+
tabIndex={-1}
|
|
44
53
|
onSubmit={event => { event.preventDefault(); onSubmit() }}
|
|
45
54
|
>
|
|
46
55
|
<h2 className={css.modalTitle}>{title}</h2>
|
|
@@ -226,7 +235,7 @@ export function TaskTagFields({
|
|
|
226
235
|
aria-label={t('new.tagRemove', { name: tag.name })}
|
|
227
236
|
onClick={() => { onChange(tags.filter((_, position) => position !== index)) }}
|
|
228
237
|
>
|
|
229
|
-
|
|
238
|
+
<IconClose size={14} />
|
|
230
239
|
</button>
|
|
231
240
|
</div>
|
|
232
241
|
))}
|
|
@@ -239,7 +248,8 @@ export function TaskTagFields({
|
|
|
239
248
|
disabled={tags.length >= TASK_TAG_LIMIT}
|
|
240
249
|
onClick={() => { onChange([...tags, { name: '' }]) }}
|
|
241
250
|
>
|
|
242
|
-
|
|
251
|
+
<IconPlus size={14} />
|
|
252
|
+
{t('new.tagAdd')}
|
|
243
253
|
</button>
|
|
244
254
|
</div>
|
|
245
255
|
)
|