@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.
- package/README.md +32 -0
- package/app/components/board/nodes/TaskCard.vue +45 -212
- package/app/components/layout/AiProvidersBanner.vue +3 -2
- package/app/components/layout/BoardToolbar.vue +24 -26
- package/app/components/layout/BoardTopOverlays.vue +8 -2
- package/app/components/layout/GitHubPatBanner.vue +2 -2
- package/app/components/layout/GitHubPatPermissionsBanner.vue +4 -12
- package/app/components/layout/InfraSetupBanner.vue +6 -7
- package/app/components/layout/SideBar.vue +5 -0
- package/app/components/layout/WorkViewSwitcher.vue +64 -0
- package/app/components/queue/QueueCard.vue +269 -0
- package/app/components/queue/QueueView.vue +192 -0
- package/app/components/setup/SetupChecklistPanel.vue +340 -0
- package/app/composables/useFrameLanes.ts +6 -82
- package/app/composables/useNavContributions.ts +3 -0
- package/app/composables/useSetupChecklist.ts +122 -0
- package/app/composables/useSetupRemedies.ts +72 -0
- package/app/composables/useTaskActions.ts +254 -0
- package/app/composables/useTaskLaneClassifier.ts +88 -0
- package/app/modular/nav-contributions.spec.ts +6 -0
- package/app/modular/nav-contributions.ts +56 -0
- package/app/modular/tutorial-tours.ts +10 -0
- package/app/pages/index.vue +43 -6
- package/app/stores/github/context.ts +1 -0
- package/app/stores/github/probe.ts +7 -3
- package/app/stores/github.ts +9 -0
- package/app/stores/ui/modals.ts +14 -0
- package/app/stores/workView.ts +51 -0
- package/app/stores/workspaceQueue.ts +148 -0
- package/app/utils/queueSections.spec.ts +111 -0
- package/app/utils/queueSections.ts +117 -0
- package/app/utils/setupChecklist.spec.ts +184 -0
- package/app/utils/setupChecklist.ts +229 -0
- package/app/utils/workView.spec.ts +16 -0
- package/app/utils/workView.ts +25 -0
- package/i18n/locales/de.json +132 -3
- package/i18n/locales/en.json +132 -3
- package/i18n/locales/es.json +132 -3
- package/i18n/locales/fr.json +132 -3
- package/i18n/locales/he.json +132 -3
- package/i18n/locales/it.json +132 -3
- package/i18n/locales/ja.json +132 -3
- package/i18n/locales/pl.json +132 -3
- package/i18n/locales/tr.json +132 -3
- package/i18n/locales/uk.json +132 -3
- 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>
|