@nwire/studio 0.10.1 → 0.11.1

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 CHANGED
@@ -31,22 +31,33 @@ supervisor, dispatch).
31
31
 
32
32
  ## Pages
33
33
 
34
- | Page | Reads | Renders |
35
- | --------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
36
- | Overview | `manifest.json` | Per-app summary, counts, links. |
37
- | Topology | `graph.json` + manifest | VueFlow graph of modules + cross-domain event edges. |
38
- | Modules | `modules.json` | Per-domain card with badges (public/private, persona, journeyStep). |
39
- | Actions | `actions.json` | Action table with SLO + retry + emits. |
40
- | Events | `events.json` | Event catalog grouped by audience/outcome. |
41
- | Resolvers | `resolvers.json` + `routes.json` | Resolver bindings with their HTTP mount tuples. |
42
- | Workflows | `workflows.json` | Reactions + sagas with state diagrams. |
43
- | Hooks | `hooks.json` + live `.tap()` | Every `hook()` in the registry with chain + listener counts, click-to-open chips, live step taps. |
44
- | Plugins | `plugins.json` | Plugins + modules-compiled-as-plugins (distinguished by `kind`). |
45
- | Commands | `commands.json` | Operator `defineCommand` entries with run buttons. |
46
- | Live | `/__nwire/events/stream` (SSE) | Tail every domain event as it fires. |
47
- | Trace | `/__nwire/telemetry/stream` (SSE) | Causation tree for one `correlationId`; Play Trace replays telemetry across canvas with amber-glow per sticky. |
48
- | Dispatch | `/__nwire/dispatch` | Form-driven action invocation against a live wire. |
49
- | Run | `/__nwire/run/*` (kernel supervisor) | 3-column picker / processes / live stdout — boots wires from the browser. |
34
+ The nav is grouped into **Map** (the live system), **Run** (operate
35
+ it), **Inspect** (browse the static surface). Screenshots are
36
+ captured live against `examples/moderation-queue` see
37
+ [docs/concepts/studio](../../docs/concepts/studio.md) for the
38
+ annotated tour.
39
+
40
+ ![Home](../../docs/public/studio/01-home.png)
41
+
42
+ | Group | Page | Reads | Renders |
43
+ | ------- | ----------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
44
+ | | Projects | localStorage catalog | Catalog of every workspace Studio has ever opened. |
45
+ | | Home | `manifest.json` + live telemetry | Per-project dashboard recent failures, boot summary, composition stats. |
46
+ | Map | Topology | manifest + `graph.events` | VueFlow graph apps as nodes with their plugins + sinks inline, cross-app event edges. |
47
+ | Map | Trace | `/__nwire/telemetry/stream` (SSE) | Causation tree per `correlationId`; Play Trace replays telemetry across canvas with amber-glow per sticky. |
48
+ | Map | Stream | `/__nwire/events/stream` (SSE) | Live event firehose. |
49
+ | Run | Try | `/__nwire/dispatch` | Form-from-Zod-schema action dispatch against the live wire. |
50
+ | Run | Processes | `/__nwire/run/*` (supervisor) | Start dev with custom port + env, stream stdout, recognise external `nwire dev` processes via `.nwire/processes/*`. |
51
+ | Run | Commands | `/__nwire/run/commands` | Operator `defineCommand` entries with run buttons. |
52
+ | Run | Workflows | `workflows` | Workflow defs with subscribed events and dispatched actions. |
53
+ | Inspect | Apps | `apps` + `plugins` + `sinks` | Every App with its plugin stack, primitive counts, and outbound sinks. |
54
+ | Inspect | Actions | `actions` | Searchable list + detail panel: schema, retry, persona, journey, SLO, source link. |
55
+ | Inspect | Events | `events` + `graph.events` | Catalog with the `.public()` gate badge + per-event producer/consumer flow. |
56
+ | Inspect | Projections | `projections` | CQRS read models; each fold with the events it listens to and the queries reading it. |
57
+ | Inspect | Queries | `queries` | Read endpoints — projection-backed or direct-handler. |
58
+ | Inspect | Sinks | `sinks` | Outbound delivery chain — every stage by position (early / middle / terminal). |
59
+ | Inspect | Plugins | `plugins` | Installed plugins per app — bundle-mode forge, sub-plugins, custom. |
60
+ | Inspect | Hooks | `hooks` | Every materialised framework hook slot and its chain length. |
50
61
 
