@cat-factory/app 0.37.2 → 0.38.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 (39) hide show
  1. package/app/components/auth/AuthGate.vue +8 -0
  2. package/app/components/auth/LoginScreen.vue +86 -8
  3. package/app/components/auth/ResetPasswordScreen.vue +106 -0
  4. package/app/components/board/nodes/BlockNode.vue +32 -13
  5. package/app/components/bootstrap/BootstrapModal.vue +10 -6
  6. package/app/components/documents/DocumentImportModal.vue +11 -7
  7. package/app/components/github/AddServiceFromRepoModal.vue +9 -5
  8. package/app/components/github/GitHubPanel.vue +8 -4
  9. package/app/components/kaizen/KaizenPanel.vue +7 -3
  10. package/app/components/layout/IntegrationsHub.vue +2 -0
  11. package/app/components/panels/ObservabilityPanel.vue +12 -7
  12. package/app/components/providers/VendorCredentialsModal.vue +10 -6
  13. package/app/components/sandbox/SandboxPanel.vue +30 -19
  14. package/app/components/settings/IssueTrackerPanel.vue +3 -1
  15. package/app/components/settings/LocalModeSettingsPanel.vue +7 -3
  16. package/app/components/settings/LocalModelEndpointsPanel.vue +7 -3
  17. package/app/components/settings/ModelConfigurationPanel.vue +12 -8
  18. package/app/components/settings/ObservabilityConnectionPanel.vue +16 -12
  19. package/app/components/settings/OpenRouterCatalogPanel.vue +14 -9
  20. package/app/components/settings/ProviderConnectionPanel.vue +4 -4
  21. package/app/components/settings/UserSecretsSection.vue +7 -3
  22. package/app/components/settings/WorkspaceSettingsPanel.vue +3 -1
  23. package/app/components/slack/SlackPanel.vue +2 -0
  24. package/app/composables/api/auth.ts +11 -0
  25. package/app/composables/useBlockQueries.ts +31 -9
  26. package/app/pages/index.vue +103 -51
  27. package/app/pages/reset-password.vue +7 -0
  28. package/app/stores/auth.ts +12 -0
  29. package/app/stores/board.spec.ts +30 -0
  30. package/app/stores/board.ts +27 -2
  31. package/app/stores/brainstorm.ts +11 -0
  32. package/app/stores/clarity.ts +11 -0
  33. package/app/stores/consensus.ts +7 -1
  34. package/app/stores/execution.spec.ts +43 -0
  35. package/app/stores/execution.ts +19 -0
  36. package/app/stores/github.ts +17 -0
  37. package/app/stores/requirements.ts +12 -0
  38. package/app/stores/workspace.ts +17 -0
  39. package/package.json +2 -2
@@ -1,10 +1,16 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  import LoginScreen from '~/components/auth/LoginScreen.vue'
3
4
 
4
5
  // Resolves auth state once on mount, then either renders the app (auth off, or
5
6
  // on with a signed-in user) or the login screen. The board's own bootstrap runs
6
7
  // inside the default slot, so it only fires once the user is allowed in.
7
8
  const auth = useAuthStore()
9
+ const route = useRoute()
10
+
11
+ // The password-reset page is public: a recipient of an emailed reset link is signed
12
+ // out, so it must render even when auth is required and there's no user.
13
+ const isPublicRoute = computed(() => route.path === '/reset-password')
8
14
 
9
15
  onMounted(() => auth.bootstrap())
10
16
  </script>
@@ -18,6 +24,8 @@ onMounted(() => auth.bootstrap())
18
24
  <span class="text-sm">Loading…</span>
19
25
  </div>
20
26
 
27
+ <slot v-else-if="isPublicRoute" />
28
+
21
29
  <LoginScreen v-else-if="auth.required && !auth.user" />
22
30
 
23
31
  <slot v-else />
@@ -12,13 +12,17 @@ const invite = computed(() => {
12
12
  })
13
13
 
14
14
  // Password form: signup creates a new user (invite or allowed-email-domain gated),
15
- // login authenticates an existing one. Default to login; flip to signup when invited.
16
- const mode = ref<'login' | 'signup'>(invite.value ? 'signup' : 'login')
15
+ // login authenticates an existing one, forgot requests a reset link. Default to login;
16
+ // flip to signup when invited.
17
+ const mode = ref<'login' | 'signup' | 'forgot'>(invite.value ? 'signup' : 'login')
17
18
  const email = ref('')
