Use when writing or modifying Vue 2 components, Vuex store modules, TypeScript interfaces, or Vuetify UI in the src/ directory. Covers: vue-property-decorator class components, vuex-module-decorators (@Module, @Action, @Mutation), Vuetify 2 theming (light/dark mode), dialog patterns, API client usage (GirderAPI.ts, AnnotationsAPI.ts), logging utilities (logWarning/logError instead of console.*), button loading states, and style guidelines.
A mock that mutates state in place where the real store replaces it makes
tests silently vacuous. AnalysisPanel.test.ts's setPlots did
plots.length = 0; plots.push(...) while the real applyAnalysisPlots
builds a new array. Vue short-circuits a computed whose value is unchanged
by identity, so analysisPlots never invalidated and every watcher
downstream of it silently never re-ran — a new test for plot-removal
behavior passed against code that did nothing.
It hid because an existing test appeared to cover removal: its watcher
happened to read analysisPopulation, which returns a fresh array each
evaluation, so that one re-fired for an unrelated reason.
Rules:
mocks.plots = [...next]), matching the store.true stops propagation, so a test that
changes only downstream data may never re-run the watcher.Two related mock traps, both of which make a test assert against something the component never touched:
vi.mock factory captures the spy it closes over. Reassigning
mocks.someAction = vi.fn() in beforeEach leaves the component calling
the original spy while the test asserts on the new one — "expected spy
to be called, number of calls: 0" with obviously working code. Use
mocks.someAction.mockClear() instead.() => store.something never fires when a test assigns to it. If the
behavior under test is a watcher on store state, wrap the mock's default
export in reactive() (vi.mock("@/store", async () => { const { reactive } = await import("vue"); return { default: reactive({ … }) }; })).All 121 components use <script setup lang="ts">:
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import store from "@/store";
const props = defineProps<{
value: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
const localState = ref("");
const computedValue = computed(() => props.value.toUpperCase());
watch(() => props.value, (newVal) => {
localState.value = newVal;
});
onMounted(() => {
// lifecycle hook
});
</script>
import store from "@/store";
import annotationStore from "@/store/annotation";
// Direct usage in <script setup> — no `this` needed
store.someAction();
annotationStore.filteredAnnotations;
Store modules still use vuex-module-decorators with @Module, @Mutation, and @Action decorators.
For advanced store patterns (routeMapper, form change detection, caching with batch loading): read references/store-module-patterns.md
To test the Nimbus AI panel (src/store/aiPanel.ts, AiPanel.vue) end-to-end without clicking, dispatch its actions on the live store. Two traps:
vuex-module-decorators puts them in the global action map as sendUserMessage, handleAuthenticatedUserChange, etc. — NOT aiPanel/sendUserMessage. A namespaced dispatch is silently dropped (Vuex warns, resolves a no-op promise, nothing runs). Confirm with store._actions['sendUserMessage'].sendUserMessage runs the whole agent loop and only resolves when the turn ends — don't await it if you want to poll progress; fire it and read store.state.aiPanel.items / .running on a timer.const store = document.querySelector('#app').__vue_app__.config.globalProperties.$store;
store.commit('setAutoApprove', true); // skip gated-action approval clicks
await store.dispatch('clearConversationAndStorage'); // full reset (memory + IndexedDB)
store.dispatch('sendUserMessage', 'Find the nuclei in this image.'); // fire, don't await
Send exactly once, from a clean/hydrated state. Two sendUserMessages in quick succession start two overlapping runs that both push to the module-level wireMessages, nesting the tool-result blocks (content: [[tool_result,…]]). The next request then fails with Anthropic 400 … messages.N.content.0: Input should be an object. This is not a create/run bug — it's conversation corruption from concurrent turns. (The UI's send() and the sendUserMessage guard both check running, but a stale in-flight run or leftover persisted conversation can still bite; a hard reload + clearConversationAndStorage gives a truly clean slate.) Related: hydrating (module var) blocks sends until a reloaded conversation finishes restoring — a dispatch right after reload can no-op; wait a beat. clearConversation() (no force) no-ops while running; use clearConversationAndStorage.
Agent tool executors live in src/agent/executors.ts (executeAgentTool(name, input, ctx)), importable in the Vite dev page for isolated testing: await import('/src/agent/executors.ts?t=' + Date.now()) (the query-bust avoids a stale module cache). Worker tools save parameters under tool.values.workerInterfaceValues; channelCheckboxes values are {channelIndex: true} maps (a true value selects — key-presence alone does not).
markRawannotationStubs, hydratedAnnotations, and annotationCentroids hold one
entry per annotation — up to ~700K. Every existing assignment wraps them in
markRaw(...); a new mutation that forgets it hands Vue a raw Map to walk and
proxy entry by entry, and that cost dwarfs whatever the mutation was doing. A
whole-dataset recolor measured 16.9s with the markRaw missing against
~5.5s with it — and the mutation itself was only ~0.5s of that.
Nothing static catches this: tsc and lint are happy, and any test with a
handful of fixture annotations is far too small to feel it. The tell is a
measured time that doesn't add up from its parts.
// BAD: Vue proxies ~700K entries on assignment
this.annotationStubs = newStubs;
// GOOD: matches the nine other assignments to this map
this.annotationStubs = markRaw(newStubs);
src/store/__tests__/rawStateMaps.test.ts asserts isReactive(...) === false
after every mutation that replaces one of these maps — extend it when you add
another, rather than hand-checking. Verify a new row can fail by deleting only
the markRaw call (not the whole mutation — stashing the file reverts it
entirely and the test then fails for the wrong reason).
Note the array convention differs: annotations is a plain reactive array of
markRawed items (setAnnotations does annotations.map(markRaw)), so
markRaw goes on the items there, not the array.
Editing any src/store/*.ts while pnpm run dev runs corrupts the store: vuex-module-decorators registers getters at import time with no HMR accept handler, so a hot re-import double-registers → [vuex] duplicate getter key cascade and broken state (e.g. annotations stuck at 0). Hard-reload the page after every store-module edit before trusting any in-browser behavior. Component .vue edits HMR fine — prefer putting temporary instrumentation in .vue files.
@Action({ rawError: true })vuex-module-decorators wraps any error thrown from a bare @Action in a generic Error("ERR_ACTION_ACCESS_UNDEFINED: Are you trying to access this.someMutation()..."), discarding the original message — unless the action is declared @Action({ rawError: true }). This is a library-wide behavior, not specific to one module.
Most actions in this codebase never throw (they log and return null/false on failure), so this rarely bites. It matters the moment an action is designed to throw so a caller can show the real failure reason (e.g. addMultiSourceMetadata throwing a storage-quota message for MultiSourceConfiguration.vue to display). Forgetting rawError: true silently replaces that message with the cryptic wrapper text — tsc/lint/tests all stay green because the action still rejects, just with the wrong message.
// BAD: caller's catch block sees "ERR_ACTION_ACCESS_UNDEFINED: ..." instead
// of the real message
@Action
async doThing() {
throw new Error("Helpful, specific reason");
}
// GOOD
@Action({ rawError: true })
async doThing() {
throw new Error("Helpful, specific reason");
}
When writing a test for an action's thrown-error message, expect(...).rejects.toThrow("substring") is not a reliable regression check here: the wrapped error's message embeds the original error's .stack (which starts with "Error: <original message>"), so a substring match can pass even when rawError is missing. Assert the exact .message instead. See src/store/index.test.ts for the pattern (dispatches the real action instead of mocking @/store).
Two traps that let this ship a real bug even after the rule above was documented:
throw. Awaiting an API call or another action re-throws through your own decorator. createProperty has no throw and still emitted the blob — so a "grep action bodies for throw" audit misses exactly these.@Action boundary they cross, across modules. createProperty → setProperties → updateConfigurationProperties → syncConfiguration is four boundaries; one bare @Action anywhere on the path mangles the message. Audit every src/store/*.ts, not just index.ts.See references/store-module-patterns.md for the audit commands, how to tell which callers actually display the message, and the vitest setup details (accessor getters are non-configurable — set store.state.main.* directly).
syncConfiguration(key) PUTs the whole key. So a caller that changes three fields by calling a single-field action three times issues three writes of the same key, and a rejection part-way through leaves the shared collection partially updated while reporting failure — the same false-reporting rawError exists to prevent, one level up. Two instances shipped before this was caught (set_scale writing scales up to 3×, update_layer writing layers 2× via changeLayer + saveContrastInConfiguration).
Validate everything first, then write once:
// BAD: validates and persists per field. An invalid tStep leaves pixelSize
// already written — a partial update with no backend failure involved.
if (input.pixelSize) await apply("pixelSize", input.pixelSize);
if (input.tStep) await apply("tStep", input.tStep); // throws on a bad unit
// GOOD: validate all → assign all → one sync
const scales = {};
if (input.pixelSize) scales.pixelSize = validate("pixelSize", input.pixelSize);
if (input.tStep) scales.tStep = validate("tStep", input.tStep);
await main.saveScalesInConfiguration({ scales, throwOnError: true });
Interleaved validation is the easier half to miss: it fails with no backend involvement at all, so it can't be caught by testing backend rejections. When adding a batch action, keep the singular one — the interactive UI edits one field at a time and legitimately wants it (ScaleSettings.vue).
Existing in-codebase idioms for writing once:
changeLayer({ ..., sync: false }) per item, then a single syncConfiguration({ key: "layers", throwOnError: true }) — see set_layer_visibility.saveScalesInConfiguration, setViewContrastOverrides.delta merged into an existing action's single write — saveContrastInConfiguration({ layerId, contrast, delta }).Writes to genuinely different resources can't be merged (the configuration vs the dataset view are separate endpoints); say so at the call site rather than leaving it looking like an oversight.
watch(() => someGetter, cb, { deep: true }) on a getter that returns a new object on every read fires on every dependency touch — including dependencies the getter reads but that don't change the output (deep: true skips the value comparison entirely). This shipped a real bug: a deep watch on currentFilters cleared the selection on every Z-scrub because the getter read z unconditionally. tsc/lint/reasoning all passed; only the live app caught it.
// BAD: fires on every dependency touch
watch(() => annotationListServer.currentFilters, cb, { deep: true });
// GOOD: fires only when content genuinely changes; stringify's traversal
// still registers the nested reactive deps
watch(() => JSON.stringify(annotationListServer.currentFilters), cb);
Watch out for stringify cost on large objects.
This bug recurs even after being fixed once nearby — grep for it. A second, separate watch([...9 getters...], cb, { deep: true }) in the same file (AnnotationList.vue's "server-mode reactive refetch" block, a few lines below the currentFilters watch above) had the identical bug, confirmed via live instrumentation firing every 30-80ms with zero of the 9 tracked values actually changing. Each spurious firing called setOptions({ page: 1 }), silently resetting the server-paginated annotation list's page after every click-to-row navigation — while the rows stayed correct (the accompanying debounced refetch never settled long enough to fire), so only the page number/footer/Index column were wrong. This looked exactly like "clicking an annotation goes to the wrong spot in the list," and a plausible-looking VDataTableServer update:options stale-echo race was chased first as the cause (it even reproduced once) before instrumenting the watcher itself proved it was actually firing with no real change. When you find and fix one instance of this pattern, grep -n "deep:\s*true" src for siblings in the same or related files before considering it fixed — a documented fix comment next to one watcher does not protect a copy-pasted watcher elsewhere.
Not every { deep: true } is this bug — it only applies when the watched source is a getter function that rebuilds a fresh object/array on each call (a Vuex/Pinia getter, a computed, or a plain function reading store state). A ref()/reactive() passed directly as the watch source (not wrapped in a function) is the correct, safe use of deep: true — Vue tracks its stable identity and only fires on genuine in-place mutations. Don't blanket-remove deep: true without checking which case you're in.
throttle/debounce needs a cancel() in onBeforeUnmountA trailing call that fires after teardown runs against a dead view — in
AnnotationViewer.vue that means layer.annotations() / layer.draw() on a
torn-down GeoJS map, or a store write from a component that no longer exists.
The teardown block already cancels them; the failure mode is forgetting to add
the new one, which nothing catches because the component unmounts fine and the
trailing call usually lands harmlessly.
Guard it with a test that records the throttles at construction — the
version that listed them by name stayed green while two uncancelled ones
shipped, and a version that scanned wrapper.vm only moved the hand-maintained
list to defineExpose (an unexposed throttle stays invisible there):
// top of the test file — delegates to real lodash, so timing is unchanged
const createdThrottles = vi.hoisted(() => [] as any[]);
vi.mock("lodash", async (importOriginal) => {
const actual = await importOriginal<typeof import("lodash")>();
const record = (w: any) => { createdThrottles.push(w); return w; };
return { ...actual,
throttle: (...a: any[]) => record((actual.throttle as any)(...a)),
debounce: (...a: any[]) => record((actual.debounce as any)(...a)) };
});
// in the test
createdThrottles.length = 0;
wrapper = mountComponent();
expect(createdThrottles.length).toBeGreaterThanOrEqual(7); // recording can break too
const named = createdThrottles.map((fn, i) => [
Object.keys(vm).find((k) => vm[k] === fn) ?? `unexposed#${i}`,
vi.spyOn(fn, "cancel"),
] as const);
wrapper.unmount();
expect(named.filter(([, s]) => !s.mock.calls.length).map(([n]) => n)).toEqual([]);
<script setup> bodies run per instance, so setup-scope throttles are created
during mount and land in the recording. One residual gap: a wrapper built lazily
inside a handler isn't recorded until that handler runs.
Vuetify 4 wraps all styles in CSS @layer declarations. Custom styles (outside layers) automatically win over Vuetify's defaults — no specificity wars.
Key implications:
!important overrides for Vuetify are unnecessary — remove them:deep() selectors targeting Vuetify internals "just work" without specificity tricks@girder/components bundles Vuetify 3 CSS (un-layered), so !important IS still needed when overriding Girder component styles// In <script setup>
import { useTheme } from "vuetify";
const theme = useTheme();
const isDark = computed(() => theme.current.value.dark);
<!-- In templates -->
<div :class="{
'v-theme--light': !$vuetify.theme.current.dark,
'v-theme--dark': $vuetify.theme.current.dark
}">
Theme config in src/plugins/vuetify.ts:
defaultTheme: Persister.get("theme", "dark") === "dark" ? "dark" : "light",
Vuetify 4 changed the default theme from "light" to "system". Our config sets it explicitly.
Option 1: Vuetify Components (preferred) — auto-inherit theme.
Option 2: Theme classes in SCSS
.v-theme--dark & {
background: rgba(255, 255, 255, 0.05);
}
.v-theme--light & {
background: rgba(0, 0, 0, 0.05);
}
Option 3: CSS Variables
.my-element {
color: rgb(var(--v-theme-primary));
background: rgb(var(--v-theme-surface));
}
.raw Wrapper)Vuetify 4 removed the .raw wrapper from select slot items. Items are passed directly. This applies to ALL slot types: #item, #chip, and #selection.
Object items — access properties directly:
<!-- Vuetify 4: access properties directly on object items -->
<v-select :items="items" item-title="displayName">
<template v-slot:item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #title>{{ item.displayName }}</template>
<template #subtitle>{{ item.description }}</template>
</v-list-item>
</template>
</v-select>
String items — item IS the string, not a wrapped object. Do NOT use item.title:
<!-- BAD: item.title is undefined on a string — renders empty chips -->
<v-combobox :items="tagList" chips multiple>
<template v-slot:chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ item.title }}</v-chip> <!-- WRONG -->
</template>
</v-combobox>
<!-- GOOD: use item directly for string items -->
<v-combobox :items="tagList" chips multiple>
<template v-slot:chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ item }}</v-chip> <!-- CORRECT -->
</template>
</v-combobox>
The #item slot name did NOT change (contrary to some sources claiming rename to #internalItem).
v-select shows [object Object] — set item-title to match the item keyVuetify's VSelect defaults to item-title="title" and item-value="value". If your items are objects keyed differently, the selected display renders the raw object as [object Object] (selection still works because item-value happens to match).
This bit the tool-creation form: every select interface element in public/config/templates.json uses { text, value } items, but the generic VSelect in ToolConfigurationItem.vue set no item-title, so every non-submenu select in the Add-tool dialog rendered [object Object]. Fix: pass item-title="text" (the app's convention) for select elements.
<!-- BAD: items are { text, value } but VSelect looks for `.title` -->
<v-select :items="[{ text: 'Point prompts', value: 'point' }]" /> <!-- [object Object] -->
<!-- GOOD -->
<v-select :items="items" item-title="text" item-value="value" />
When you add a non-submenu select to a tool template, or render options in a v-select, always confirm item-title matches the item objects' label key.
v-model a computed that reads a non-reactive pipeline nodeComputeNode.output / ManualInputNode.output (in src/pipelines/computePipeline.ts) is a plain field, not a Vue ref (pipeline nodes are markRaw'd for perf). A computed whose getter reads node.output registers no reactive dependency, so it never re-evaluates when the node's value changes. Bind a control's v-model to such a computed and the control snaps back to its stale value on the next render — e.g. a dropdown that "looks selected" but always displays the old option, or a slider that jumps back.
// BAD: getter reads node.output (non-reactive) → v-model display reverts
const promptMode = computed({
get: () => promptModeNode.value?.output ?? "point",
set: (v) => promptModeNode.value?.setValue(v),
});
// GOOD: a reactive ref is the UI source of truth; push into the node on change,
// and seed the ref from config/state on mount + when the tool state changes.
const promptMode = ref<TPromptMode>("point");
watch(promptMode, (v) => segState.value?.nodes.input.promptMode.setValue(v));
Reactive state fields (from reactive(...) in the tool-state factory) are fine to read in computeds — only raw markRaw'd node .output reads are the trap.
dense everywhere elseOn <v-row>, the boolean dense prop is deprecated. Use density="comfortable":
<v-row density="comfortable" align="center">
The substitution is visually identical — VRow maps
density === 'comfortable' || dense to the same v-row--density-comfortable
class, so dense still works and the only symptom is
[Vuetify UPGRADE] 'dense' is deprecated in the console. Don't trust
Vuetify's own JSDoc here: makeVRowProps says @deprecated use density="compact" while the runtime warning and the class mapping both say
comfortable. comfortable is the behaviour-preserving one.
The warning is one-shot per mounted row, not per render. deprecate() is
called from VRow.setup(), so re-rendering or updating an existing row never
repeats it — which is exactly why you cannot "re-trigger" it by toggling the UI
that contains it, and why it is usually already gone by the time you attach a
console listener. See the in-browser-testing skill for the capture order this
forces.
VRow is the only component that warns. deprecate('dense', …) is called
in exactly one place in Vuetify 4 (VRow.setup()). So a dense on anything
else
npx skills add arjunrajlaboratory/Nimbus 前端下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer