@cat-factory/app 0.311.3 → 0.313.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.
Files changed (46) hide show
  1. package/README.md +32 -0
  2. package/app/components/board/nodes/TaskCard.vue +45 -212
  3. package/app/components/layout/AiProvidersBanner.vue +3 -2
  4. package/app/components/layout/BoardToolbar.vue +24 -26
  5. package/app/components/layout/BoardTopOverlays.vue +8 -2
  6. package/app/components/layout/GitHubPatBanner.vue +2 -2
  7. package/app/components/layout/GitHubPatPermissionsBanner.vue +4 -12
  8. package/app/components/layout/InfraSetupBanner.vue +6 -7
  9. package/app/components/layout/SideBar.vue +5 -0
  10. package/app/components/layout/WorkViewSwitcher.vue +64 -0
  11. package/app/components/queue/QueueCard.vue +269 -0
  12. package/app/components/queue/QueueView.vue +192 -0
  13. package/app/components/setup/SetupChecklistPanel.vue +340 -0
  14. package/app/composables/useFrameLanes.ts +6 -82
  15. package/app/composables/useNavContributions.ts +3 -0
  16. package/app/composables/useSetupChecklist.ts +122 -0
  17. package/app/composables/useSetupRemedies.ts +72 -0
  18. package/app/composables/useTaskActions.ts +254 -0
  19. package/app/composables/useTaskLaneClassifier.ts +88 -0
  20. package/app/modular/nav-contributions.spec.ts +6 -0
  21. package/app/modular/nav-contributions.ts +56 -0
  22. package/app/modular/tutorial-tours.ts +10 -0
  23. package/app/pages/index.vue +43 -6
  24. package/app/stores/github/context.ts +1 -0
  25. package/app/stores/github/probe.ts +7 -3
  26. package/app/stores/github.ts +9 -0
  27. package/app/stores/ui/modals.ts +14 -0
  28. package/app/stores/workView.ts +51 -0
  29. package/app/stores/workspaceQueue.ts +148 -0
  30. package/app/utils/queueSections.spec.ts +111 -0
  31. package/app/utils/queueSections.ts +117 -0
  32. package/app/utils/setupChecklist.spec.ts +184 -0
  33. package/app/utils/setupChecklist.ts +229 -0
  34. package/app/utils/workView.spec.ts +16 -0
  35. package/app/utils/workView.ts +25 -0
  36. package/i18n/locales/de.json +132 -3
  37. package/i18n/locales/en.json +132 -3
  38. package/i18n/locales/es.json +132 -3
  39. package/i18n/locales/fr.json +132 -3
  40. package/i18n/locales/he.json +132 -3
  41. package/i18n/locales/it.json +132 -3
  42. package/i18n/locales/ja.json +132 -3
  43. package/i18n/locales/pl.json +132 -3
  44. package/i18n/locales/tr.json +132 -3
  45. package/i18n/locales/uk.json +132 -3
  46. package/package.json +1 -1
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import type { WorkView } from '~/utils/workView'
3
+ import SectionLabel from '~/components/common/SectionLabel.vue'
4
+
5
+ /**
6
+ * The sidebar's "Views" section: the queue and the canvas, as two views of one board.
7
+ *
8
+ * A view SWITCHER, not a list of destinations, which is why it is not a `nav` contribution: a
9
+ * contribution opens something, and these change what the main area shows, so they need the
10
+ * selected state a contribution has no shape for. Shown in every tier and every role: which view
11
+ * a person works in is their choice, and neither view is an override of the other.
12
+ */
13
+ defineProps<{ collapsed: boolean }>()
14
+
15
+ const { t } = useI18n()
16
+ const views = useWorkViewStore()
17
+ const queue = useWorkspaceQueueStore()
18
+
19
+ const ITEMS: { view: WorkView; icon: string; labelKey: string }[] = [
20
+ { view: 'queue', icon: 'i-lucide-list-checks', labelKey: 'views.queue' },
21
+ { view: 'board', icon: 'i-lucide-map', labelKey: 'views.board' },
22
+ ]
23
+ </script>
24
+
25
+ <template>
26
+ <section data-testid="work-view-switcher">
27
+ <SectionLabel v-if="!collapsed" as="h2" class="mb-2 px-1">
28
+ {{ t('views.title') }}
29
+ </SectionLabel>
30
+ <div class="space-y-1.5">
31
+ <UButton
32
+ v-for="item in ITEMS"
33
+ :key="item.view"
34
+ :block="!collapsed"
35
+ :square="collapsed"
36
+ size="sm"
37
+ :color="views.view === item.view ? 'primary' : 'neutral'"
38
+ :variant="views.view === item.view ? 'soft' : 'ghost'"
39
+ :icon="item.icon"
40
+ class="w-full"
41
+ :class="collapsed ? 'justify-center' : 'justify-start'"
42
+ :aria-label="collapsed ? t(item.labelKey) : undefined"
43
+ :aria-current="views.view === item.view ? 'page' : undefined"
44
+ :title="collapsed ? t(item.labelKey) : undefined"
45
+ :data-testid="`work-view-${item.view}`"
46
+ @click="views.show(item.view)"
47
+ >
48
+ <template v-if="!collapsed">
49
+ <span class="flex-1 truncate text-start">{{ t(item.labelKey) }}</span>
50
+ <!-- The queue's count: tasks an agent cannot continue until a person acts. -->
51
+ <UBadge
52
+ v-if="item.view === 'queue' && queue.needsYouCount > 0"
53
+ size="sm"
54
+ color="warning"
55
+ variant="subtle"
56
+ data-testid="work-view-queue-count"
57
+ >
58
+ {{ queue.needsYouCount }}
59
+ </UBadge>
60
+ </template>
61
+ </UButton>
62
+ </div>
63
+ </section>
64
+ </template>
@@ -0,0 +1,269 @@
1
+ <script setup lang="ts">
2
+ import { agentKindMeta } from '~/utils/catalog'
3
+ import { formatDuration, useNowTick } from '~/composables/useStepTimer'
4
+ import { useFramePlacement } from '~/composables/useFramePlacement'
5
+ import { LANE_REASON_LABEL_KEYS } from '~/utils/swimlanes'
6
+ import type { QueueEntry } from '~/utils/queueSections'
7
+ import IconButton from '~/components/common/IconButton.vue'
8
+ import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
9
+
10
+ /**
11
+ * One task in the queue: why it is here, where it lives, and the ONE thing to do about it.
12
+ *
13
+ * The board's card answers "what is this task's state"; this card answers "what do I do next",
14
+ * so it carries a single primary action derived from the lane REASON, and the inspector one
15
+ * click away for everything else. The actions themselves are the board card's
16
+ * (`useTaskActions`), so Start, Resolve and Merge behave identically on both surfaces, and a failed
17
+ * run shows the same `AgentFailureCard` the board card shows, with its Retry.
18
+ *
19
+ * Every action is `soft`, as on the board card: the queue lists many cards at once, and a solid
20
+ * button on each would put many primaries in one view.
21
+ */
22
+ const props = defineProps<{ entry: QueueEntry }>()
23
+
24
+ const { t } = useI18n()
25
+ const ui = useUiStore()
26
+ const execution = useExecutionStore()
27
+ const agentRuns = useAgentRunsStore()
28
+ const views = useWorkViewStore()
29
+ const { focusFrame } = useFramePlacement()
30
+ // A shared 30s clock, so "waited 4m" advances on a quiet board instead of freezing until some
31
+ // unrelated event re-evaluates the card.
32
+ const nowTick = useNowTick(30_000)
33
+
34
+ const taskId = () => props.entry.task.id
35
+ const {
36
+ task,
37
+ runnable,
38
+ canExecute,
39
+ executeDeniedTitle,
40
+ unmet,
41
+ sandboxed,
42
+ outcomeReadable,
43
+ openOutcome,
44
+ starting,
45
+ start,
46
+ merge,
47
+ pendingDecision,
48
+ reviewStageLabel,
49
+ attention,
50
+ } = useTaskActions(taskId)
51
+
52
+ const reason = computed(() => props.entry.reason)
53
+ const run = computed(() => execution.getByBlock(props.entry.task.id) ?? null)
54
+ const agentRun = computed(() => agentRuns.byBlock[props.entry.task.id])
55
+ const runFailed = computed(() => agentRun.value?.status === 'failed')
56
+
57
+ /** The eyebrow's time fact: how long a human has kept it waiting, or how fresh the run is. */
58
+ const timeFact = computed(() => {
59
+ const now = nowTick.value
60
+ // The clock reads 0 until the card has mounted and subscribed.
61
+ if (!now) return null
62
+ const e = props.entry
63
+ if (reason.value === 'merged' && e.task.completedAt != null)
64
+ return t('queue.card.merged', { duration: formatDuration(now - e.task.completedAt) })
65
+ if (e.waitingSince != null)
66
+ return t('queue.card.waited', { duration: formatDuration(now - e.waitingSince) })
67
+ if ((reason.value === 'running' || reason.value === 'background_review') && e.activityAt != null)
68
+ return t('queue.card.active', { duration: formatDuration(now - e.activityAt) })
69
+ return null
70
+ })
71
+
72
+ /** The step a live run is on, as "step 5 of 9 · Coder". Null when no run is in flight. */
73
+ const stepFact = computed(() => {
74
+ const r = run.value
75
+ if (!r || r.steps.length === 0) return null
76
+ const step = r.steps[r.currentStep]
77
+ const position = t('queue.card.step', {
78
+ current: Math.min(r.currentStep + 1, r.steps.length),
79
+ total: r.steps.length,
80
+ })
81
+ return step ? `${position} · ${agentKindMeta(step.agentKind).label}` : position
82
+ })
83
+
84
+ /** One line of context: what the human would want to know before pressing the button. */
85
+ const context = computed<string | null>(() => {
86
+ switch (reason.value) {
87
+ case 'decision':
88
+ return pendingDecision.value?.decision.question ?? null
89
+ case 'budget_paused':
90
+ return t('queue.card.budgetPaused')
91
+ case 'parked':
92
+ case 'unclassified':
93
+ return t('queue.card.parked')
94
+ case 'dependencies':
95
+ return t('queue.card.waitingOn', { deps: unmet.value.map((d) => d.title).join(', ') })
96
+ case 'background_review':
97
+ return reviewStageLabel.value
98
+ default:
99
+ return null
100
+ }
101
+ })
102
+
103
+ /**
104
+ * The eyebrow's first word. The lane reason, except in the merge column, whose header already says
105
+ * "ready to merge": there the pull request's own number is the more useful fact.
106
+ */
107
+ const eyebrow = computed(() => {
108
+ const pr = props.entry.task.pullRequest
109
+ if (reason.value === 'pr_awaiting_merge' && pr?.number)
110
+ return t('queue.card.pr', { number: pr.number })
111
+ return t(LANE_REASON_LABEL_KEYS[reason.value])
112
+ })
113
+
114
+ const showProgress = computed(
115
+ () => reason.value === 'running' || reason.value === 'background_review',
116
+ )
117
+
118
+ /** The inspector is showing this card's task. Its border wins over the hover one. */
119
+ const selected = computed(() => ui.selectedBlockId === props.entry.task.id)
120
+
121
+ function openTask() {
122
+ ui.select(props.entry.task.id)
123
+ }
124
+
125
+ /**
126
+ * Leave the queue for the canvas, with this task selected and its service in view. The canvas
127
+ * kept its own camera while the queue covered it, so without the pan the task is usually off
128
+ * screen. A task has no position of its own (it lives in its frame's swimlanes), so the camera
129
+ * centres on the frame.
130
+ */
131
+ function showOnBoard() {
132
+ views.show('board')
133
+ ui.select(props.entry.task.id)
134
+ void focusFrame(props.entry.service?.id ?? props.entry.task.id)
135
+ }
136
+ </script>
137
+
138
+ <template>
139
+ <!-- The whole card opens the task, as the board's card does: a click anywhere on it selects
140
+ the task, and each control inside stops its own click. The title stays a button, so the
141
+ card is reachable by keyboard. -->
142
+ <article
143
+ v-if="task"
144
+ class="rounded-lg border bg-default p-3 transition-colors"
145
+ :class="selected ? 'border-inverted' : 'border-muted hover:border-accented'"
146
+ @click="openTask"
147
+ data-testid="queue-card"
148
+ :data-queue-task="task.id"
149
+ :data-reason="reason"
150
+ >
151
+ <div class="flex items-center gap-1.5 text-3xs uppercase tracking-wide text-dimmed">
152
+ <span
153
+ class="shrink-0 font-semibold whitespace-nowrap"
154
+ :class="
155
+ reason === 'failed'
156
+ ? 'text-error'
157
+ : entry.reason === 'pr_awaiting_merge'
158
+ ? 'text-success'
159
+ : 'text-highlighted'
160
+ "
161
+ >
162
+ {{ eyebrow }}
163
+ </span>
164
+ <span v-if="entry.service" class="min-w-0 truncate">· {{ entry.service.title }}</span>
165
+ <span v-if="timeFact" class="shrink-0 whitespace-nowrap">· {{ timeFact }}</span>
166
+ <IconButton
167
+ class="ms-auto shrink-0"
168
+ color="neutral"
169
+ variant="ghost"
170
+ size="xs"
171
+ icon="i-lucide-map"
172
+ :label="t('queue.card.showOnBoard')"
173
+ @click.stop="showOnBoard"
174
+ />
175
+ </div>
176
+
177
+ <!-- The card's keyboard target: Enter on it opens the task, as a click on the card does. -->
178
+ <UButton
179
+ color="neutral"
180
+ variant="link"
181
+ class="mt-1 w-full p-0 text-start text-sm font-semibold leading-snug text-highlighted"
182
+ data-testid="queue-card-title"
183
+ @click.stop="openTask"
184
+ >
185
+ {{ task.title }}
186
+ </UButton>
187
+
188
+ <p v-if="context" class="mt-1 line-clamp-2 text-xs text-muted" :title="context">
189
+ {{ context }}
190
+ </p>
191
+
192
+ <!-- A failed run: the same failure banner and Retry the board card shows. -->
193
+ <AgentFailureCard
194
+ v-if="reason === 'failed' && runFailed && agentRun"
195
+ :run="agentRun"
196
+ variant="compact"
197
+ class="mt-2"
198
+ />
199
+
200
+ <div v-if="showProgress" class="mt-2 space-y-1">
201
+ <UProgress :model-value="Math.round(task.progress * 100)" size="xs" />
202
+ <p v-if="stepFact" class="text-2xs text-dimmed">{{ stepFact }}</p>
203
+ </div>
204
+
205
+ <div class="mt-3 flex flex-wrap items-center gap-2 empty:hidden">
206
+ <!-- Parked on a decision or an approval: the gate itself, one click away. -->
207
+ <UButton
208
+ v-if="attention"
209
+ color="warning"
210
+ variant="soft"
211
+ size="xs"
212
+ :icon="attention.icon"
213
+ data-testid="queue-card-primary"
214
+ @click.stop="attention.open()"
215
+ >
216
+ {{ reason === 'decision' ? t('queue.card.answer') : attention.action }}
217
+ </UButton>
218
+
219
+ <template v-else-if="reason === 'pr_awaiting_merge'">
220
+ <UButton
221
+ v-if="outcomeReadable"
222
+ variant="soft"
223
+ size="xs"
224
+ icon="i-lucide-clipboard-check"
225
+ data-testid="queue-card-primary"
226
+ @click.stop="openOutcome"
227
+ >
228
+ {{ t('board.task.readOutcome') }}
229
+ </UButton>
230
+ <UButton
231
+ color="success"
232
+ :variant="outcomeReadable ? 'outline' : 'soft'"
233
+ size="xs"
234
+ icon="i-lucide-git-merge"
235
+ :disabled="!canExecute"
236
+ :title="executeDeniedTitle"
237
+ @click.stop="merge"
238
+ >
239
+ {{ t('board.task.merge') }}
240
+ </UButton>
241
+ </template>
242
+
243
+ <UButton
244
+ v-else-if="reason === 'unstarted'"
245
+ variant="soft"
246
+ size="xs"
247
+ :icon="sandboxed ? 'i-lucide-shield' : 'i-lucide-play'"
248
+ :loading="starting"
249
+ :disabled="!runnable || starting || !canExecute"
250
+ :title="executeDeniedTitle"
251
+ data-testid="queue-card-primary"
252
+ @click.stop="start"
253
+ >
254
+ {{ starting ? t('board.task.starting') : t('board.task.start') }}
255
+ </UButton>
256
+
257
+ <UButton
258
+ v-else-if="reason === 'merged' && outcomeReadable"
259
+ size="xs"
260
+ variant="soft"
261
+ icon="i-lucide-clipboard-check"
262
+ data-testid="queue-card-primary"
263
+ @click.stop="openOutcome"
264
+ >
265
+ {{ t('board.task.readOutcome') }}
266
+ </UButton>
267
+ </div>
268
+ </article>
269
+ </template>
@@ -0,0 +1,192 @@
1
+ <script setup lang="ts">
2
+ import type { QueueEntry } from '~/utils/queueSections'
3
+ import QueueCard from '~/components/queue/QueueCard.vue'
4
+ import NotificationsInbox from '~/components/layout/NotificationsInbox.vue'
5
+ import SectionLabel from '~/components/common/SectionLabel.vue'
6
+
7
+ /**
8
+ * The queue: every task across every service, in the column that says what it needs. One of the
9
+ * board's two views (issue #2258); the canvas is the other.
10
+ *
11
+ * Three columns, read left to right in order of urgency: what is stalled on you, what agents are
12
+ * doing, what is finished and waiting to land. Work nobody has started sits under "In flight" as
13
+ * a short list, and recent merges sit under "Ready to merge" behind a count, because neither is
14
+ * what a person opening the app came to act on first. The canvas stays one click away as the
15
+ * planning view.
16
+ */
17
+ const { t, d } = useI18n()
18
+ const workspaceSettings = useWorkspaceSettingsStore()
19
+ const ui = useUiStore()
20
+ const board = useBoardStore()
21
+
22
+ /**
23
+ * Where the Done window starts: the workspace's Done-lane retention, the same one each frame's
24
+ * Done lane applies. Stated as a date, so no sentence has to agree with a number of days. Null
25
+ * when the workspace keeps no age cap.
26
+ */
27
+ const doneSince = computed(() => {
28
+ const days = workspaceSettings.settings.doneLaneRetentionDays
29
+ return days == null ? null : new Date(Date.now() - days * 86_400_000)
30
+ })
31
+ const queueStore = useWorkspaceQueueStore()
32
+
33
+ const sections = computed(() => queueStore.queue.sections)
34
+ const doneOpen = ref(false)
35
+
36
+ const serviceItems = computed(() => [
37
+ { label: t('queue.filter.allServices'), value: '__all__' },
38
+ ...queueStore.services.map((s) => ({ label: s.title, value: s.id })),
39
+ ])
40
+ const serviceValue = computed({
41
+ get: () => queueStore.serviceFilter ?? '__all__',
42
+ set: (v: string) => (queueStore.serviceFilter = v === '__all__' ? null : v),
43
+ })
44
+
45
+ interface Column {
46
+ id: 'needs_you' | 'in_flight' | 'ready_to_merge'
47
+ icon: string
48
+ entries: QueueEntry[]
49
+ }
50
+ const columns = computed<Column[]>(() => [
51
+ { id: 'needs_you', icon: 'i-lucide-hand', entries: sections.value.needs_you },
52
+ { id: 'in_flight', icon: 'i-lucide-loader', entries: sections.value.in_flight },
53
+ { id: 'ready_to_merge', icon: 'i-lucide-git-merge', entries: sections.value.ready_to_merge },
54
+ ])
55
+
56
+ const COLUMN_COPY: Record<Column['id'], { label: string; empty: string }> = {
57
+ needs_you: { label: 'queue.columns.needsYou.label', empty: 'queue.columns.needsYou.empty' },
58
+ in_flight: { label: 'queue.columns.inFlight.label', empty: 'queue.columns.inFlight.empty' },
59
+ ready_to_merge: {
60
+ label: 'queue.columns.readyToMerge.label',
61
+ empty: 'queue.columns.readyToMerge.empty',
62
+ },
63
+ }
64
+
65
+ const COLUMN_ACCENT: Record<Column['id'], string> = {
66
+ needs_you: 'text-warning',
67
+ in_flight: 'text-primary',
68
+ ready_to_merge: 'text-success',
69
+ }
70
+
71
+ /**
72
+ * Whether the inspector is on screen beside the queue. On a wide screen it floats over the
73
+ * board's right edge (`InspectorPanel`, `lg:w-80` at `lg:end-4`), which is where the queue's
74
+ * last column sits, so the columns give it that width (20rem, its 1rem offset and a 1rem gap)
75
+ * rather than letting it cover them. Below `lg` it is a bottom sheet the reader opens and closes.
76
+ */
77
+ const inspectorOpen = computed(() => !!ui.selectedBlockId && !!board.getBlock(ui.selectedBlockId))
78
+ </script>
79
+
80
+ <template>
81
+ <div class="flex h-full flex-col overflow-hidden" data-testid="queue-view">
82
+ <!-- `ps-14` below lg leaves room for the drawer's menu button in the corner. -->
83
+ <header
84
+ class="flex flex-wrap items-center gap-x-4 gap-y-2 ps-14 pe-4 pt-4 pb-3 sm:pe-6 lg:ps-6"
85
+ >
86
+ <div class="flex items-baseline gap-2">
87
+ <h1 class="text-xl font-semibold text-highlighted">{{ t('queue.title') }}</h1>
88
+ <span class="text-sm text-muted" data-testid="queue-summary">
89
+ {{
90
+ t('queue.summary', {
91
+ needsYou: sections.needs_you.length,
92
+ ready: sections.ready_to_merge.length,
93
+ })
94
+ }}
95
+ </span>
96
+ </div>
97
+ <div class="ms-auto flex flex-wrap items-center gap-2">
98
+ <USelect
99
+ v-model="serviceValue"
100
+ :items="serviceItems"
101
+ size="sm"
102
+ class="w-48"
103
+ :aria-label="t('queue.filter.service')"
104
+ data-testid="queue-service-filter"
105
+ />
106
+ <!-- The inbox still holds what is NOT a task: platform alerts, budget thresholds, key
107
+ drift. The canvas shows it in its toolbar, which this view does not have. -->
108
+ <NotificationsInbox />
109
+ </div>
110
+ </header>
111
+
112
+ <div
113
+ class="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto px-4 pb-6 transition-[padding] sm:px-6 md:grid-cols-3 md:overflow-hidden"
114
+ :class="inspectorOpen ? 'lg:pe-[22rem]' : ''"
115
+ >
116
+ <section
117
+ v-for="column in columns"
118
+ :key="column.id"
119
+ class="flex min-h-0 flex-col rounded-xl border border-muted bg-elevated/40"
120
+ data-testid="queue-column"
121
+ :data-column="column.id"
122
+ >
123
+ <div class="flex items-center gap-2 px-3 pt-3 pb-2">
124
+ <UIcon :name="column.icon" class="h-4 w-4" :class="COLUMN_ACCENT[column.id]" />
125
+ <SectionLabel as="h2">{{ t(COLUMN_COPY[column.id].label) }}</SectionLabel>
126
+ <UBadge class="ms-auto" color="neutral" variant="subtle" size="sm">
127
+ {{ column.entries.length }}
128
+ </UBadge>
129
+ </div>
130
+
131
+ <div class="min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pb-3">
132
+ <QueueCard v-for="entry in column.entries" :key="entry.task.id" :entry="entry" />
133
+ <p v-if="column.entries.length === 0" class="px-1 py-6 text-center text-xs text-dimmed">
134
+ {{ t(COLUMN_COPY[column.id].empty) }}
135
+ </p>
136
+
137
+ <!-- Not started: under In flight, because it is the next thing that column will hold. -->
138
+ <template v-if="column.id === 'in_flight'">
139
+ <USeparator class="my-3" />
140
+ <SectionLabel as="h3" class="px-1">
141
+ {{ t('queue.waiting.label', { count: sections.waiting.length }) }}
142
+ </SectionLabel>
143
+ <QueueCard v-for="entry in sections.waiting" :key="entry.task.id" :entry="entry" />
144
+ <p v-if="sections.waiting.length === 0" class="px-1 text-xs text-dimmed">
145
+ {{ t('queue.waiting.empty') }}
146
+ </p>
147
+ </template>
148
+
149
+ <!-- Recent merges: a count, and the list on request. The queue is about now. -->
150
+ <template v-if="column.id === 'ready_to_merge'">
151
+ <USeparator class="my-3" />
152
+ <div class="flex items-center gap-2 px-1 text-xs text-muted">
153
+ <span data-testid="queue-done-count">
154
+ {{
155
+ doneSince
156
+ ? t('queue.done.count', {
157
+ count: queueStore.queue.doneInWindow,
158
+ date: d(doneSince, 'short'),
159
+ })
160
+ : t('queue.done.countAll', { count: queueStore.queue.doneInWindow })
161
+ }}
162
+ </span>
163
+ <UButton
164
+ v-if="queueStore.queue.doneInWindow > 0"
165
+ variant="link"
166
+ size="xs"
167
+ class="p-0"
168
+ @click="doneOpen = !doneOpen"
169
+ >
170
+ {{ doneOpen ? t('queue.done.hide') : t('queue.done.show') }}
171
+ </UButton>
172
+ </div>
173
+ <template v-if="doneOpen">
174
+ <QueueCard v-for="entry in sections.done" :key="entry.task.id" :entry="entry" />
175
+ <p
176
+ v-if="queueStore.queue.doneInWindow > sections.done.length"
177
+ class="px-1 text-2xs text-dimmed"
178
+ >
179
+ {{
180
+ t('queue.done.capped', {
181
+ shown: sections.done.length,
182
+ total: queueStore.queue.doneInWindow,
183
+ })
184
+ }}
185
+ </p>
186
+ </template>
187
+ </template>
188
+ </div>
189
+ </section>
190
+ </div>
191
+ </div>
192
+ </template>