From 5a906185a673878b671805e95dc230f4c3c69550 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 19 Aug 2026 18:47:46 +0200 Subject: [PATCH] feat: govern task-local quick actions --- docs/QUICK_ACCESS.md | 21 +++-- src/govoplan_quick_access/backend/manifest.py | 25 ++++-- src/govoplan_quick_access/backend/schemas.py | 5 ++ src/govoplan_quick_access/backend/service.py | 5 ++ webui/src/api/quickAccess.ts | 5 ++ webui/src/components/QuickAccessRail.tsx | 77 +++++++++++++++++-- .../settings/QuickAccessSettingsPanel.tsx | 4 +- webui/src/i18n/generatedTranslations.ts | 4 + webui/src/index.ts | 1 + webui/src/styles/quick-access.css | 15 ++++ 10 files changed, 139 insertions(+), 23 deletions(-) diff --git a/docs/QUICK_ACCESS.md b/docs/QUICK_ACCESS.md index 0e079d0..7f77a3b 100644 --- a/docs/QUICK_ACCESS.md +++ b/docs/QUICK_ACCESS.md @@ -37,10 +37,17 @@ owner module. Quick Access is not an authorization boundary. Every contribution keeps its own permission requirements and View surface. Full-page routes remain the -canonical fallback. Launch context version 1 contains only the tenant/account -identity, safe active-object reference, acting-assignment identifiers, -temporal selection, exact View identity, and return route. Cross-tenant object -references are discarded and unknown context versions are ignored. The -destination reauthorizes every read and effect. Disabling this module removes -the rail without making any domain state unavailable through its owning -module. +canonical fallback. Launch-context version 2 contains only the tenant/account +identity, a reference-contract-version-1 active object, acting-assignment +identifiers, temporal selection, exact View identity, View recommendations or +focus, and a safe return route. Cross-tenant object references are discarded +and unknown versions fail closed. View focus applies only when at least one +focused tool is currently enabled, context-compatible, and authorized; a +recommendation only changes ordering and emphasis. + +Each renderer performs owner-side reads and effects and explicitly reports +either a result-contract-version-1 completion with a typed owner reference or +a cancellation reason. Closing the drawer is not completion. The host may +listen for that correlated result while its unsaved page state remains mounted. +Disabling this module removes the rail without making any domain state +unavailable through its owning module. diff --git a/src/govoplan_quick_access/backend/manifest.py b/src/govoplan_quick_access/backend/manifest.py index e399a45..5ef2849 100644 --- a/src/govoplan_quick_access/backend/manifest.py +++ b/src/govoplan_quick_access/backend/manifest.py @@ -121,9 +121,11 @@ DOCUMENTATION = ( "Messages combines enabled Mail, Postbox, and future chat contributions in one overlay. " "Personal settings can reorder or hide items that remain available under system, tenant, " "permission, and View policy. Every item retains a link to its complete owning page. " - "That launch carries a versioned, bounded reference to the current object, acting assignment, " - "temporal selection, View revision, and return location. The destination rechecks access, and " - "the breadcrumb return action restores the originating route without copying protected content." + "Launch-context version 2 carries only versioned, bounded references to the current object, acting assignment, " + "temporal selection, exact View revision, and return location. Owner modules recheck access when a tool opens and " + "before each effect. A tool returns either an explicit version-1 completion with a typed owner reference or an " + "explicit cancellation; closing the drawer does not imply success. The overlay preserves unsaved host-page work, " + "and the complete owning page remains the fallback for work that exceeds the compact surface." ), layer="configured", documentation_types=("user",), @@ -143,8 +145,11 @@ DOCUMENTATION = ( "Eine Kategorie in der rechten Leiste oeffnet kompakte Werkzeuge, ohne die aktuelle Aufgabe zu verlassen. " "Nachrichten fuehrt Beitraege aus Mail, Postfach und kuenftigen Chat-Modulen in einer Einblendung zusammen. " "Persoenliche Einstellungen koennen alle durch System, Mandant, Berechtigungen und Ansicht zugelassenen Eintraege ordnen oder ausblenden. " - "Beim Oeffnen der vollstaendigen Seite werden nur versionierte, begrenzte Verweise auf Objekt, handelnde Zuordnung, " - "Zeitbezug, Ansichtsversion und Ruecksprungort uebergeben. Das Ziel prueft den Zugriff erneut." + "Startkontext Version 2 uebergibt nur versionierte, begrenzte Verweise auf Objekt, handelnde Zuordnung, " + "Zeitbezug, genaue Ansichtsversion und Ruecksprungort. Das besitzende Modul prueft den Zugriff beim Oeffnen " + "und vor jeder Wirkung erneut. Ein Werkzeug meldet entweder einen ausdruecklichen Abschluss mit typisiertem " + "Besitzerverweis oder einen ausdruecklichen Abbruch; das Schliessen gilt nicht als Erfolg. Die Einblendung " + "erhaelt ungespeicherte Arbeit auf der Ausgangsseite, die vollstaendige Besitzerseite bleibt das Ausweichziel." ), } }, @@ -164,7 +169,10 @@ DOCUMENTATION = ( "The catalogue follows installed module registrations. System settings constrain tenants; " "tenant settings constrain users. An item may remain available, be blocked, or be forced. " "Views and permissions form additional ceilings and Quick Access never grants access to domain data. " - "Launch context version 1 rejects cross-tenant active-object references and unknown context versions fail closed." + "A View may recommend tools or focus the rail to a subset, but only currently enabled, context-compatible, authorized " + "tools participate. Workflow receives that same presentation from the exact resolved View revision. Launch-context " + "version 2, reference contract version 1, and result contract version 1 fail closed on unknown versions; cross-tenant " + "active-object and result references are rejected." ), layer="configured", documentation_types=("admin",), @@ -177,7 +185,10 @@ DOCUMENTATION = ( "Der Katalog folgt den Registrierungen installierter Module. Systemeinstellungen begrenzen Mandanten, " "Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. " "Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff. " - "Startkontext Version 1 verwirft mandantenfremde Objektverweise; unbekannte Versionen werden abgelehnt." + "Eine Ansicht darf Werkzeuge empfehlen oder die Leiste auf eine Teilmenge fokussieren, jedoch nur innerhalb " + "der aktivierten, kontextgeeigneten und berechtigten Werkzeuge. Workflow verwendet dieselbe Darstellung aus " + "der genau aufgeloesten Ansichtsversion. Startkontext Version 2 sowie Verweis- und Ergebniskontrakt Version 1 " + "lehnen unbekannte Versionen ab; mandantenfremde Objekt- und Ergebnisverweise werden verworfen." ), } }, diff --git a/src/govoplan_quick_access/backend/schemas.py b/src/govoplan_quick_access/backend/schemas.py index ccd6e48..07af5a9 100644 --- a/src/govoplan_quick_access/backend/schemas.py +++ b/src/govoplan_quick_access/backend/schemas.py @@ -42,6 +42,7 @@ class CatalogueCategoryResponse(BaseModel): class CatalogueToolResponse(BaseModel): + contract_version: str id: str module_id: str category_id: str @@ -55,6 +56,10 @@ class CatalogueToolResponse(BaseModel): order: int default_enabled: bool modes: list[str] + availability: str + accepted_reference_kinds: list[str] + returned_reference_kinds: list[str] + help_context_id: str | None = None class CatalogueResponse(BaseModel): diff --git a/src/govoplan_quick_access/backend/service.py b/src/govoplan_quick_access/backend/service.py index 9b040df..0378a1f 100644 --- a/src/govoplan_quick_access/backend/service.py +++ b/src/govoplan_quick_access/backend/service.py @@ -91,6 +91,7 @@ def build_catalogue( continue tools.append( CatalogueToolResponse( + contract_version=tool.contract_version, id=tool.id, module_id=tool.module_id, category_id=tool.category_id, @@ -104,6 +105,10 @@ def build_catalogue( order=tool.order, default_enabled=tool.default_enabled, modes=list(tool.modes), + availability=tool.availability, + accepted_reference_kinds=list(tool.accepted_reference_kinds), + returned_reference_kinds=list(tool.returned_reference_kinds), + help_context_id=tool.help_context_id, ) ) tools.sort(key=lambda item: (item.category_id, item.order, item.id)) diff --git a/webui/src/api/quickAccess.ts b/webui/src/api/quickAccess.ts index 17dd718..c653276 100644 --- a/webui/src/api/quickAccess.ts +++ b/webui/src/api/quickAccess.ts @@ -27,6 +27,7 @@ export type QuickAccessCategory = { }; export type QuickAccessTool = { + contract_version: "1"; id: string; module_id: string; category_id: string; @@ -40,6 +41,10 @@ export type QuickAccessTool = { order: number; default_enabled: boolean; modes: string[]; + availability: "global" | "active_object"; + accepted_reference_kinds: string[]; + returned_reference_kinds: string[]; + help_context_id?: string | null; }; export type QuickAccessCatalogue = { diff --git a/webui/src/components/QuickAccessRail.tsx b/webui/src/components/QuickAccessRail.tsx index f5b3476..7d0940b 100644 --- a/webui/src/components/QuickAccessRail.tsx +++ b/webui/src/components/QuickAccessRail.tsx @@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { Link, useLocation } from "react-router"; import { DismissibleAlert, + DocumentationHelpLink, IconButton, LoadingFrame, dispatchQuickAccessResult, @@ -51,12 +52,46 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext } [contributions] ); const availableToolIds = useMemo(() => new Set(tools.map((tool) => tool.id)), [tools]); - const categories = useMemo( - () => (effective?.categories ?? []).map((category) => ({ + const metadataById = useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), [tools]); + const focusedToolIds = useMemo( + () => new Set(launchContext.viewContext?.focusedToolIds ?? []), + [launchContext.viewContext?.focusedToolIds] + ); + const recommendedToolIds = useMemo( + () => new Set(launchContext.viewContext?.recommendedToolIds ?? []), + [launchContext.viewContext?.recommendedToolIds] + ); + const categories = useMemo(() => { + const eligibleCategories = (effective?.categories ?? []) + .filter((category) => category.enabled) + .map((category) => ({ + ...category, + tools: category.tools.filter((tool) => { + if (!tool.enabled || !availableToolIds.has(tool.id)) return false; + const metadata = metadataById.get(tool.id); + if (metadata?.availability === "active_object" && !launchContext.activeObject) return false; + if (metadata?.acceptedReferenceKinds.length && launchContext.activeObject) { + const activeKind = `${launchContext.activeObject.ownerModule}.${launchContext.activeObject.kind}`; + if (!metadata.acceptedReferenceKinds.includes(activeKind)) return false; + } + return true; + }) + })); + const hasEligibleFocus = eligibleCategories.some((category) => + category.tools.some((tool) => focusedToolIds.has(tool.id)) + ); + return eligibleCategories.map((category) => ({ ...category, - tools: category.tools.filter((tool) => tool.enabled && availableToolIds.has(tool.id)) - })).filter((category) => category.enabled && category.tools.length > 0), - [availableToolIds, effective] + tools: category.tools.filter((tool) => + !hasEligibleFocus || focusedToolIds.has(tool.id) + ).sort((left, right) => + Number(recommendedToolIds.has(right.id)) - Number(recommendedToolIds.has(left.id)) + || left.order - right.order + || left.id.localeCompare(right.id) + ) + })).filter((category) => category.enabled && category.tools.length > 0); + }, + [availableToolIds, effective, focusedToolIds, launchContext.activeObject, metadataById, recommendedToolIds] ); const activeCategory = categories.find((category) => category.id === activeCategoryId) ?? null; @@ -189,13 +224,27 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext } {activeCategory.tools.map((tool) => { const renderer = renderers.get(tool.id); + const metadata = metadataById.get(tool.id); return ( -
+
{translateText(tool.label)} + {recommendedToolIds.has(tool.id) ? ( + + {translateText("i18n:govoplan-quick-access.recommended_for_view")} + + ) : null} {tool.description ? {translateText(tool.description)} : null}
+ {metadata?.helpContextId ? ( + + ) : null} {tool.full_page_path ? (
- - + +
diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 9963f8a..dcdd429 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -8,6 +8,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-quick-access.loading": "Schnellzugriff wird geladen", "i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen", "i18n:govoplan-quick-access.compact_view_unavailable": "Die kompakte Ansicht ist nicht verfügbar. Verwenden Sie die vollständige Seite.", + "i18n:govoplan-quick-access.recommended_for_view": "Für diese Ansicht empfohlen", + "i18n:govoplan-quick-access.invalid_result": "Das Werkzeug hat ein ungültiges oder nicht zugelassenes Ergebnis zurückgegeben.", "i18n:govoplan-quick-access.settings_title": "Schnellzugriff", "i18n:govoplan-quick-access.personal_help": "Werkzeuge auswählen und ordnen, die neben der aktuellen Arbeit verfügbar bleiben.", "i18n:govoplan-quick-access.admin_help": "Verfügbarkeit, erzwungene Einträge und Standardreihenfolge für nachgeordnete Ebenen festlegen.", @@ -45,6 +47,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-quick-access.loading": "Loading Quick Access", "i18n:govoplan-quick-access.open_full_page": "Open full page", "i18n:govoplan-quick-access.compact_view_unavailable": "The compact view is unavailable. Use the full page.", + "i18n:govoplan-quick-access.recommended_for_view": "Recommended for this View", + "i18n:govoplan-quick-access.invalid_result": "The tool returned an invalid or undeclared result.", "i18n:govoplan-quick-access.settings_title": "Quick Access", "i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.", "i18n:govoplan-quick-access.admin_help": "Set availability, forced items, and default ordering for lower scopes.", diff --git a/webui/src/index.ts b/webui/src/index.ts index 1c5321a..99ee43b 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -1,2 +1,3 @@ export { default, quickAccessModule } from "./module"; +export { default as QuickAccessRail } from "./components/QuickAccessRail"; export * from "./api/quickAccess"; diff --git a/webui/src/styles/quick-access.css b/webui/src/styles/quick-access.css index 874f381..fba8673 100644 --- a/webui/src/styles/quick-access.css +++ b/webui/src/styles/quick-access.css @@ -124,6 +124,21 @@ padding: 14px 16px 16px; } +.quick-access-tool.is-recommended { + border-inline-start: 3px solid var(--accent); + padding-inline-start: 13px; +} + +.quick-access-recommended { + width: fit-content; + border-radius: var(--radius-round); + padding: 1px 7px; + background: var(--accent-soft); + color: var(--accent); + font-size: 11px; + font-weight: 700; +} + .quick-access-tool-heading, .quick-access-settings-heading, .quick-access-preference-row,