18
19
  const password = ref('')
19
20
  const name = ref('')
20
21
  const error = ref<string | null>(null)
21
22
  const busy = ref(false)
23
+ // Set once a reset link has been requested, so we can show a generic confirmation
24
+ // (we never reveal whether the email is registered).
25
+ const forgotSent = ref(false)
22
26
 
23
27
  async function submitPassword() {
24
28
  error.value = null
@@ -44,6 +48,27 @@ async function submitPassword() {
44
48
  }
45
49
  }
46
50
 
51
+ async function submitForgot() {
52
+ error.value = null
53
+ busy.value = true
54
+ try {
55
+ await auth.forgotPassword(email.value)
56
+ forgotSent.value = true
57
+ } catch {
58
+ // The request endpoint is generic by design; only an infra error lands here.
59
+ error.value = 'Something went wrong. Please try again.'
60
+ } finally {
61
+ busy.value = false
62
+ }
63
+ }
64
+
65
+ /** Switch modes, clearing any transient form state. */
66
+ function setMode(next: 'login' | 'signup' | 'forgot') {
67
+ mode.value = next
68
+ error.value = null
69
+ forgotSent.value = false
70
+ }
71
+
47
72
  const showOAuthDivider = computed(
48
73
  () => auth.providers.password && (auth.providers.github || auth.providers.google),
49
74
  )
