context-assignment

Context and scope-tagging UI. Use when adding a context picker to a surface, tagging an entity to scopes, setting or filtering by the active (working) context, showing per-entity context status, promp

SKILL.md

GitHub

Context assignment — picking the right component

The mental model (read this or you WILL wire it wrong)

  1. Membership, not ownership. A person belongs to MULTIPLE organizations, all equal (no personal or business type — common-docs/policies/access-ladder.md). Never assume one org per user. Every project and task belongs to exactly one organization (organization_id NOT NULL, live); a SCOPE assignment on them can be empty ("unassigned"), and that is normal, not an error.
  2. The hierarchy: organization → scope types (the org's custom dimensions, e.g. Clients, Matters) → scopes (instances, e.g. "Acme Corp") → context items (typed fields per scope). Tasks may live in projects but DON'T have to; projects may live in orgs but DON'T have to.
  3. A scope NEVER implies its organization. Org is an independent context dimension — it is part of a selection only when explicitly checked. Display follows the same rule (ContextSummaryChips enforces it).
  4. Three runtime layers — NEVER conflate them (full table: ../common-docs/systems/data/scopes-context/STATE.md → "Runtime context — three layers"):
    • Layer A — Active (working/passive) context = "what I'm doing right now"; ephemeral; lives in appContextSlice; feeds every agent run automatically (execute-instance stamps it). MULTI-scope (keyed by scope id since 2026-06-12), single org/project/task, plus independent active scope types (active_scope_type_ids, 2026-06-30 — "whole dimension in play, no instance chosen"). Writers MUST be Surface A (ESLint-gated; the write actions include setActiveScopeTypes).
    • Layer B — Reference tree = what exists (org→type→scope→item). Cache only; fetched once at boot. Being consolidated onto the hierarchy owner — read scope data via hierarchy selectors, do NOT reach for the fragmented scopeTypes/scopes/… fan-out slices in new code.
    • Layer C — Durable object assignment = "this entity belongs to these"; persisted in canonical platform.associations via the setEntityScopes chokepoint, from the user's explicit UI selection. (The legacy ctx_scope_assignments table is RETIRED — FE cut over 2026-06, zero DB functions reference it; never write to or read from it.)
    • A user ACTION (Layer C) must read the explicit selection, never Layer A just because it's loaded. A successful write that sourced Layer A is worse than a failure — it silently mis-binds. Never auto-convert active → durable (suggest only). The condemned agent_surface binding service (features/surfaces) is the cautionary example of doing this wrong.

Component selection table

You need… Use Mode/notes
Tag an entity (file/note/agent/…) inline on a page ContextAssignmentField mode="assignment", pass subject; use dimensions={["scopes"]} when the surface is scope-only (no project/task FK intent)
Same, without blocking the page ContextAssignmentPopover trigger = your button
Same, as an explicit modal step ContextAssignmentDialog controlled open
Same, floating/draggable ContextAssignmentWindow inline-controlled
Set the WORKING context anywhere (header, toolbar, rail, composer) ActiveContextLensChip THE one scope control; iconOnly for rails, attention for "needs a scope", fill for sidebars, onOpenPreview adds the preview eye
Same field inside a tab panel / drawer (no trigger) ActiveContextPanel composes ContextAssignmentField mode="active"; default checkboxVariant="standard"; used by RunControlsMenu Context tab
Clear working context (rose Eraser + "Context", app-wide) ClearContextButton dispatches clearContext; wired into every Surface A control
Show context status per entity (amber/green nudge) ContextStatusButton pass knownScopeCount on list rows (bulk!), omit on single-entity surfaces
Display a selection readably ContextSummaryChips compact chips for headers; org only if explicit
Labeled active/filter breakdown in the field footer ContextSelectionSummary Org · Scope Types (group) · per-type scopes · Projects · Tasks
Prompt for context during an upload UploadContextPrompt Save awaits awaitFileIds() — handles both races
Filter a list by context (no saving!) ContextAssignmentField mode="filter" emits via onSelectionChange; zero save-side effects

All in features/scopes/components/context-assignment/ except ActiveContextLensChip + ActiveContextPanel (features/scopes/components/active-context/ — Surface A writers MUST live there; ESLint + FEATURE.md enforce it).

Hard rules

  • Fetch discipline. The org/type/scope tree is fetched ONCE at boot into Redux (ensureScopeTree → state.scopesTree) and refreshed only by scopeTreeInvalidationMiddleware on structural mutations. Components NEVER refetch it. It is durably warm-cached across hard reload by the sync engine (scopesTreePolicy in features/scopes/redux/scopesSlice.ts): a warm boot rehydrates the tree before paint and ensureScopeTree() skips the network entirely (no staleAfter/remote.fetch — the tree changes rarely). Projects/tasks/items go through context-assignment/data.ts (60s TTL + in-flight dedup). List surfaces use getEntityScopesBulk/primeEntityScopes — N rows must never mean N requests. Add new reads to data.ts, never to a component.
  • Write paths. Scopes → useEntityScopes().setScopes / setEntityScopes thunk only. Active context → dispatch from active-context/ components only. Entity FKs (e.g. a note's project_id) → that feature's save pipeline, applied from onSaved's selection (see features/notes/components/NoteContextSection.tsx as the template).
  • Dimension gating. Pass dimensions={["scopes"]} (or any subset of "scopes" | "projects" | "tasks") to hide sections that do not apply — e.g. project settings (scope tagging only) vs notes (scopes + project/task FK intent). Default is all three. Org dropdown (assignment) and org rows (active/filter) are independent of dimensions.
  • Org default-but-changeable. Assignment mode defaults to "All organizations" (ALL_ORGS sentinel — nothing filtered, scope sections grouped per org, selection.organizationId = null). Surfaces that "enforce" an org pass initialOrganizationId to override that starting selection; the user can always switch (including back to All).
  • No layout shift. Fixed section heights; fixed-size check targets; status icons swap glyphs, never dimensions.
  • Mobile = one bottom sheet. Every wrapper (ContextAssignmentPopover, ContextAssignmentDialog, ContextAssignmentWindow, UploadContextPrompt, ActiveContextLensChip) switches to ContextSheet on useIsMobile() — never a desktop popover/dialog/window on a phone. ContextSheet (built on the BottomSheet primitive) hosts the body with fill, which makes the field's own section list the single scroll area and pins the footer. Never set a fixed sectionHeight inside a sheet (that re-introduces nested scrolling) — pass fill instead. The field's org-of-record row stacks (flex-col sm:flex-row) so it never overflows a narrow screen.
  • Project/task durable links are LIVE via associationsService.setTargets (platform.associations, replace-semantics) — the field's live save path and UploadContextPrompt both write them. Never reintroduce a log-and-toast stub.
  • Active context is MULTI-SCOPE — never radio. Any number of scopes per scope type (Arman, 2026-07-07). Toggle with addActiveScope / removeActiveScope (additive/surgical); never evict same-type siblings, and never read a scope_selections KEY as a scope_type_id — use selectActiveScopeIdsByType.

Live references

  • Design surface / every variant demoed: /demos/scopes/context-lab (app/(dev)/demos/scopes/context-lab/page.dev.tsx).
  • Real integrations to copy from: files table cell (features/files/components/surfaces/desktop/FileContextCell.tsx), upload prompt host (features/files/components/surfaces/PageShell.tsx), note adapter (NoteContextSection.tsx), header button (ChatRunHeader.tsx), run-controls tab (RunControlsMenu.tsx Context tab → ActiveContextPanel).
  • Current state: features/scopes/FEATURE.md (the live SOR for scope/context on the FE).