feat(workbench): Scene.scopeTagIds + reactive scene-scope store

WorkbenchScene grows an optional scopeTagIds field so scenes can act
as data-scope lenses: when set, module queries filter records to those
tagged with at least one of the scene's tags (+ untagged = global).

New reactive store scene-scope.svelte.ts:
  - setSceneScopeTagIds(ids) — called by scene store on switch/init
  - getSceneScopeTagIds()    — read by module queries
  - filterBySceneScope()     — reusable filter (same semantics as
                               AI scope-context's filterByScope)

Wired into workbench-scenes.svelte.ts:
  - setActiveScene() syncs scope on manual switch
  - liveQuery subscription syncs scope on init/sync/tab-focus

Module queries can now opt into scene scoping by calling
filterBySceneScope in their useAll* hooks — not wired yet (each
module opts in as needed). The foundation is in place.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 15:15:44 +02:00
parent fad7f4bea3
commit 0ddaab53e4
3 changed files with 70 additions and 0 deletions

View file

@ -0,0 +1,54 @@
/**
* Reactive scene scope propagates the active scene's scopeTagIds to
* module queries so the UI can filter records by the scene's tags.
*
* Updated by the workbench layout whenever the active scene changes.
* Module queries read `currentScopeTagIds` and filter accordingly.
* Undefined = no filtering (everything visible).
*
* This is the UI-facing counterpart of the AI-facing `withAgentScope`
* in `data/ai/scope-context.ts`. Both use the same tag system; the AI
* scope is ephemeral (duration of a mission run), this one is long-
* lived (persists as long as the scene is active).
*/
/** Reactive scope state — set by workbench layout, read by module queries. */
let _scopeTagIds = $state<readonly string[] | undefined>(undefined);
/** Set the active scene's scope. Called from the workbench layout
* when the active scene changes. Pass undefined to clear. */
export function setSceneScopeTagIds(tagIds: readonly string[] | undefined): void {
_scopeTagIds = tagIds?.length ? tagIds : undefined;
}
/** Read the current scene scope. Returns undefined when no scope is
* active (= show everything). Module queries use this to filter. */
export function getSceneScopeTagIds(): readonly string[] | undefined {
return _scopeTagIds;
}
/**
* Utility: filter records by the active scene scope. Same semantics as
* `filterByScope` in AI scope-context: untagged records pass through,
* tagged records must match at least one scope tag.
*
* Synchronous if tagIds are already loaded; async variant for
* junction-table lookups (same signature as the AI version).
*/
export async function filterBySceneScope<T>(
records: T[],
getTagIdsForRecord: (record: T) => Promise<string[]>
): Promise<T[]> {
const scope = _scopeTagIds;
if (!scope) return records;
const scopeSet = new Set(scope);
const results: T[] = [];
for (const r of records) {
const tagIds = await getTagIdsForRecord(r);
if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) {
results.push(r);
}
}
return results;
}

View file

@ -21,6 +21,7 @@ import type {
WorkbenchScene,
WorkbenchSceneApp,
} from '$lib/types/workbench-scenes';
import { setSceneScopeTagIds } from './scene-scope.svelte';
const TABLE = 'workbenchScenes';
const ACTIVE_SCENE_LS_KEY = 'mana:workbench:activeSceneId';
@ -163,6 +164,9 @@ export const workbenchScenesStore = {
activeSceneIdState = next;
writeActiveIdToStorage(next);
}
// Sync scope when scenes reload (init, sync pull, tab focus).
const activeScope = visible.find((s) => s.id === (next ?? activeSceneIdState));
setSceneScopeTagIds(activeScope?.scopeTagIds);
initializedState = true;
},
error: (err) => {
@ -183,6 +187,9 @@ export const workbenchScenesStore = {
if (!scenesState.some((s) => s.id === id)) return;
activeSceneIdState = id;
writeActiveIdToStorage(id);
// Sync scene scope for module queries
const scene = scenesState.find((s) => s.id === id);
setSceneScopeTagIds(scene?.scopeTagIds);
},
async createScene(opts: {

View file

@ -42,6 +42,15 @@ export interface WorkbenchScene {
* settings. See docs/plans/multi-agent-workbench.md §Phase 5d.
*/
viewingAsAgentId?: string;
/**
* Tag-based data scope for this scene (Phase 6). When set, module
* queries in apps rendered within this scene filter records to only
* those tagged with at least one of these global tag IDs (+ untagged
* records which are globally visible). Auto-inferred from the bound
* agent's `scopeTagIds` if the user hasn't overridden it explicitly.
* Undefined = no filtering, everything visible.
*/
scopeTagIds?: string[];
}
/** Dexie row shape (adds the BaseRecord audit fields stamped by hooks). */