@cat-factory/app 0.311.3 → 0.312.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.
@@ -0,0 +1,254 @@
1
+ import { computed, ref } from 'vue'
2
+ import { defaultBuildPipelineId } from '@cat-factory/contracts'
3
+ import type { Block } from '~/types/domain'
4
+ import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
5
+
6
+ /**
7
+ * What a task offers a human to DO, and the handlers that do it.
8
+ *
9
+ * Shared by the two surfaces that put a task's action within one click: the board's task card
10
+ * and the queue's card. Both must offer the same Start (same pipeline), the same Resolve (same
11
+ * gate) and the same Merge (same confirm), or a reader would learn two products.
12
+ */
13
+ export function useTaskActions(taskId: () => string) {
14
+ const board = useBoardStore()
15
+ const execution = useExecutionStore()
16
+ const ui = useUiStore()
17
+ const reviews = useReviewStage()
18
+ const toast = useActionToast()
19
+ const { t } = useI18n()
20
+ const { confirm } = useConfirm()
21
+ const access = useWorkspaceAccess()
22
+
23
+ const task = computed<Block | undefined>(() => board.getBlock(taskId()))
24
+
25
+ // This Start is a ONE-TAP live start, so it has nothing to ask for and offers no dry-run
26
+ // request. What it does owe the reader is the half that is not a choice: a preset that sandboxes
27
+ // their role means this button opens a pull request and merges nothing, and a card that said so
28
+ // only after the fact would leave that to be discovered from a run that stops at the merge.
29
+ const { forcedFor } = useDryRunPolicy()
30
+ const sandboxed = computed(() => forcedFor(taskId()))
31
+
32
+ /** Deps that haven't merged yet: these block this task from running. */
33
+ const unmet = computed(() => board.unmetDeps(taskId()))
34
+ const runnable = computed(() => board.isRunnable(taskId()))
35
+
36
+ // Start and Merge both need `runs.execute`. A read-only member sees them disabled with the reason,
37
+ // the way the shared failure banner treats Retry, rather than pressing a button the server
38
+ // refuses. Asked once here so the two cards cannot gate them differently.
39
+ const canExecute = computed(() => access.canExecuteRuns.value)
40
+ const executeDeniedTitle = computed(() =>
41
+ canExecute.value ? undefined : t('access.noRunExecute'),
42
+ )
43
+
44
+ const pipelineFor = useStartPipelineResolver()
45
+ const defaultPipeline = computed(() => pipelineFor(task.value))
46
+
47
+ /**
48
+ * Whether the outcome summary has something to show, asked of the SAME reduction the window
49
+ * renders and the inspector's button gates on: a surface that offered "read the result" on a
50
+ * task whose every section says "nothing here" would teach people the surface is empty. A task
51
+ * marked done by hand, with no pull request and no run, is that task.
52
+ */
53
+ const outcomeReadable = computed(() => {
54
+ const block = task.value
55
+ if (!block) return false
56
+ return hasOutcomeToShow(
57
+ composeRunOutcome({ block, instance: execution.getInstance(block.executionId) ?? null }),
58
+ )
59
+ })
60
+
61
+ function openOutcome() {
62
+ ui.openOutcome(taskId(), task.value?.executionId ?? null)
63
+ }
64
+
65
+ // Optimistic "Start": flip the button into a spinning "Starting…" state the instant it's
66
+ // clicked, before the server confirms. The button naturally unmounts once the stream pushes the
67
+ // block into `in_progress`; if the start call faults we revert and surface a toast.
68
+ const starting = ref(false)
69
+
70
+ async function start() {
71
+ if (!runnable.value) {
72
+ // A refusal decided in the browser, in translated copy: the `error` tone (`useActionToast`).
73
+ toast.error('board.task.blockedByDependenciesTitle', {
74
+ description: t('board.task.waitingOn', {
75
+ deps: unmet.value.map((d) => d.title).join(', '),
76
+ }),
77
+ })
78
+ return
79
+ }
80
+ const pipeline = defaultPipeline.value
81
+ if (!pipeline) {
82
+ toast.error('board.task.noPipelineTitle', { description: t('board.task.noPipelineBody') })
83
+ return
84
+ }
85
+ starting.value = true
86
+ // false ⇒ the run never started (the user cancelled the personal-password prompt, or the
87
+ // start was refused: the store surfaces the actionable toast itself). Revert the optimistic
88
+ // state; on success the button unmounts once the stream pushes in_progress.
89
+ const started = await execution.start(taskId(), pipeline)
90
+ if (started) {
91
+ // Confirm the (optimistic) start landed: the button unmounts once the stream pushes
92
+ // in_progress, so without this the successful action gives no feedback. A sandboxed start
93
+ // says so here rather than borrowing the live wording: this is the moment the reader learns
94
+ // what they just started, and the two runs differ in what they will end up doing.
95
+ // A sandboxed run is `warning`: it ran, and its outcome differs in a way the reader should
96
+ // know (it opens a pull request and merges nothing), so it stays until closed.
97
+ if (sandboxed.value) {
98
+ toast.warning('board.dryRunToast.title', {
99
+ description: t('board.dryRunToast.body', { name: pipeline.name }),
100
+ })
101
+ } else {
102
+ toast.success('board.task.startedToast.title', {
103
+ description: t('board.task.startedToast.body', { name: pipeline.name }),
104
+ })
105
+ }
106
+ } else {
107
+ starting.value = false
108
+ }
109
+ }
110
+
111
+ async function merge() {
112
+ // Merging a PR into its base is consequential and effectively irreversible: gate it behind a
113
+ // confirm (plain, not the destructive shape). `execution.mergePr` surfaces its own error toast.
114
+ const ok = await confirm({
115
+ title: t('board.task.mergeConfirm.title'),
116
+ description: t('board.task.mergeConfirm.body'),
117
+ confirmLabel: t('board.task.mergeConfirm.confirm'),
118
+ icon: 'i-lucide-git-merge',
119
+ })
120
+ if (!ok) return
121
+ await execution.mergePr(taskId())
122
+ }
123
+
124
+ // A `blocked` task is waiting on a human for one of two reasons, an agent-raised decision OR an
125
+ // approval gate, and both must surface (a failed run is shown separately by the
126
+ // AgentFailureCard). Read off the per-block index rather than scanning the workspace-wide list:
127
+ // this computed is mounted once per card and invalidated by every execution event.
128
+ const pendingDecision = computed(() => execution.decisionsByBlock.get(taskId())?.[0])
129
+ // The async stage an iterative reviewer gate (requirements-review / clarity-review) is mid-cycle
130
+ // in (folding the answers, then re-reviewing), or null. While set, the gate needs NO human
131
+ // action, so its approval is suppressed below and a working indicator shows instead.
132
+ const reviewStage = computed(() => reviews.stageForBlock(taskId()))
133
+ const reviewStageLabel = computed(() =>
134
+ reviewStage.value === 'incorporating'
135
+ ? t('board.task.incorporatingAnswers')
136
+ : reviewStage.value === 'reviewing'
137
+ ? t('board.task.reReviewing')
138
+ : reviewStage.value === 'recommending'
139
+ ? t('board.task.recommending')
140
+ : null,
141
+ )
142
+ const pendingApproval = computed(() => {
143
+ // A reviewer gate whose review is incorporating / re-reviewing in the driver is doing
144
+ // background work, not awaiting a human: skip it, and offer the first gate a human IS
145
+ // awaiting. The lane classifier counts any such gate, so a card filed under Needs you for an
146
+ // approval always has that approval to open.
147
+ return execution.approvalsByBlock
148
+ .get(taskId())
149
+ ?.find((a) => !reviews.isBackground(a.agentKind, taskId()))
150
+ })
151
+
152
+ /**
153
+ * What this blocked task actually needs from a human: drives the card's label, pulse and
154
+ * action. Decision takes precedence over approval (a step never holds both at once; this is
155
+ * just a stable order). Null when nothing is pending.
156
+ */
157
+ const attention = computed<{
158
+ label: string
159
+ icon: string
160
+ action: string
161
+ open: () => void
162
+ } | null>(() => {
163
+ const d = pendingDecision.value
164
+ if (d)
165
+ return {
166
+ label: t('board.task.decisionNeeded'),
167
+ icon: 'i-lucide-circle-help',
168
+ action: t('board.task.resolve'),
169
+ open: () => ui.openDecision(d.instanceId, d.decision.id),
170
+ }
171
+ const a = pendingApproval.value
172
+ if (a)
173
+ return {
174
+ label: t('board.task.approvalNeeded'),
175
+ icon: 'i-lucide-shield-check',
176
+ action: t('board.task.approve'),
177
+ open: () => ui.openApprovalDetail(a.instanceId, a.approval.id),
178
+ }
179
+ return null
180
+ })
181
+
182
+ return {
183
+ task,
184
+ sandboxed,
185
+ unmet,
186
+ runnable,
187
+ canExecute,
188
+ executeDeniedTitle,
189
+ defaultPipeline,
190
+ outcomeReadable,
191
+ openOutcome,
192
+ starting,
193
+ start,
194
+ merge,
195
+ pendingDecision,
196
+ reviewStage,
197
+ reviewStageLabel,
198
+ attention,
199
+ }
200
+ }
201
+
202
+ /** The pipeline a plain "Start" runs, as the descriptor `execution.start` takes. */
203
+ export interface StartPipeline {
204
+ id: string
205
+ name: string
206
+ }
207
+
208
+ /**
209
+ * Resolve the pipeline a plain "Start" uses for a task. A function rather than a computed so
210
+ * a caller can ask it about any task, not only about the one its card shows.
211
+ */
212
+ export function useStartPipelineResolver() {
213
+ const pipelines = usePipelinesStore()
214
+ const uiMode = useUiModeStore()
215
+ const { t } = useI18n()
216
+
217
+ /**
218
+ * The pipeline a plain "Start" will use: the task's pinned pipeline, else the build rung this
219
+ * INTERFACE MODE defaults to (`defaultBuildPipelineId`: the fixed Standard build in basic mode,
220
+ * the Adaptive one in advanced). The workspace's positional first pipeline remains the last
221
+ * resort, for a board whose catalog does not carry the rung (an older seed, or a deployment that
222
+ * retired it).
223
+ *
224
+ * A PIN is honoured even when the library holds no row for it, and that branch is the whole reason
225
+ * this returns a descriptor rather than a `Pipeline`. An INTERNAL pipeline is withheld from the
226
+ * library on purpose (the platform starts it on its own behalf, so no picker may offer it), and a
227
+ * task can legitimately be pinned to one: the docs-refresh preset spawns its tasks onto
228
+ * `pl_code_comments`. Resolving that pin through the library alone answers undefined, and the
229
+ * fallback below then starts a FULL BUILD on a comment-only task while the button still reads as
230
+ * an ordinary Start. The fallback chain exists for a task with NO pin; a pin the library cannot
231
+ * show is still the task's answer, and the backend resolves the id for the run.
232
+ */
233
+ return (task: Pick<Block, 'pipelineId'> | undefined): StartPipeline | undefined => {
234
+ const pinnedId = task?.pipelineId
235
+ if (pinnedId) {
236
+ return (
237
+ pipelines.getPipeline(pinnedId) ?? {
238
+ id: pinnedId,
239
+ // The catalog NAME map spans the whole catalog (unlike the versions map), so an internal
240
+ // pin still names itself here; the generic label covers a pin to something this build's
241
+ // catalog does not know at all.
242
+ name: pipelines.catalogNames[pinnedId] ?? t('board.task.pipelineFallback'),
243
+ }
244
+ )
245
+ }
246
+ // No pin: the workspace's own DECLARED in-app default outranks the interface-mode rung, because
247
+ // an operator who named one said something a tier cannot overrule.
248
+ const declared = pipelines.declaredDefaultId('interactive')
249
+ return (
250
+ pipelines.getPipeline(declared ?? defaultBuildPipelineId(uiMode.isAdvanced)) ??
251
+ pipelines.pipelines[0]
252
+ )
253
+ }
254
+ }
@@ -0,0 +1,88 @@
1
+ import type { Block } from '~/types/domain'
2
+ import { runActivityAt, runWaitingSince, type LaneTaskEntry } from '~/utils/laneSort'
3
+ import { classifyTask, type TaskLane } from '~/utils/swimlanes'
4
+
5
+ /** A lane entry plus the lane it was classified into. */
6
+ export interface ClassifiedEntry {
7
+ readonly entry: LaneTaskEntry
8
+ readonly lane: TaskLane
9
+ }
10
+
11
+ /**
12
+ * The store-facing half of `classifyTask`: resolve one task's lane, reason and rendered entry.
13
+ *
14
+ * Shared by the two surfaces that lay tasks out in lanes, a service frame's swimlanes
15
+ * (`useFrameLanes`) and the workspace-wide queue (`useWorkspaceQueueStore`), so both state the SAME
16
+ * claim about a task. A card in "Needs you" on the board and in "In flight" in the queue would
17
+ * make the reader arbitrate between two answers to one question.
18
+ *
19
+ * Every cross-block lookup here is a Map read off an index a STORE maintains, never a reduction
20
+ * of its own: `useFrameLanes` runs one instance per mounted frame, so deriving a workspace-wide
21
+ * fact here makes a board with n frames pay for it n times on every change. The review-debt map
22
+ * (`notifications.reviewDebtByBlock`) is the worked example: a reduction over the whole
23
+ * workspace's open notifications, and it belongs on the store that owns its input.
24
+ */
25
+ export function useTaskLaneClassifier() {
26
+ const board = useBoardStore()
27
+ const execution = useExecutionStore()
28
+ const agentRuns = useAgentRunsStore()
29
+ const notifications = useNotificationsStore()
30
+ const reviews = useReviewStage()
31
+
32
+ /**
33
+ * The module a task belongs to: the module BLOCK's title when it already lives in one, else
34
+ * the module it DECLARES. The engine only materialises the block on merge
35
+ * (`applyModuleAssignment`), so keying on the parent alone would leave every unmerged task in
36
+ * "no module" while its own card names one.
37
+ */
38
+ function moduleNameOf(task: Block): string | null {
39
+ const parent = task.parentId ? board.getBlock(task.parentId) : undefined
40
+ if (parent?.level === 'module') return parent.title
41
+ return task.moduleName?.trim() || null
42
+ }
43
+
44
+ /** One task's lane, reason and rendered entry. `order` is the caller's stable tiebreak. */
45
+ function classify(task: Block, order: number): ClassifiedEntry {
46
+ const run = execution.getByBlock(task.id) ?? null
47
+ const decisions = execution.decisionsByBlock.get(task.id) ?? []
48
+ const allApprovals = execution.approvalsByBlock.get(task.id) ?? []
49
+ // The same suppression the card and the frame badge apply: an iterative reviewer mid-cycle
50
+ // holds a pending approval while the driver folds answers in, and nobody is waiting on it.
51
+ const humanApprovals = allApprovals.filter((a) => !reviews.isBackground(a.agentKind, a.blockId))
52
+
53
+ const { lane, reason } = classifyTask({
54
+ status: task.status,
55
+ // Read from the coarse per-block summary, which also covers a bootstrap run.
56
+ runFailed: agentRuns.byBlock[task.id]?.status === 'failed',
57
+ run,
58
+ // A park is background exactly when everything asking was suppressed AND nothing else
59
+ // asks. With no approvals at all it is NOT background: it is a park on a surface this
60
+ // layer cannot name, which `classifyTask` reports as `parked` rather than as work.
61
+ parkIsBackground:
62
+ decisions.length === 0 &&
63
+ humanApprovals.length === 0 &&
64
+ allApprovals.length > humanApprovals.length,
65
+ pendingDecision: decisions.length > 0,
66
+ pendingApproval: humanApprovals.length > 0,
67
+ hasUnmetDeps: board.unmetDeps(task.id).length > 0,
68
+ })
69
+
70
+ return {
71
+ lane,
72
+ entry: {
73
+ task,
74
+ reason,
75
+ order,
76
+ activityAt: runActivityAt(run),
77
+ waitingSince: runWaitingSince(run, notifications.reviewDebtByBlock.get(task.id) ?? null),
78
+ moduleName: moduleNameOf(task),
79
+ initiativeName: task.initiativeId
80
+ ? (board.getBlock(task.initiativeId)?.title ?? null)
81
+ : null,
82
+ epicName: board.epicOf(task)?.title ?? null,
83
+ },
84
+ }
85
+ }
86
+
87
+ return { classify }
88
+ }
@@ -224,6 +224,8 @@ describe('navSlotFilter', () => {
224
224
  tutorial: 'the walkthroughs - the surface with the fewest destinations needs them most',
225
225
  'keyboard-shortcuts': 'the cheatsheet covers the board and the palette, which every role has',
226
226
  'ui-role': 'the way BACK out of the narrowed role',
227
+ 'queue-view': 'which view of the board a person works in is their own choice, in every role',
228
+ 'board-view': 'the way back to the canvas from the queue view, in every role',
227
229
  }
228
230
  const intake = NAV_CONTRIBUTIONS.filter((i) => i.intake).map((i) => i.id)
229
231
  expect(intake.sort()).toEqual(Object.keys(REASON).sort())
@@ -522,6 +524,8 @@ describe('nav grouping helpers', () => {
522
524
  'ui-role',
523
525
  'tutorial',
524
526
  'foundational-services',
527
+ 'queue-view',
528
+ 'board-view',
525
529
  ])
526
530
  })