51
62
  ## Surface (programmatic)
52
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/studio",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Nwire Studio — visual companion for the framework. Vue 3 + Vite + shadcn-vue + VueFlow. System topology, action runner, actor browser, projection viewer, DLQ inspector.",
5
5
  "keywords": [
6
6
  "devtools",
@@ -40,7 +40,7 @@
40
40
  "vite": "npm:rolldown-vite@latest",
41
41
  "vue": "^3.5.13",
42
42
  "vue-router": "^4.5.0",
43
- "@nwire/supervisor": "0.10.1"
43
+ "@nwire/supervisor": "0.11.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@playwright/test": "^1.60.0",
package/src/App.vue CHANGED
@@ -1,11 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { onMounted, onBeforeUnmount, ref, watch, computed } from "vue";
3
- import { RouterLink, RouterView, useRoute } from "vue-router";
3
+ import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
4
4
  import {
5
5
  upsertCurrent,
6
6
  loadCatalog,
7
7
  getActiveProjectCwd,
8
8
  setActiveProjectCwd,
9
+ setActiveSlug,
10
+ projectSlug,
11
+ cwdForSlug,
9
12
  type ProjectSnapshot,
10
13
  } from "@/lib/project-catalog";
11
14
  import {
@@ -24,14 +27,58 @@ import {
24
27
  Anchor,
25
28
  Puzzle,
26
29
  FolderOpen,
30
+ Map,
31
+ Wrench,
32
+ Eye,
33
+ Database,
34
+ Search,
35
+ Terminal,
27
36
  } from "lucide-vue-next";
28
37
  import { useCache } from "@/lib/cache";
29
38
  import { ErrorBoundary } from "@/components";
30
39
  import { Button } from "@/components/ui/button";
31
40
 
32
41
  const route = useRoute();
42
+ const router = useRouter();
33
43
  const { cache, loading, error, missingFields, reload } = useCache();
34
44
 
45
+ /**
46
+ * The active project slug — read from the URL when present, falls back
47
+ * to the launched project's slug. Drives every sidebar link so all
48
+ * navigation stays inside the project.
49
+ */
50
+ const activeSlug = computed(() => {
51
+ const fromRoute = route.params.slug as string | undefined;
52
+ if (fromRoute) return fromRoute;
53
+ return project.value ? projectSlug(project.value) : null;
54
+ });
55
+
56
+ /** Build a project-scoped router path. */
57
+ function pPath(suffix: string): string {
58
+ const slug = activeSlug.value;
59
+ if (!slug) return suffix;
60
+ const tail = suffix === "/" ? "" : suffix;
61
+ return `/projects/${slug}${tail}`;
62
+ }
63
+
64
+ /**
65
+ * When the URL slug changes (route navigation, manual paste, switch),
66
+ * resolve it to a cwd via the catalog and pin both legacy keys so the
67
+ * fetch shim + project-status middleware target the right project.
68
+ */
69
+ watch(
70
+ () => route.params.slug,
71
+ (slug) => {
72
+ if (typeof slug !== "string" || !slug) return;
73
+ const cwd = cwdForSlug(slug);
74
+ if (cwd) {
75
+ setActiveProjectCwd(cwd);
76
+ setActiveSlug(slug);
77
+ }
78
+ },
79
+ { immediate: true },
80
+ );
81
+
35
82
  // Project identity — multi-project (Shape A). The active project is
36
83
  // stored in localStorage; the server uses `?project=<cwd>` (injected via
37
84
  // the fetch shim in main.ts) to scope every endpoint. We need to register
@@ -42,18 +89,29 @@ const catalog = ref<Record<string, ProjectSnapshot>>({});
42
89
 
43
90
  async function bootProjects() {
44
91
  // Server-side registration happens in main.ts before mount (so the
45
- // cache fetch has an accepted cwd). Here we just resolve the active
92
+ // cache fetch has an accepted cwd). Here we resolve the active
46
93
  // project's metadata + load the catalog into reactive state.
47
94
  catalog.value = loadCatalog();
95
+ // If the URL carries a /projects/:slug, that's the source of truth —
96
+ // resolve it against the catalog and pin the cwd before any fetch.
97
+ const slugFromRoute = route.params.slug as string | undefined;
98
+ if (slugFromRoute) {
99
+ const cwd = cwdForSlug(slugFromRoute);
100
+ if (cwd) {
101
+ setActiveProjectCwd(cwd);
102
+ setActiveSlug(slugFromRoute);
103
+ }
104
+ }
48
105
  const stored = getActiveProjectCwd();
49
106
  try {
50
107
  const res = await fetch("/__nwire/project");
51
108
  if (res.ok) {
52
109
  const body = (await res.json()) as { name: string; cwd: string };
53
110
  project.value = body;
54
- // First-time mount with no active selection — pin it now so the
55
- // catalog page + future requests have something to point at.
56
- if (!stored) setActiveProjectCwd(body.cwd);
111
+ if (!stored) {
112
+ setActiveProjectCwd(body.cwd);
113
+ setActiveSlug(projectSlug(body));
114
+ }
57
115
  }
58
116
  } catch {
59
117
  // header just shows "Nwire Studio" if the fetch fails
@@ -66,11 +124,15 @@ const catalogList = computed<ProjectSnapshot[]>(() =>
66
124
  );
67
125
 
68
126
  function switchProject(cwd: string) {
127
+ const snap = catalog.value[cwd];
128
+ const slug = snap ? projectSlug(snap) : null;
69
129
  setActiveProjectCwd(cwd);
130
+ if (slug) setActiveSlug(slug);
70
131
  // Hard reload — every page's data depends on the active project, and
71
132
  // SSE streams are tied to the old one. A fresh page guarantees a clean
72
- // pivot without untangling every page's reactive subscriptions.
73
- window.location.reload();
133
+ // pivot without untangling every page's reactive subscriptions. Land
134
+ // on the new project's Home so the URL matches the pinned project.
135
+ window.location.assign(slug ? `/projects/${slug}` : "/projects");
74
136
  }
75
137
 
76
138
  // ─── Project switcher dropdown — click-driven + close-on-outside ────
@@ -110,6 +172,24 @@ onBeforeUnmount(() => {
110
172
  // the cache does, so we watch both.
111
173
  watch([project, cache], persistSnapshot);
112
174
 
175
+ /**
176
+ * One-shot URL upgrade — if the user landed on a bare path like
177
+ * `/actions` we rewrite to `/projects/<slug>/actions` once the slug
178
+ * resolves. Lets old bookmarks and CI suites keep working while
179
+ * preserving the project-pinned canonical URL going forward.
180
+ */
181
+ watch([project, () => route.path], ([_, currentPath]) => {
182
+ if (!project.value) return;
183
+ const slug = projectSlug(project.value);
184
+ if (currentPath.startsWith("/projects/")) return;
185
+ if (currentPath === "/projects") return;
186
+ // Don't fight a fresh navigation in flight.
187
+ const target = currentPath === "/" ? `/projects/${slug}` : `/projects/${slug}${currentPath}`;
188
+ if (target !== currentPath) {
189
+ void router.replace(target);
190
+ }
191
+ });
192
+
113
193
  function persistSnapshot() {
114
194
  if (!project.value) return;
115
195
  const c = cache.value;
@@ -120,7 +200,7 @@ function persistSnapshot() {
120
200
  composition: c
121
201
  ? {
122
202
  apps: c.apps.length,
123
- modules: c.modules.length,
203
+ plugins: c.plugins.length,
124
204
  actions: c.actions.length,
125
205
  events: c.events.length,
126
206
  resolvers: c.resolvers?.length ?? 0,
@@ -141,36 +221,53 @@ async function rebuildCache() {
141
221
  setTimeout(() => void reload(), 800);
142
222
  }
143
223
 
144
- // Studio nav — grouped by what a working dev actually does.
145
- // Daily work first (Trace + Try + Processes are the high-frequency tools);
146
- // Explorer for browsing the surface; Architecture for the big picture.
224
+ // Studio nav — three top-level surfaces:
225
+ //
226
+ // Map — the live system in motion (architecture, trace, stream)
227
+ // Run — operate the system (dispatch actions, drive processes, run CLI)
228
+ // Inspect — browse the static surface (every primitive that's wired up)
229
+ //
230
+ // Home + Projects sit above the groups: Home is the per-project dashboard;
231
+ // Projects is the catalog of every workspace Studio has ever opened.
232
+ const topItems = [
233
+ { to: "/projects", label: "Projects", icon: FolderOpen },
234
+ { to: "/", label: "Home", icon: Home },
235
+ ];
236
+
147
237
  const navGroups = [
148
238
  {
149
- label: "Daily",
239
+ label: "Map",
240
+ icon: Map,
150
241
  items: [
151
- { to: "/projects", label: "Projects", icon: FolderOpen }, // catalog of every project ever opened
152
- { to: "/", label: "Home", icon: Home },
242
+ { to: "/topology", label: "Topology", icon: Network }, // app+module shape
153
243
  { to: "/trace", label: "Trace", icon: Workflow }, // causal tree, one correlationId
154
244
  { to: "/live", label: "Stream", icon: Waves }, // raw live event firehose
245
+ ],
246
+ },
247
+ {
248
+ label: "Run",
249
+ icon: Wrench,
250
+ items: [
155
251
  { to: "/dispatch", label: "Try", icon: Send }, // form-driven action dispatch
156
- { to: "/run", label: "Processes", icon: Play }, // start/stop wires + logs
252
+ { to: "/run", label: "Processes", icon: Play }, // start/stop processes + logs
253
+ { to: "/commands", label: "Commands", icon: Terminal }, // please CLI surface
254
+ { to: "/workflows", label: "Workflows", icon: GitBranch }, // workflow defs + runs
157
255
  ],
158
256
  },
159
257
  {
160
- label: "Explorer",
258
+ label: "Inspect",
259
+ icon: Eye,
161
260
  items: [
162
- { to: "/modules", label: "Modules", icon: Boxes },
261
+ { to: "/apps", label: "Apps", icon: Boxes }, // bounded contexts — one per app
163
262
  { to: "/actions", label: "Actions", icon: Zap },
164
263
  { to: "/events", label: "Events", icon: Radio },
165
- { to: "/workflows", label: "Workflows", icon: GitBranch },
264
+ { to: "/projections", label: "Projections", icon: Database },
265
+ { to: "/queries", label: "Queries", icon: Search },
266
+ { to: "/sinks", label: "Sinks", icon: Waves }, // outbound delivery chain
166
267
  { to: "/plugins", label: "Plugins", icon: Puzzle },
167
268
  { to: "/hooks", label: "Hooks", icon: Anchor },
168
269
  ],
169
270
  },
170
- {
171
- label: "Architecture",
172
- items: [{ to: "/topology", label: "Topology", icon: Network }],
173
- },
174
271
  ];
175
272
  </script>
176
273
 
@@ -232,19 +329,37 @@ const navGroups = [
232
329
  </div>
233
330
  <div class="text-[10px] text-zinc-600 mt-0.5">v0 · OSS</div>
234
331
  </div>
235
- <nav class="flex-1 px-2 py-3 space-y-3 overflow-y-auto">
332
+ <nav class="flex-1 px-2 py-3 space-y-4 overflow-y-auto">
333
+ <div class="space-y-0.5">
334
+ <RouterLink
335
+ v-for="item in topItems"
336
+ :key="item.to"
337
+ :to="item.to === '/projects' ? '/projects' : pPath(item.to)"
338
+ class="flex items-center gap-2 px-2 py-1.5 rounded text-sm text-zinc-400 hover:bg-zinc-900 hover:text-zinc-100 transition-colors"
339
+ :class="{
340
+ 'bg-zinc-900 text-zinc-100':
341
+ route.path === (item.to === '/projects' ? '/projects' : pPath(item.to)),
342
+ }"
343
+ >
344
+ <component :is="item.icon" class="w-4 h-4" />
345
+ {{ item.label }}
346
+ </RouterLink>
347
+ </div>
236
348
  <div v-for="group in navGroups" :key="group.label">
237
- <div class="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-600">
349
+ <div
350
+ class="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-600 flex items-center gap-1.5"
351
+ >
352
+ <component :is="group.icon" class="w-3 h-3" />
238
353
  {{ group.label }}
239
354
  </div>
240
355
  <div class="space-y-0.5">
241
356
  <RouterLink
242
357
  v-for="item in group.items"
243
358
  :key="item.to"
244
- :to="item.to"
359
+ :to="pPath(item.to)"
245
360
  class="flex items-center gap-2 px-2 py-1.5 rounded text-sm text-zinc-400 hover:bg-zinc-900 hover:text-zinc-100 transition-colors"
246
361
  :class="{
247
- 'bg-zinc-900 text-zinc-100': route.path === item.to,
362
+ 'bg-zinc-900 text-zinc-100': route.path === pPath(item.to),
248
363
  }"
249
364
  >
250
365
  <component :is="item.icon" class="w-4 h-4" />
@@ -255,7 +370,7 @@ const navGroups = [
255
370
  </nav>
256
371
  <div class="border-t border-zinc-800 px-4 py-3 text-xs text-zinc-500 space-y-1">
257
372
  <div v-if="cache" class="flex items-center justify-between">
258
- <span>{{ cache.apps.length }} apps · {{ cache.modules.length }} BCs</span>
373
+ <span>{{ cache.apps.length }} apps · {{ cache.plugins.length }} plugins</span>
259
374
  <button
260
375
  class="text-zinc-400 hover:text-zinc-100 transition-colors"
261
376
  title="Reload manifest"
@@ -11,8 +11,8 @@
11
11
  * returns `{ content, language }`. Permission scope is the repo root
12
12
  * — the middleware refuses paths outside the cwd.
13
13
  */
14
- import { ref, watch } from "vue";
15
- import { X } from "lucide-vue-next";
14
+ import { computed, ref, watch } from "vue";
15
+ import { X, ExternalLink } from "lucide-vue-next";
16
16
  import MonacoViewer from "./MonacoViewer.vue";
17
17
  import SourcePill from "./SourcePill.vue";
18
18
 
@@ -22,6 +22,33 @@ const props = defineProps<{
22
22
 
23
23
  const emit = defineEmits<{ (e: "close"): void }>();
24
24
 
25
+ const idePrefix = (): string => {
26
+ if (typeof localStorage === "undefined") return "vscode://file";
27
+ const ide = localStorage.getItem("nwire.ide") ?? "vscode";
28
+ switch (ide) {
29
+ case "cursor":
30
+ return "cursor://file";
31
+ case "zed":
32
+ return "zed://file";
33
+ case "idea":
34
+ case "webstorm":
35
+ case "jetbrains":
36
+ return `jetbrains://idea/navigate/reference?path=`;
37
+ default:
38
+ return "vscode://file";
39
+ }
40
+ };
41
+
42
+ const ideHref = computed(() => {
43
+ if (!props.source) return undefined;
44
+ const { file, line, column } = props.source;
45
+ const prefix = idePrefix();
46
+ if (prefix.includes("?path=")) {
47
+ return `${prefix}${encodeURIComponent(file)}:${line}`;
48
+ }
49
+ return `${prefix}${file}:${line}${column ? `:${column}` : ""}`;
50
+ });
51
+
25
52
  const content = ref<string>("");
26
53
  const language = ref<string>("typescript");
27
54
  const loading = ref(false);
@@ -77,13 +104,23 @@ watch(
77
104
  <h2 class="text-sm font-medium text-zinc-200 truncate">Source</h2>
78
105
  <SourcePill :source="source" compact />
79
106
  </div>
80
- <button
81
- type="button"
82
- class="text-zinc-500 hover:text-zinc-300 transition-colors"
83
- @click="emit('close')"
84
- >
85
- <X class="h-4 w-4" />
86
- </button>
107
+ <div class="flex items-center gap-2">
108
+ <a
109
+ v-if="ideHref"
110
+ :href="ideHref"
111
+ class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900/50 px-2 py-1 text-xs text-zinc-300 hover:text-orange-400 hover:border-orange-500/40 transition-colors"
112
+ >
113
+ <ExternalLink class="h-3 w-3" />
114
+ Open in IDE
115
+ </a>
116
+ <button
117
+ type="button"
118
+ class="text-zinc-500 hover:text-zinc-300 transition-colors"
119
+ @click="emit('close')"
120
+ >
121
+ <X class="h-4 w-4" />
122
+ </button>
123
+ </div>
87
124
  </header>
88
125
 
89
126
  <div class="flex-1 overflow-hidden p-3">
@@ -1,19 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * `SourcePill` — chip that shows a `file:line` source location with a copy
4
- * button and an "open in IDE" button. Backed by the `source` field every
5
- * `.nwire/*.json` entry now carries (set at `defineX` call time by
6
- * `captureSourceLocation()`).
3
+ * `SourcePill` — chip that shows a `file:line` source location and emits
4
+ * `click` so the parent can open `SourceDrawer`. The IDE-open button
5
+ * lives inside the drawer; clicking the pill never bypasses the panel.
7
6
  *
8
- * <SourcePill :source="action.source" />
7
+ * <SourcePill :source="action.source" @click="sourcePreview = action.source" />
9
8
  *
10
- * The IDE-open URI scheme is picked from `localStorage["nwire.ide"]`
11
- * (default: `vscode`). Supported values: `vscode`, `cursor`, `zed`,
12
- * `idea`, `webstorm`. Users can change theirs from the Studio Overview
13
- * "Settings" pane (see `pages/Overview.vue`).
9
+ * The copy button is still inline for quick `file:line` capture without
10
+ * opening the drawer.
14
11
  */
15
12
  import { computed } from "vue";
16
- import { ExternalLink, Copy, Check } from "lucide-vue-next";
13
+ import { Copy, Check } from "lucide-vue-next";
17
14
  import { useCopy } from "../composables/useCopy";
18
15
 
19
16
  const props = defineProps<{
@@ -22,43 +19,16 @@ const props = defineProps<{
22
19
  compact?: boolean;
23
20
  }>();
24
21
 
22
+ defineEmits<{ (e: "click"): void }>();
23
+
25
24
  const { copy, copied } = useCopy();
26
25
 
27
26
  const fileSegment = computed(() => {
28
27
  if (!props.source) return "";
29
28
  const parts = props.source.file.split("/");
30
- // Last 2 path segments give enough context without filling the chip.
31
29
  return parts.slice(-2).join("/");
32
30
  });
33
31
 
34
- const idePrefix = (): string => {
35
- if (typeof localStorage === "undefined") return "vscode://file";
36
- const ide = localStorage.getItem("nwire.ide") ?? "vscode";
37
- switch (ide) {
38
- case "cursor":
39
- return "cursor://file";
40
- case "zed":
41
- return "zed://file";
42
- case "idea":
43
- case "webstorm":
44
- case "jetbrains":
45
- return `jetbrains://idea/navigate/reference?path=`;
46
- default:
47
- return "vscode://file";
48
- }
49
- };
50
-
51
- const ideHref = computed(() => {
52
- if (!props.source) return undefined;
53
- const { file, line, column } = props.source;
54
- const prefix = idePrefix();
55
- // JetBrains uses a query-string scheme; everything else uses path:line:col.
56
- if (prefix.includes("?path=")) {
57
- return `${prefix}${encodeURIComponent(file)}:${line}`;
58
- }
59
- return `${prefix}${file}:${line}${column ? `:${column}` : ""}`;
60
- });
61
-
62
32
  const copyText = computed(() => {
63
33
  if (!props.source) return "";
64
34
  return `${props.source.file}:${props.source.line}${props.source.column ? `:${props.source.column}` : ""}`;
@@ -72,9 +42,12 @@ const onCopy = (e: Event) => {
72
42
  </script>
73
43
 
74
44
  <template>
75
- <div
45
+ <button
76
46
  v-if="source"
77
- class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900/50 px-2 py-1 text-xs font-mono text-zinc-400 hover:border-zinc-700 hover:text-zinc-300 transition-colors"
47
+ type="button"
48
+ class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900/50 px-2 py-1 text-xs font-mono text-zinc-400 hover:border-zinc-700 hover:text-zinc-100 hover:bg-zinc-900 transition-colors"
49
+ :title="`Open ${copyText} in Studio's source panel`"
50
+ @click="$emit('click')"
78
51
  >
79
52
  <span
80
53
  v-if="!compact"
@@ -82,22 +55,14 @@ const onCopy = (e: Event) => {
82
55
  >
83
56
  source
84
57
  </span>
85
- <a
86
- :href="ideHref"
87
- :title="`Open ${copyText} in IDE`"
88
- class="inline-flex items-center gap-1 hover:text-orange-400"
89
- >
90
- {{ fileSegment }}:{{ source.line }}
91
- <ExternalLink class="h-3 w-3 opacity-60" />
92
- </a>
93
- <button
94
- type="button"
58
+ <span>{{ fileSegment }}:{{ source.line }}</span>
59
+ <span
95
60
  :title="copied ? 'Copied!' : `Copy ${copyText}`"
96
61
  class="ml-1 inline-flex items-center text-zinc-500 hover:text-zinc-300"
97
62
  @click="onCopy"
98
63
  >
99
64
  <Check v-if="copied" class="h-3 w-3 text-emerald-400" />
100
65
  <Copy v-else class="h-3 w-3" />
101
- </button>
102
- </div>
66
+ </span>
67
+ </button>
103
68
  </template>
@@ -6,7 +6,6 @@ describe("normalizeCache", () => {
6
6
  const { cache, missingFields } = normalizeCache({});
7
7
  expect(cache).not.toBeNull();
8
8
  expect(cache?.apps).toEqual([]);
9
- expect(cache?.modules).toEqual([]);
10
9
  expect(cache?.actions).toEqual([]);
11
10
  expect(cache?.events).toEqual([]);
12
11
  expect(cache?.actors).toEqual([]);
@@ -23,8 +22,7 @@ describe("normalizeCache", () => {
23
22
 
24
23
  it("preserves arrays that ARE present", () => {
25
24
  const input = {
26
- apps: [{ name: "x", modules: [] }],
27
- modules: [],
25
+ apps: [{ name: "x", plugins: [] }],
28
26
  actions: [],
29
27
  events: [],
30
28
  actors: [],
@@ -40,6 +38,8 @@ describe("normalizeCache", () => {
40
38
  crons: [],
41
39
  hooks: [],
42
40
  plugins: [],
41
+ sinks: [],
42
+ bindings: [],
43
43
  graph: { events: [] },
44
44
  generatedAt: "2026-05-17T00:00:00Z",
45
45
  };
@@ -52,13 +52,12 @@ describe("normalizeCache", () => {
52
52
  it("reports the exact list of missing array fields", () => {
53
53
  const { missingFields } = normalizeCache({
54
54
  apps: [],
55
- modules: [],
56
55
  actions: [],
57
56
  events: [],
58
57
  actors: [],
59
58
  projections: [],
60
59
  queries: [],
61
- // resolvers + workflows + graph missing
60
+ // resolvers + workflows + sinks + graph missing
62
61
  routes: [],
63
62
  externalCalls: [],
64
63
  inboundWebhooks: [],
@@ -68,10 +67,12 @@ describe("normalizeCache", () => {
68
67
  generatedAt: "2026-05-17T00:00:00Z",
69
68
  });
70
69
  expect([...missingFields].sort()).toEqual([
70
+ "bindings",
71
71
  "graph",
72
72
  "hooks",
73
73
  "plugins",
74
74
  "resolvers",
75
+ "sinks",
75
76
  "workflows",
76
77
  ]);
77
78
  });