@@ -58,12 +83,15 @@ const showOAuthDivider = computed(
58
83
  <UIcon name="i-lucide-layout-dashboard" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
59
84
  <h1 class="mb-1 text-lg font-semibold text-white">Architecture Board</h1>
60
85
  <p class="text-sm text-slate-400">
61
- {{ invite ? 'Accept your invitation to continue.' : 'Sign in to continue.' }}
86
+ <template v-if="mode === 'forgot'">Reset your password.</template>
87
+ <template v-else>{{
88
+ invite ? 'Accept your invitation to continue.' : 'Sign in to continue.'
89
+ }}</template>
62
90
  </p>
63
91
  </div>
64
92
 
65
93
  <!-- OAuth providers -->
66
- <div class="space-y-2">
94
+ <div v-if="mode !== 'forgot'" class="space-y-2">
67
95
  <UButton
68
96
  v-if="auth.providers.github"
69
97
  block
@@ -87,12 +115,19 @@ const showOAuthDivider = computed(
87
115
  </UButton>
88
116
  </div>
89
117
 
90
- <div v-if="showOAuthDivider" class="my-4 flex items-center gap-3 text-xs text-slate-500">
118
+ <div
119
+ v-if="showOAuthDivider && mode !== 'forgot'"
120
+ class="my-4 flex items-center gap-3 text-xs text-slate-500"
121
+ >
91
122
  <span class="h-px flex-1 bg-slate-800" /> or <span class="h-px flex-1 bg-slate-800" />
92
123
  </div>
93
124
 
94
125
  <!-- Email / password -->
95
- <form v-if="auth.providers.password" class="space-y-3" @submit.prevent="submitPassword">
126
+ <form
127
+ v-if="auth.providers.password && mode !== 'forgot'"
128
+ class="space-y-3"
129
+ @submit.prevent="submitPassword"
130
+ >
96
131
  <UInput
97
132
  v-if="mode === 'signup'"
98
133
  v-model="name"
@@ -126,17 +161,60 @@ const showOAuthDivider = computed(
126
161
  <p class="text-center text-xs text-slate-400">
127
162
  <template v-if="mode === 'login'">
128
163
  Need an account?
129
- <button type="button" class="text-indigo-400 hover:underline" @click="mode = 'signup'">
164
+ <button
165
+ type="button"
166
+ class="text-indigo-400 hover:underline"
167
+ @click="setMode('signup')"
168
+ >
130
169
  Sign up
131
170
  </button>
132
171
  </template>
133
172
  <template v-else>
134
173
  Already have an account?
135
- <button type="button" class="text-indigo-400 hover:underline" @click="mode = 'login'">
174
+ <button type="button" class="text-indigo-400 hover:underline" @click="setMode('login')">
136
175
  Sign in
137
176
  </button>
138
177
  </template>
139
178
  </p>
179
+ <p v-if="mode === 'login'" class="text-center text-xs text-slate-400">
180
+ <button type="button" class="text-indigo-400 hover:underline" @click="setMode('forgot')">
181
+ Forgot password?
182
+ </button>
183
+ </p>
184
+ </form>
185
+
186
+ <!-- Forgot password: request a reset link by email -->
187
+ <form
188
+ v-if="auth.providers.password && mode === 'forgot'"
189
+ class="space-y-3"
190
+ @submit.prevent="submitForgot"
191
+ >
192
+ <template v-if="forgotSent">
193
+ <p class="text-sm text-slate-300">
194
+ If an account exists for that email, a password reset link is on its way. Check your
195
+ inbox.
196
+ </p>
197
+ </template>
198
+ <template v-else>
199
+ <UInput
200
+ v-model="email"
201
+ type="email"
202
+ required
203
+ placeholder="Email"
204
+ icon="i-lucide-at-sign"
205
+ size="lg"
206
+ class="w-full"
207
+ />
208
+ <p v-if="error" class="text-sm text-rose-400">{{ error }}</p>
209
+ <UButton block size="lg" color="primary" type="submit" :loading="busy">
210
+ Send reset link
211
+ </UButton>
212
+ </template>
213
+ <p class="text-center text-xs text-slate-400">
214
+ <button type="button" class="text-indigo-400 hover:underline" @click="setMode('login')">
215
+ Back to sign in
216
+ </button>
217
+ </p>
140
218
  </form>
141
219
  </div>
142
220
  </div>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+
4
+ // Standalone full-screen reset form reached from the emailed link
5
+ // (`/reset-password?token=…`). It is a public route (see AuthGate), so a recipient who
6
+ // is signed out can still set a new password. On success we send them to the login.
7
+ const auth = useAuthStore()
8
+
9
+ const token = computed(() => {
10
+ if (typeof window === 'undefined') return ''
11
+ return new URLSearchParams(window.location.search).get('token') || ''
12
+ })
13
+
14
+ const password = ref('')
15
+ const confirm = ref('')
16
+ const error = ref<string | null>(null)
17
+ const busy = ref(false)
18
+ const done = ref(false)
19
+
20
+ async function submit() {
21
+ error.value = null
22
+ if (password.value.length < 8) {
23
+ error.value = 'Password must be at least 8 characters.'
24
+ return
25
+ }
26
+ if (password.value !== confirm.value) {
27
+ error.value = 'Passwords do not match.'
28
+ return
29
+ }
30
+ busy.value = true
31
+ try {
32
+ await auth.resetPassword(token.value, password.value)
33
+ done.value = true
34
+ } catch (e) {
35
+ error.value =
36
+ (e as { data?: { error?: { message?: string } } })?.data?.error?.message ??
37
+ 'This password reset link is invalid or has expired.'
38
+ } finally {
39
+ busy.value = false
40
+ }
41
+ }
42
+
43
+ function goToLogin() {
44
+ if (typeof window !== 'undefined') window.location.assign('/')
45
+ }
46
+ </script>
47
+
48
+ <template>
49
+ <div class="flex h-screen w-screen items-center justify-center bg-slate-950 text-slate-100">
50
+ <div
51
+ class="w-full max-w-sm rounded-xl border border-slate-800 bg-slate-900/80 p-8 backdrop-blur"
52
+ >
53
+ <div class="mb-6 text-center">
54
+ <UIcon name="i-lucide-key-round" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
55
+ <h1 class="mb-1 text-lg font-semibold text-white">Reset password</h1>
56
+ <p class="text-sm text-slate-400">Choose a new password for your account.</p>
57
+ </div>
58
+
59
+ <template v-if="done">
60
+ <p class="mb-4 text-sm text-slate-300">
61
+ Your password has been reset. You can now sign in with your new password.
62
+ </p>
63
+ <UButton block size="lg" color="primary" @click="goToLogin">Go to sign in</UButton>
64
+ </template>
65
+
66
+ <template v-else-if="!token">
67
+ <p class="mb-4 text-sm text-rose-400">
68
+ This reset link is missing its token. Request a new link from the sign-in screen.
69
+ </p>
70
+ <UButton block size="lg" color="neutral" variant="subtle" @click="goToLogin">
71
+ Back to sign in
72
+ </UButton>
73
+ </template>
74
+
75
+ <form v-else class="space-y-3" @submit.prevent="submit">
76
+ <UInput
77
+ v-model="password"
78
+ type="password"
79
+ required
80
+ placeholder="New password"
81
+ icon="i-lucide-lock"
82
+ size="lg"
83
+ class="w-full"
84
+ />
85
+ <UInput
86
+ v-model="confirm"
87
+ type="password"
88
+ required
89
+ placeholder="Confirm new password"
90
+ icon="i-lucide-lock"
91
+ size="lg"
92
+ class="w-full"
93
+ />
94
+ <p v-if="error" class="text-sm text-rose-400">{{ error }}</p>
95
+ <UButton block size="lg" color="primary" type="submit" :loading="busy">
96
+ Reset password
97
+ </UButton>
98
+ <p class="text-center text-xs text-slate-400">
99
+ <button type="button" class="text-indigo-400 hover:underline" @click="goToLogin">
100
+ Back to sign in
101
+ </button>
102
+ </p>
103
+ </form>
104
+ </div>
105
+ </div>
106
+ </template>
@@ -35,8 +35,18 @@ const allTasks = computed(() => board.allTasksUnder(props.id))
35
35
  const taskIds = computed(() => new Set(allTasks.value.map((t) => t.id)))
36
36
  const taskCount = computed(() => allTasks.value.length)
37
37
  const hasTasks = computed(() => taskCount.value > 0 || modules.value.length > 0)
38
- const mergedTasks = computed(() => allTasks.value.filter((t) => t.status === 'done').length)
39
- const prTasks = computed(() => allTasks.value.filter((t) => t.status === 'pr_ready').length)
38
+ // Single pass over the tasks for both rollups (vs. one filter each).
39
+ const taskStats = computed(() => {
40
+ let merged = 0
41
+ let prReady = 0
42
+ for (const t of allTasks.value) {
43
+ if (t.status === 'done') merged++
44
+ else if (t.status === 'pr_ready') prReady++
45
+ }
46
+ return { merged, prReady }
47
+ })
48
+ const mergedTasks = computed(() => taskStats.value.merged)
49
+ const prTasks = computed(() => taskStats.value.prReady)
40
50
  const canvas = computed(() => board.containerSize(props.id))
41
51
 
42
52
  // Frame status is derived from its tasks — services never reach "done".
@@ -60,10 +70,17 @@ const selected = computed(() => ui.selectedBlockId === props.id)
60
70
  // kept (gated off) so the prior behaviour is one edit away if we want chips back.
61
71
  const showExpanded = computed(() => true)
62
72
 
63
- // Surface a pending decision from this frame OR any of its tasks.
64
- const blockDecisions = computed(() =>
65
- execution.openDecisions.filter((d) => d.blockId === props.id || taskIds.value.has(d.blockId)),
66
- )
73
+ // Surface a pending decision from this frame OR any of its tasks (O(tasks) map
74
+ // lookups, not a scan of every open decision per frame).
75
+ const blockDecisions = computed(() => {
76
+ const byBlock = execution.decisionsByBlock
77
+ const out = [...(byBlock.get(props.id) ?? [])]
78
+ for (const id of taskIds.value) {
79
+ const list = byBlock.get(id)
80
+ if (list) out.push(...list)
81
+ }
82
+ return out
83
+ })
67
84
 
68
85
  function openFirstDecision() {
69
86
  const d = blockDecisions.value[0]
@@ -74,13 +91,15 @@ function openFirstDecision() {
74
91
  // iterative reviewer gate (requirements-review / clarity-review) that's mid-cycle
75
92
  // (incorporating / re-reviewing in the driver), which is background work needing no human,
76
93
  // so it stays off the frame's "Approval" badge.
77
- const blockApprovals = computed(() =>
78
- execution.openApprovals.filter(
79
- (a) =>
80
- (a.blockId === props.id || taskIds.value.has(a.blockId)) &&
81
- !reviews.isBackground(a.agentKind, a.blockId),
82
- ),
83
- )
94
+ const blockApprovals = computed(() => {
95
+ const byBlock = execution.approvalsByBlock
96
+ const candidates = [...(byBlock.get(props.id) ?? [])]
97
+ for (const id of taskIds.value) {
98
+ const list = byBlock.get(id)
99
+ if (list) candidates.push(...list)
100
+ }
101
+ return candidates.filter((a) => !reviews.isBackground(a.agentKind, a.blockId))
102
+ })
84
103
 
85
104
  function openFirstApproval() {
86
105
  const a = blockApprovals.value[0]
@@ -24,12 +24,16 @@ const open = computed({
24
24
 
25
25
  // Load the workspace's reference architectures + recent jobs, plus (best-effort)
26
26
  // the GitHub repos the user can access so the base form can pick from them.
27
- watch(open, (isOpen) => {
28
- if (isOpen) {
29
- void bootstrap.load()
30
- void loadGitHubRepos()
31
- }
32
- })
27
+ watch(
28
+ open,
29
+ (isOpen) => {
30
+ if (isOpen) {
31
+ void bootstrap.load()
32
+ void loadGitHubRepos()
33
+ }
34
+ },
35
+ { immediate: true },
36
+ )
33
37
 
34
38
  async function loadGitHubRepos() {
35
39
  try {
@@ -41,13 +41,17 @@ const sourceDocs = computed(() =>
41
41
  source.value ? documents.documents.filter((d) => d.source === source.value) : [],
42
42
  )
43
43
 
44
- watch(open, (isOpen) => {
45
- if (isOpen) {
46
- ref_.value = ''
47
- source.value = ui.documentImport?.source ?? documents.connectedSources[0]?.source ?? undefined
48
- documents.loadDocuments().catch(() => {})
49
- }
50
- })
44
+ watch(
45
+ open,
46
+ (isOpen) => {
47
+ if (isOpen) {
48
+ ref_.value = ''
49
+ source.value = ui.documentImport?.source ?? documents.connectedSources[0]?.source ?? undefined
50
+ documents.loadDocuments().catch(() => {})
51
+ }
52
+ },
53
+ { immediate: true },
54
+ )
51
55
 
52
56
  async function doImport() {
53
57
  const value = ref_.value.trim()
@@ -41,11 +41,15 @@ async function loadRepos() {
41
41
 
42
42
  // On open: ensure we know the connection + which repos the App can access, and
43
43
  // the workspace's already-tracked repos (to flag ones already on the board).
44
- watch(open, (isOpen) => {
45
- if (!isOpen) return
46
- resetSelection()
47
- void loadRepos()
48
- })
44
+ watch(
45
+ open,
46
+ (isOpen) => {
47
+ if (!isOpen) return
48
+ resetSelection()
49
+ void loadRepos()
50
+ },
51
+ { immediate: true },
52
+ )
49
53
 
50
54
  // If the user connects from inside the modal (the not-connected prompt), pull the
51
55
  // repo list as soon as the connection is bound.
@@ -26,10 +26,14 @@ const back = useIntegrationBack(open)
26
26
 
27
27
  // On open: refresh projections when connected. The not-connected state renders
28
28
  // <GitHubConnect>, which discovers and links installations on its own.
29
- watch(open, (isOpen) => {
30
- if (!isOpen) return
31
- if (github.connected) void github.load()
32
- })
29
+ watch(
30
+ open,
31
+ (isOpen) => {
32
+ if (!isOpen) return
33
+ if (github.connected) void github.load()
34
+ },
35
+ { immediate: true },
36
+ )
33
37
 
34
38
  function notifyError(title: string, e: unknown) {
35
39
  toast.add({
@@ -12,9 +12,13 @@ const kaizen = useKaizenStore()
12
12
 
13
13
  const open = computed(() => ui.kaizenScreenOpen)
14
14
 
15
- watch(open, (isOpen) => {
16
- if (isOpen) void kaizen.loadOverview()
17
- })
15
+ watch(
16
+ open,
17
+ (isOpen) => {
18
+ if (isOpen) void kaizen.loadOverview()
19
+ },
20
+ { immediate: true },
21
+ )
18
22
 
19
23
  function close() {
20
24
  ui.closeKaizen()
@@ -54,6 +54,8 @@ watch(
54
54
  if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
55
55
  }
56
56
  },
57
+ // Lazy v-if mount: the hub mounts with `integrationsOpen` already true → load immediately.
58
+ { immediate: true },
57
59
  )
58
60
 
59
61
  const open = computed({
@@ -44,13 +44,18 @@ const contextLoading = computed(
44
44
 
45
45
  // Load (and refresh) whenever a different run's panel opens. Reset to the calls view
46
46
  // and load both the calls and the provided-context snapshots.
47
- watch(executionId, (id) => {
48
- if (id) {
49
- view.value = 'calls'
50
- void observability.load(id)
51
- void observability.loadContext(id)
52
- }
53
- })
47
+ watch(
48
+ executionId,
49
+ (id) => {
50
+ if (id) {
51
+ view.value = 'calls'
52
+ void observability.load(id)
53
+ void observability.loadContext(id)
54
+ }
55
+ },
56
+ // Lazy v-if mount: the panel mounts with executionId already set, so load immediately.
57
+ { immediate: true },
58
+ )
54
59
 
55
60
  const expandedCtx = reactive<Record<string, boolean>>({})
56
61
  function toggleCtx(s: AgentContextSnapshot) {
@@ -52,12 +52,16 @@ const label = ref('')
52
52
  const token = ref('')
53
53
  const busy = ref(false)
54
54
 
55
- watch(open, (isOpen) => {
56
- if (!isOpen) return
57
- // Honour a deep-linked tab each time the modal opens (e.g. "My subscriptions" → personal).
58
- activeTab.value = ui.vendorCredentialsTab
59
- if (workspace.workspaceId) void creds.load(workspace.workspaceId)
60
- })
55
+ watch(
56
+ open,
57
+ (isOpen) => {
58
+ if (!isOpen) return
59
+ // Honour a deep-linked tab each time the modal opens (e.g. "My subscriptions" → personal).
60
+ activeTab.value = ui.vendorCredentialsTab
61
+ if (workspace.workspaceId) void creds.load(workspace.workspaceId)
62
+ },
63
+ { immediate: true },
64
+ )
61
65
 
62
66
  /** Step-by-step instructions for the selected vendor. */
63
67
  const steps = computed<string[]>(() => {
@@ -19,9 +19,13 @@ const open = computed({
19
19
 
20
20
  const tab = ref<'experiments' | 'prompts' | 'fixtures'>('experiments')
21
21
 
22
- watch(open, (isOpen) => {
23
- if (isOpen) void store.load()
24
- })
22
+ watch(
23
+ open,
24
+ (isOpen) => {
25
+ if (isOpen) void store.load()
26
+ },
27
+ { immediate: true },
28
+ )
25
29
 
26
30
  // ---- experiment builder ----------------------------------------------------
27
31
  const agentKind = ref('requirements-review')
@@ -107,6 +111,21 @@ const gradeByRun = computed(() => {
107
111
  for (const g of store.detail?.grades ?? []) map.set(g.runId, g)
108
112
  return map
109
113
  })
114
+ // Fixture id → name, so a row resolves its fixture in O(1) instead of a .find scan.
115
+ const fixtureMap = computed(() => {
116
+ const map = new Map<string, string>()
117
+ for (const f of store.fixtures) map.set(f.id, f.name)
118
+ return map
119
+ })
120
+ // Pre-join each run with its grade + fixture name once, so the results table doesn't
121
+ // re-`.get()` the same grade four times (and `.find()` the fixture) per row on render.
122
+ const detailRows = computed(() =>
123
+ (store.detail?.runs ?? []).map((run) => ({
124
+ run,
125
+ grade: gradeByRun.value.get(run.id) ?? null,
126
+ fixtureName: fixtureMap.value.get(run.fixtureId) ?? run.fixtureId,
127
+ })),
128
+ )
110
129
  const selectedRun = ref<SandboxRun | null>(null)
111
130
 
112
131
  function scoreColor(score: number): string {
@@ -157,8 +176,6 @@ async function archive(prompt: SandboxPromptVersion) {
157
176
  })
158
177
  }
159
178
  }
160
-
161
- const fixtureName = (id: string) => store.fixtures.find((f) => f.id === id)?.name ?? id
162
179
  </script>
163
180
 
164
181
  <template>
@@ -348,7 +365,7 @@ const fixtureName = (id: string) => store.fixtures.find((f) => f.id === id)?.nam
348
365
  </thead>
349
366
  <tbody>
350
367
  <tr
351
- v-for="run in store.detail.runs"
368
+ v-for="{ run, grade, fixtureName } in detailRows"
352
369
  :key="run.id"
353
370
  class="cursor-pointer border-t border-slate-800 hover:bg-slate-800/40"
354
371
  @click="selectedRun = run"
@@ -357,14 +374,14 @@ const fixtureName = (id: string) => store.fixtures.find((f) => f.id === id)?.nam
357
374
  <td class="py-1 pr-2 font-mono text-[11px] text-slate-400">
358
375
  {{ run.model }}
359
376
  </td>
360
- <td class="py-1 pr-2 text-slate-400">{{ fixtureName(run.fixtureId) }}</td>
377
+ <td class="py-1 pr-2 text-slate-400">{{ fixtureName }}</td>
361
378
  <td class="py-1 pr-2">
362
379
  <span
363
- v-if="gradeByRun.get(run.id)"
364
- :class="scoreColor(gradeByRun.get(run.id)!.weightedTotal)"
380
+ v-if="grade"
381
+ :class="scoreColor(grade.weightedTotal)"
365
382
  class="font-semibold"
366
383
  >
367
- {{ gradeByRun.get(run.id)!.weightedTotal.toFixed(2) }}
384
+ {{ grade.weightedTotal.toFixed(2) }}
368
385
  </span>
369
386
  <span v-else-if="run.status === 'failed'" class="text-rose-400"
370
387
  >failed</span
@@ -373,16 +390,10 @@ const fixtureName = (id: string) => store.fixtures.find((f) => f.id === id)?.nam
373
390
  </td>
374
391
  <td class="py-1">
375
392
  <span
376
- v-if="gradeByRun.get(run.id)?.objective"
377
- :class="
378
- gradeByRun.get(run.id)!.objective!.pass
379
- ? 'text-emerald-400'
380
- : 'text-amber-400'
381
- "
393
+ v-if="grade?.objective"
394
+ :class="grade.objective.pass ? 'text-emerald-400' : 'text-amber-400'"
382
395
  >
383
- {{ gradeByRun.get(run.id)!.objective!.caught }}/{{
384
- gradeByRun.get(run.id)!.objective!.total
385
- }}
396
+ {{ grade.objective.caught }}/{{ grade.objective.total }}
386
397
  </span>
387
398
  <span v-else class="text-slate-600">—</span>
388
399
  </td>
@@ -41,7 +41,9 @@ onMounted(() => {
41
41
  // probe on open if the navbar hasn't already, so the toggles below reflect reality.
42
42
  if (tasks.available === null) void tasks.probe()
43
43
  })
44
- watch(() => tracker.settings, hydrate, { deep: true })
44
+ // `tracker.settings` is reassigned wholesale on hydrate/save, so a reference watch
45
+ // (no deep traversal) catches every change.
46
+ watch(() => tracker.settings, hydrate)
45
47
 
46
48
  // Per-source live state (available = usable now; enabled = offered to the workspace).
47
49
  const github = computed(() => tasks.descriptorFor('github'))
@@ -43,9 +43,13 @@ function syncDraft() {
43
43
  }
44
44
 
45
45
  // Load + hydrate the draft whenever the panel opens.
46
- watch(open, (isOpen) => {
47
- if (isOpen) void store.load().then(syncDraft)
48
- })
46
+ watch(
47
+ open,
48
+ (isOpen) => {
49
+ if (isOpen) void store.load().then(syncDraft)
50
+ },
51
+ { immediate: true },
52
+ )
49
53
  watch(() => store.settings, syncDraft)
50
54
 
51
55
  async function save() {
@@ -21,9 +21,13 @@ const back = useIntegrationBack(open)
21
21
 
22
22
  // Load the user's endpoints whenever the panel opens (loaded independently of the
23
23
  // workspace snapshot, like personal subscriptions).
24
- watch(open, (isOpen) => {
25
- if (isOpen) void store.load()
26
- })
24
+ watch(
25
+ open,
26
+ (isOpen) => {
27
+ if (isOpen) void store.load()
28
+ },
29
+ { immediate: true },
30
+ )
27
31
 
28
32
  const RUNNERS: { value: LocalRunner; label: string }[] = (
29
33
  Object.keys(LOCAL_RUNNER_LABELS) as LocalRunner[]