527
531
 
@@ -220,6 +220,8 @@ export const NAV_ACTIONS = [
220
220
  'tutorial',
221
221
  'toggleUiMode',
222
222
  'chooseRole',
223
+ 'queueView',
224
+ 'boardView',
223
225
  ] as const
224
226
 
225
227
  export type NavActionId = (typeof NAV_ACTIONS)[number]
@@ -737,6 +739,35 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
737
739
  keywordsKey: 'layout.commandBar.keywords.chooseRole',
738
740
  },
739
741
  },
742
+ {
743
+ // The board's two views (issue #2258). The sidebar's Views switcher is the everyday route;
744
+ // these make each view reachable by name from the palette too. Every tier and role: which view
745
+ // a person works in is their own choice, and neither view is an override of the other.
746
+ id: 'queue-view',
747
+ labelKey: 'layout.commandBar.cmd.queueView',
748
+ icon: 'i-lucide-list-checks',
749
+ surfaces: S('command'),
750
+ intake: true,
751
+ action: 'queueView',
752
+ command: {
753
+ group: 'workspace',
754
+ order: 130,
755
+ keywordsKey: 'layout.commandBar.keywords.queueView',
756
+ },
757
+ },
758
+ {
759
+ id: 'board-view',
760
+ labelKey: 'layout.commandBar.cmd.boardView',
761
+ icon: 'i-lucide-map',
762
+ surfaces: S('command'),
763
+ intake: true,
764
+ action: 'boardView',
765
+ command: {
766
+ group: 'workspace',
767
+ order: 131,
768
+ keywordsKey: 'layout.commandBar.keywords.boardView',
769
+ },
770
+ },
740
771
  ]
741
772
 
742
773
  /**
@@ -194,6 +194,16 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
194
194
  titleKey: 'tutorial.tours.boardBasics.steps.canvas.title',
195
195
  bodyKey: 'tutorial.tours.boardBasics.steps.canvas.body',
196
196
  },
197
+ {
198
+ // A new person lands on the QUEUE view, and every tour moves them to the canvas, so the
199
+ // orientation tour is where they learn the queue exists. The switcher renders in the rail
200
+ // and the expanded sidebar, in every tier and role, so the step needs no `when`.
201
+ id: 'views',
202
+ target: 'work-view-switcher',
203
+ placement: 'right',
204
+ titleKey: 'tutorial.tours.boardBasics.steps.views.title',
205
+ bodyKey: 'tutorial.tours.boardBasics.steps.views.body',
206
+ },
197
207
  {
198
208
  id: 'sidebar',
199
209
  target: 'sidebar',
@@ -159,12 +159,15 @@ const TutorialNudge = defineAsyncView(() => import('~/components/tutorial/Tutori
159
159
  // The first-run role question (Engineer / Product manager / Designer). Same shape as the tutorial
160
160
  // launch prompt: mounted only while its store flag is set, so an answered question costs nothing.
161
161
  const RolePrompt = defineAsyncView(() => import('~/components/layout/RolePrompt.vue'))
162
+ // The queue: the board's second view (issue #2258), loaded the first time someone opens it.
163
+ const QueueView = defineAsyncView(() => import('~/components/queue/QueueView.vue'))
162
164
 
163
165
  const workspace = useWorkspaceStore()
164
166
  const github = useGitHubStore()
165
167
  const models = useModelsStore()
166
168
  const ui = useUiStore()
167
169
  const aiReadiness = useAiReadiness()
170
+ const views = useWorkViewStore()
168
171
 
169
172
  // App-wide keyboard shortcuts (Escape to deselect, Delete to remove the selected block, ?
170
173
  // for the cheatsheet). Registered ONCE here so a single global listener owns them.
@@ -176,6 +179,8 @@ onMounted(() => {
176
179
  // board it names is the one that opens. This is what makes the observability link on an
177
180
  // engine-published PR verification report resolve.
178
181
  useRunDeepLink()
182
+ // `?view=queue` / `?view=board`: a link that opens one of the board's two views.
183
+ views.consumeDeepLink()
179
184
  void workspace.init()
180
185
  // Honour a `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`): open the Infrastructure
181
186
  // window pre-seeded with the provisioned connection so the user only pastes the token + saves.
@@ -401,6 +406,14 @@ useTutorialNudge()
401
406
  // plus the funnel counters. Unconditional and best-effort: with no accounts or no store wired it
402
407
  // simply has nothing to talk to, and the browser-persisted store carries on alone.
403
408
  useTutorialSync()
409
+ // Starting a tour switches to the canvas, which every tour walks (the view store holds it for the
410
+ // tour's length), and the canvas is then the view used last.
411
+ watch(
412
+ () => tutorial.touring,
413
+ (touring) => {
414
+ if (touring) views.show('board')
415
+ },
416
+ )
404
417
 
405
418
  // Subscribe to the backend's real-time event stream and (re)connect whenever the
406
419
  // active workspace changes. Runs advance durably server-side; progress arrives as
@@ -462,12 +475,32 @@ watch(
462
475
  <main class="relative min-w-0 flex-1">
463
476
  <BoardCanvas />
464
477
  <!-- Toolbar, nav trigger and every advisory banner, in ONE stacked region that owns
465
- their placement, so no two of them can cover each other. -->
466
- <BoardTopOverlays
467
- :connected="streamConnected"
468
- :ever-connected="streamEverConnected"
469
- :connection-failed="streamConnectionFailed"
470
- />
478
+ their placement, so no two of them can cover each other.
479
+
480
+ The queue view sits OVER the canvas rather than replacing it, so switching back keeps
481
+ the canvas's camera and selection. Below the inspector (z-20), so a task opened from
482
+ the queue shows its panel beside it. The same advisories render IN FLOW above its
483
+ columns, so they push the queue down instead of covering its header.
484
+
485
+ ONE overlay instance serves both views, at the same place in this template: two
486
+ instances would remount every banner on each switch, and a banner whose dismissal
487
+ is its own component state would come back. On the canvas the wrapper is
488
+ `display: contents`, so it adds no box and the overlays float over the canvas as
489
+ before. -->
490
+ <div
491
+ :class="
492
+ views.view === 'queue' ? 'absolute inset-0 z-10 flex flex-col bg-app-950' : 'contents'
493
+ "
494
+ :data-testid="views.view === 'queue' ? 'queue-view-host' : undefined"
495
+ >
496
+ <BoardTopOverlays
497
+ :in-flow="views.view === 'queue'"
498
+ :connected="streamConnected"
499
+ :ever-connected="streamEverConnected"
500
+ :connection-failed="streamConnectionFailed"
501
+ />
502
+ <QueueView v-if="views.view === 'queue'" class="min-h-0 flex-1" />
503
+ </div>
471
504
  <InspectorPanel />
472
505
  <!-- Code-split focus view. The fade lives here (not inside the component) so the
473
506
  leave animation still plays when `focusBlockId` clears and the v-if unmounts
@@ -0,0 +1,51 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import { DEFAULT_WORK_VIEW, isWorkView, WORK_VIEW_PARAM, type WorkView } from '~/utils/workView'
4
+
5
+ /**
6
+ * Which view of the board this person is looking at: the queue or the canvas.
7
+ *
8
+ * Persisted per BROWSER in a cookie, like the interface tier and the lane order: it is the view
9
+ * used LAST in this browser, so a person who prefers the canvas stays on it, and nobody on another
10
+ * browser is moved by their choice. See `utils/workView.ts` for why the queue is the default.
11
+ */
12
+ export const useWorkViewStore = defineStore(
13
+ 'workView',
14
+ () => {
15
+ const tutorial = useTutorialStore()
16
+ // Stored raw and narrowed on read, as `uiMode` does: the persisted value is untrusted input.
17
+ const storedView = ref<string>(DEFAULT_WORK_VIEW)
18
+ /**
19
+ * The view on screen. A running tour holds the CANVAS for its whole length: every tour anchors
20
+ * to canvas nodes and the canvas toolbar, which the queue covers or unmounts, so a switch mid-
21
+ * tour would leave its later steps with nothing to point at. A choice made meanwhile is kept
22
+ * and applies when the tour ends.
23
+ */
24
+ const view = computed<WorkView>(() => {
25
+ if (tutorial.touring) return 'board'
26
+ return isWorkView(storedView.value) ? storedView.value : DEFAULT_WORK_VIEW
27
+ })
28
+
29
+ function show(next: WorkView) {
30
+ storedView.value = next
31
+ }
32
+
33
+ /**
34
+ * Honour a `?view=queue` / `?view=board` link, then strip the parameter so a reload or a
35
+ * copied URL does not re-apply it over a later choice. Same shape as the run deep link. A
36
+ * `view` value that names no view is dropped without effect, rather than guessed onto one.
37
+ */
38
+ function consumeDeepLink() {
39
+ if (!import.meta.client) return
40
+ const url = new URL(window.location.href)
41
+ if (!url.searchParams.has(WORK_VIEW_PARAM)) return
42
+ const asked = url.searchParams.get(WORK_VIEW_PARAM)
43
+ url.searchParams.delete(WORK_VIEW_PARAM)
44
+ window.history.replaceState(window.history.state, '', url.toString())
45
+ if (isWorkView(asked)) show(asked)
46
+ }
47
+
48
+ return { storedView, view, show, consumeDeepLink }
49
+ },
50
+ { persist: { pick: ['storedView'] } },
51
+ )