Expand request review tooling and KB

This commit is contained in:
Andraxion 2026-06-27 01:12:35 -04:00
parent ab1dfbf029
commit cae21b61b7
16 changed files with 1258 additions and 241 deletions

View file

@ -16,8 +16,12 @@ The goal is to give request-processing tools stable system summaries instead of
- Machine-readable system index.
- `request-analysis-schema.json`
- JSON schema for structured request parsing output.
- `tags.json`
- Standardized request tags for queue analysis and admin review.
- `queue-workflow.md`
- First-pass automation and review flow.
- `editor-capabilities.md`
- Shorthand capability matrix for common request-analysis questions.
- `systems/*.md`
- Human-readable system notes, organized by editor subsystem.
@ -44,10 +48,11 @@ Suggested queue flow:
1. Pull a raw request submission from the launcher request store.
2. Retrieve likely systems from `systems.json` using tags, aliases, and file names.
3. Provide the matching `systems/*.md` files to the local model as context.
4. Ask the model to split the submission into atomic requests.
5. Require output that matches `request-analysis-schema.json`.
6. Validate the JSON before storing anything back into the app.
7. Auto-promote only high-confidence items. Keep low-confidence items in review.
4. Require the model to use only tags from `tags.json`.
5. Ask the model to split the submission into atomic requests.
6. Require output that matches `request-analysis-schema.json`.
7. Validate the JSON before storing anything back into the app.
8. Auto-promote only high-confidence items. Keep low-confidence items in review.
## Recommended Next Additions
@ -98,6 +103,7 @@ $env:REQUEST_ANALYZER_MODEL="deepseek-v4-pro"
- If the model produces multiple atomic requests from one submission, keep the original submission id on every derived item.
- If a request touches multiple systems, prefer one primary category and many tags.
- Review rationale should be short, structured, and safe to show in admin UI. Do not rely on hidden model chain-of-thought.
- If the request is really a bug report, preserve the problem statement and write the implementation note as a likely fix path, not a promise.
- If the model cannot confidently map a request, store it as `needs_review` instead of forcing a category.

View file

@ -0,0 +1,69 @@
# Editor Capabilities Map
This file is a shorthand capability matrix for request analysis. It is intentionally less detailed than the source code, but more concrete than the original scaffold summaries.
## Tile Canvas Editing
- Owned primarily by `Layers And Tile Editing`
- Supports:
- freehand tile painting
- rectangle and circle outline strokes
- line strokes with axis lock
- erasing on the active layer
- sparse height-layer paint and erase workflows
- background-cell modes for explicit tile, transparent hole, and inherit
- Depends on:
- `Chunk Storage And Streaming`
- `Content Catalog And Records`
- `Persistence And Save Pipeline`
- `Rendering And Viewport`
## Graphics Painter
- Owned primarily by `Graphics Painter`
- Supports:
- rectangle, circle, triangle, and line tools
- outline, fill, and outline + fill variants
- shape erasers
- animation timeline editing and preview
- transform-oriented tool menus
- Depends on:
- `Content Catalog And Records`
- `Floating Window Shell`
- `Rendering And Viewport`
## Chunk Operations
- Shared by `Chunk Storage And Streaming` and `World Overview`
- Supports:
- neighborhood chunk loading
- chunk composition into the current world edit surface
- chunk move, duplicate, rotate, flip, and delete
- chunk background tile paint and restore
- bookmark and POI workflows
- Depends on:
- `Persistence And Save Pipeline`
- `Worlds`
- `Windows`
## Request Review
- Owned primarily by `Request Board`
- Supports:
- public submission intake
- standardized tag assignment
- structured review rationale
- possible options for manual triage
- admin-side edits and approval
- queue worker invocation
- Depends on:
- `Launcher`
- `Wiki`
- request-analysis worker and KB files
## Relationship Rules For Triage
- If a request asks for how a brush or rectangle tool behaves on the map canvas, start with `Layers And Tile Editing`.
- If a request asks for how a rectangle or shape tool behaves inside the art editor, start with `Graphics Painter`.
- If a request asks about chunk movement, chunk duplication, or world-scale operations, start with `World Overview`, then confirm any storage implications in `Chunk Storage And Streaming`.
- If a request is mostly about presentation, launch flow, repo links, or public site layout, start with `Launcher Home`.

View file

@ -18,9 +18,10 @@ Turn raw launcher submissions into structured request items that are:
2. Split it into candidate atomic requests.
3. Retrieve likely systems from `docs/kb/systems.json`.
4. Load the matching `docs/kb/systems/*.md` files.
5. Ask the local model for JSON that matches `docs/kb/request-analysis-schema.json`.
6. Validate the JSON.
7. Promote high-confidence items and hold low-confidence items for review.
5. Load the standardized request tags from `docs/kb/tags.json`.
6. Ask the local model for JSON that matches `docs/kb/request-analysis-schema.json`.
7. Validate the JSON.
8. Promote high-confidence items and hold low-confidence items for review.
## Suggested Confidence Rules
@ -52,10 +53,11 @@ You are processing Worldshaper editor requests.
You must:
- split the submission into one or more atomic requests
- assign one primary category per item
- assign tags grounded in the provided KB
- assign tags grounded in the provided KB and limited to `docs/kb/tags.json`
- write a short descriptive title
- explain the user intent in plain language
- propose a likely implementation path
- provide a short review rationale and possible options when confidence is low
- avoid inventing systems that are not in the KB
- return only valid JSON matching the provided schema

View file

@ -105,6 +105,17 @@
"minimum": 0,
"maximum": 1
},
"reviewRationale": {
"type": "string"
},
"reviewOptions": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"notes": {
"type": "string"
}

View file

@ -7,7 +7,7 @@
"name": "Launcher Home",
"docPath": "docs/kb/systems/launcher-home.md",
"aliases": ["launcher", "main page", "home page", "landing page"],
"tags": ["Launcher", "News", "Requests", "Presentation"],
"tags": ["Launcher", "Request Board", "Website", "Polish"],
"uiSurfaces": ["Launcher hero", "News tab", "Requests tab"],
"keyFiles": ["src/WorldshaperLauncher.tsx", "src/index.css", "src/worldshaperStudio/windowing.ts"],
"apiEndpoints": ["/api/world-default", "/api/launcher-requests"],
@ -19,7 +19,7 @@
"name": "Request Board",
"docPath": "docs/kb/systems/request-board.md",
"aliases": ["requests", "request queue", "request intake", "request board"],
"tags": ["Requests", "Launcher", "Workflow", "Moderation"],
"tags": ["Request Board", "Launcher", "UI / Workflow", "Website"],
"uiSurfaces": ["Launcher Requests tab"],
"keyFiles": ["src/WorldshaperLauncher.tsx", "server.js", "data/launcher_requests.json"],
"apiEndpoints": ["/api/launcher-requests", "/api/launcher-requests/:requestId"],
@ -31,7 +31,7 @@
"name": "Studio Bootstrap",
"docPath": "docs/kb/systems/world-bootstrap.md",
"aliases": ["bootstrap", "startup", "world load", "initial load"],
"tags": ["Bootstrap", "Worlds", "Chunks", "Startup"],
"tags": ["Worlds", "Chunks", "Launcher", "Persistence"],
"uiSurfaces": ["Studio window"],
"keyFiles": ["src/worldshaperStudio/bootstrap.ts", "src/worldshaperStudio/main.ts", "src/worldshaperStudio/runtime.ts"],
"apiEndpoints": ["/api/world-default", "/api/world/:worldId", "/api/world/:worldId/bookmarks", "/api/world/:worldId/chunks"],
@ -43,7 +43,7 @@
"name": "Chunk Storage And Streaming",
"docPath": "docs/kb/systems/chunk-storage-streaming.md",
"aliases": ["chunks", "chunk loading", "world streaming", "chunk cache", "world neighborhood"],
"tags": ["Chunks", "Streaming", "Worlds", "Performance"],
"tags": ["Chunks", "Worlds", "Performance", "Persistence"],
"uiSurfaces": ["World editor viewport", "World mode"],
"keyFiles": ["server.js", "src/worldChunking.ts", "src/worldshaperStudio/bootstrap.ts", "src/worldshaperStudio/runtime.ts"],
"apiEndpoints": ["/api/world/:worldId/chunk/:chunkX/:chunkY", "/api/world/:worldId/chunks", "/api/world/:worldId/chunks/batch-save"],
@ -55,7 +55,7 @@
"name": "Layers And Tile Editing",
"docPath": "docs/kb/systems/layers-tile-editing.md",
"aliases": ["layers", "tile painting", "height layers", "brushes", "eraser"],
"tags": ["Layers", "Tiling", "Editing", "Height Layers"],
"tags": ["Layers", "Tiling", "UI / Workflow", "Worlds"],
"uiSurfaces": ["Layers sidebar", "Canvas tools", "Viewport"],
"keyFiles": ["src/worldshaperStudio/interactionController.ts", "src/worldshaperStudio/runtime.ts", "src/worldshaperStudio/sidebarController.ts", "src/worldshaperStudio/mapDocumentController.ts", "src/worldshaperStudio/popupSessionStore.ts"],
"apiEndpoints": ["/api/world/:worldId/chunks/batch-save"],
@ -67,7 +67,7 @@
"name": "Graphics Painter",
"docPath": "docs/kb/systems/graphics-painter.md",
"aliases": ["graphic painter", "graphics painter", "tile art", "sprite painter", "animation painter"],
"tags": ["Graphics Painter", "Animation", "Tiles", "Sprites"],
"tags": ["Graphics Painter", "Animation", "Content", "Windows"],
"uiSurfaces": ["Graphic Painter window", "Animation preview"],
"keyFiles": ["src/worldshaperStudio/tileArtEditorWindowController.ts", "src/worldshaperStudio/graphicsDocumentHelpers.ts", "src/worldshaperStudio/importController.ts", "src/worldshaperStudio/dom.ts"],
"apiEndpoints": ["/api/content/images", "/api/content/sprites", "/api/content/tiles", "/api/images", "/api/images/:filename"],
@ -79,7 +79,7 @@
"name": "Rendering And Viewport",
"docPath": "docs/kb/systems/rendering-viewport.md",
"aliases": ["renderer", "pixi", "viewport", "camera", "draw", "chunk culling"],
"tags": ["Rendering", "Viewport", "Pixi", "Performance"],
"tags": ["Rendering", "Performance", "Worlds", "Chunks"],
"uiSurfaces": ["World canvas", "Debug overlay"],
"keyFiles": ["src/worldshaperStudio/renderController.ts", "src/worldshaperStudio/pixiTileStageController.ts", "src/worldshaperStudio/pixiSceneRebuildHelpers.ts", "src/worldshaperStudio/pixiChunkSurfaceHelpers.ts", "src/worldshaperStudio/overlayRenderer.ts"],
"apiEndpoints": [],
@ -91,7 +91,7 @@
"name": "World Overview",
"docPath": "docs/kb/systems/world-overview.md",
"aliases": ["overview", "world overview", "chunk overview", "overview map"],
"tags": ["World Overview", "Chunks", "Bookmarks", "Navigation"],
"tags": ["World Overview", "Chunks", "Windows", "Worlds"],
"uiSurfaces": ["World Overview window"],
"keyFiles": ["src/worldshaperStudio/worldOverviewWindowController.ts", "src/worldshaperStudio/dom.ts", "server.js"],
"apiEndpoints": ["/api/world/:worldId/overview", "/api/world/:worldId/bookmarks"],
@ -103,7 +103,7 @@
"name": "Persistence And Save Pipeline",
"docPath": "docs/kb/systems/persistence-save-pipeline.md",
"aliases": ["save", "saving", "persist", "world save", "content save"],
"tags": ["Persistence", "Saving", "APIs", "Worlds", "Content"],
"tags": ["Persistence", "Content", "Chunks", "Worlds"],
"uiSurfaces": ["Toolbar save flow", "Status messages"],
"keyFiles": ["src/worldshaperStudio/persistenceController.ts", "src/worldshaperStudio/historyController.ts", "src/worldshaperStudio/historyStateStore.ts", "server.js"],
"apiEndpoints": ["/api/world/:worldId/chunks/batch-save", "/api/content/:type", "/api/editor-settings", "/api/catalog-meta"],
@ -115,7 +115,7 @@
"name": "Floating Window Shell",
"docPath": "docs/kb/systems/floating-window-shell.md",
"aliases": ["floating window", "popout", "windowing", "tool windows"],
"tags": ["Windows", "UI", "Launcher", "Tooling"],
"tags": ["Windows", "Launcher", "UI / Workflow", "Graphics Painter"],
"uiSurfaces": ["Studio popout", "Tool windows", "Overlay layer"],
"keyFiles": ["src/worldshaperStudio/windowing.ts", "src/worldshaperStudio/floatingWindowUtils.ts", "src/worldshaperStudio/toolWindowController.ts", "src/worldshaperStudio/changelogSplashWindowController.ts", "src/worldshaperStudio/engineOverrideWindowController.ts"],
"apiEndpoints": [],
@ -127,7 +127,7 @@
"name": "Content Catalog And Records",
"docPath": "docs/kb/systems/content-catalog-records.md",
"aliases": ["content", "tiles", "sprites", "images", "catalog", "records"],
"tags": ["Content", "Tiles", "Sprites", "Images", "Schemas"],
"tags": ["Content", "Graphics Painter", "Tiling", "Persistence"],
"uiSurfaces": ["Content records", "Graphic Painter inputs", "Tile palette"],
"keyFiles": ["server.js", "src/App.tsx", "src/components/worldshaperShared.ts", "src/worldshaperStudio/graphicsDocumentHelpers.ts", "src/worldshaperStudio/importController.ts"],
"apiEndpoints": ["/api/content/:type", "/api/content/tiles/:tileId/delete", "/api/content/sprites/:spriteId/delete", "/api/catalog-meta", "/api/images", "/api/images/:filename"],

View file

@ -1,48 +1,47 @@
# Chunk Storage And Streaming
## What It Does
## What It Owns
World data is stored as per-chunk JSON files and loaded into the editor as chunk neighborhoods around a center chunk. The runtime caches chunk payloads, shifts neighborhoods as the viewport moves, and saves dirty chunks in batches.
This system owns the world-mode chunk model: loading chunk neighborhoods, composing chunk data into the current editor session, and persisting chunk updates back to disk.
## Key Files
## Core Behaviors
- Load a neighborhood of chunks around a world position.
- Compose room layers from chunk-local rows into a temporary larger editing surface.
- Compose sparse height layers from chunk data.
- Compose placed instances and overlays from chunk payloads.
- Batch-save chunk changes back to the API.
## Important Data
Chunk payloads can contribute:
- `roomLayers`
- `heightLayers`
- `instances`
- `backgroundTileId`
- chunk coordinates and dimensions
## Important Files
- `server.js`
- `src/worldChunking.ts`
- `src/worldshaperStudio/bootstrap.ts`
- `src/worldshaperStudio/runtime.ts`
- `src/worldshaperStudio/persistenceController.ts`
## Endpoints It Uses
## Known Behavior Notes
- `GET /api/world/:worldId/chunk/:chunkX/:chunkY`
- `POST /api/world/:worldId/chunk/:chunkX/:chunkY`
- `GET /api/world/:worldId/chunks`
- `POST /api/world/:worldId/chunks/batch-save`
- The bootstrap path currently loads a neighborhood around an initial bookmark or world center instead of streaming every chunk in the world.
- Exact chunk access is grid-based, so many performance requests should start with profiling the current neighborhood/cache path before proposing spatial trees.
- World mode is not editing one isolated chunk in a vacuum; it edits a composed surface backed by many chunk records.
## Important Data And Rules
## Relationships
- chunks are stored as `content/worlds/<worldId>/chunks/<x>_<y>.json`
- world neighborhoods are requested as a square around a center chunk
- runtime keeps a chunk cache and tracks dirty chunk keys
- chunk payloads include `roomLayers`, `heightLayers`, `instances`, and optional `backgroundTileId`
- Feeds `Layers And Tile Editing`, which paints onto the composed chunk neighborhood.
- Feeds `World Overview`, which visualizes and transforms chunk-level records.
- Depends on `Persistence And Save Pipeline` to write chunk mutations.
- Feeds `Rendering And Viewport`, which only draws what the currently loaded chunk neighborhood exposes.
## Invariants And Constraints
## Triage Hints
- Exact chunk lookup is grid-based and direct.
- The editor does not load the entire world for normal editing.
- Small tile edits must sync back into the correct cached chunk.
- Save flow should preserve chunk-local edits without rebuilding unrelated chunks.
## Common Request Themes
- chunk loading speed
- chunk streaming behavior
- per-chunk backgrounds
- chunk transforms, duplication, deletion
- performance questions around indexing and caching
## Triage Questions
- Is the request about storage, fetch patterns, cache behavior, or save behavior?
- Does it affect only visible chunks or the full world?
- Is the user asking for chunk-local data, or freeform world overlays that break chunk assumptions?
Requests about chunk loading, chunk transforms, chunk duplication, world streaming, chunk persistence, spatial indexing, and neighborhood performance should start here.

View file

@ -1,48 +1,55 @@
# Graphics Painter
## What It Does
## What It Owns
The Graphics Painter is the tile and sprite art editing window. It handles pixel editing, frame management, animation timeline editing, preview playback, and import-oriented art workflows.
This is the dedicated art-editing environment for tile and sprite graphics. It covers shape tools, erasers, transforms, previewing, and animation timeline editing for the currently opened asset.
## Key Files
## Core Behaviors
- Shape drawing modes:
- rectangle
- circle
- triangle
- line
- Shape variants:
- outline
- fill
- outline + fill
- Shape erasers:
- rectangle
- circle
- triangle
- Transform workflows available through the painter menu.
- Animation timeline controls, frame order, playback speed, and preview.
## Important UI Surfaces
- Graphic Painter window
- Tool menu and tooltip menus
- Animation timeline row
- Preview card and frame preview
## Important Files
- `src/worldshaperStudio/tileArtEditorWindowController.ts`
- `src/worldshaperStudio/graphicsDocumentHelpers.ts`
- `src/worldshaperStudio/importController.ts`
- `src/worldshaperStudio/dom.ts`
## Endpoints It Uses
## Known Tool Details
- `GET /api/images`
- `GET /api/images/:filename`
- `POST /api/content/images`
- `POST /api/content/sprites`
- `POST /api/content/tiles`
- Shape menus are nested by shape and variant, rather than being a single flat tool list.
- The active draw tool and active eraser shape are tracked separately.
- The painter already knows about animation preview, frame creation, and per-frame editing; many animation requests should be phrased as extensions to an existing timeline rather than brand-new systems.
- The preview and animation controls are editor-side only; runtime rendering requests often cross into `Rendering And Viewport`.
## Important Data And Rules
## Relationships
- graphics records may include animation frame data and playback metadata
- preview behavior is maintained in painter-local UI state
- art changes eventually flow into tile and sprite content records
- imports and previews are tightly tied to painter usability
- Depends on `Content Catalog And Records` for source records and saved asset definitions.
- Depends on `Floating Window Shell` because the painter lives inside a movable editor window.
- Feeds `Rendering And Viewport` when edited assets are later shown in the live map renderer.
- Intersects with `Animation`-tagged requests when frame behavior changes.
## Invariants And Constraints
## Triage Hints
- the painter is a floating tool window, not a docked pane
- animation preview should reflect current frame ordering and speed settings
- save behavior must preserve frame structure and timing metadata
- painter workflows often depend on tile and sprite schema conventions
## Common Request Themes
- move or selection tools
- frame duplication and frame management
- animation controls
- import workflow improvements
- better preview behavior
## Triage Questions
- Is this request about art authoring, record storage, or runtime playback?
- Does it affect tiles, sprites, or both?
- Is the requested behavior inside the painter window only?
Requests about sprite drawing, tile art tools, frame duplication, painter transforms, selection or move workflows, and editor-side animation controls should usually start here.

View file

@ -1,42 +1,36 @@
# Launcher Home
## What It Does
## What It Owns
The launcher is the public-facing entry point for Worldshaper. It presents the hero card, opens the editor in its own floating window, links to the repo, and hosts the News and Requests tabs.
The launcher is the public-facing web shell for Worldshaper. It handles editor launch, launch presentation, release notes, repo navigation, and the visible request board entry point.
## Key Files
## Core Behaviors
- Open the editor in a separate floating window.
- Show the release/news presentation.
- Show the Requests board.
- Swap between public request browsing and protected admin review mode.
- Present launcher artwork and page-level theming.
## Important Files
- `src/WorldshaperLauncher.tsx`
- `src/index.css`
- `src/worldshaperStudio/windowing.ts`
## Endpoints It Uses
## Important UI Surfaces
- `GET /api/world-default`
- `GET /api/launcher-requests`
- Hero launch card
- News tab
- Requests tab
- Protected admin unlock flow
## Important State
## Relationships
- launch state: ready, opening, opened, blocked, error
- active board tab: news or requests
- request list, filters, and expanded request rows
- resolved default world id
- Depends on `Floating Window Shell` for popup launch behavior.
- Depends on `Request Board` for request intake and moderation UI.
- Intersects with `Website` and `Polish` requests more than the editor-runtime systems do.
## Invariants And Constraints
## Triage Hints
- The editor is intended to open in a separate popup-style window.
- The launcher should still work if the default world lookup fails by falling back to `overworld`.
- Browser popup blocking is a real failure mode and must be surfaced to the user.
## Common Request Themes
- page styling and presentation
- launch flow and popup behavior
- repo button or external links
- tab layout and launcher information architecture
## Triage Questions
- Is this about the launcher page itself, or the studio window after launch?
- Does it affect only presentation, or also request API behavior?
- Does it depend on popup/window behavior?
Requests about the landing page, launch flow, background presentation, release content, public request visibility, and repo-linking should start here.

View file

@ -1,47 +1,55 @@
# Layers And Tile Editing
## What It Does
## What It Owns
This subsystem handles normal tile painting, erasing, selection, visible layer management, sparse height layers, and the mapping between visible document edits and stored chunk data.
This system owns the main world-canvas tile workflow: selecting a room layer, choosing a tile brush, painting, erasing, and editing sparse height layers in world mode.
## Key Files
## Core Behaviors
- Freehand tile painting on the active layer.
- Shape strokes for the world canvas:
- freehand
- rectangle outline
- circle outline
- line with axis lock behavior
- Height-layer painting modes:
- single or freehand paint
- rectangle fill
- circle fill
- erase variants for the same shapes
- Background-cell editing modes:
- explicit tile stamp
- transparent hole
- inherit world background
## Important UI Surfaces
- Layers sidebar
- Height layer list
- Canvas brush interactions
- Background mode button and preview
## Important Files
- `src/worldshaperStudio/interactionController.ts`
- `src/worldshaperStudio/runtime.ts`
- `src/worldshaperStudio/sidebarController.ts`
- `src/worldshaperStudio/runtime.ts`
- `src/worldshaperStudio/mapDocumentController.ts`
- `src/worldshaperStudio/popupSessionStore.ts`
- `src/worldshaperStudio/dom.ts`
## Endpoints It Uses
## Known Tool Details
- primarily saved through `POST /api/world/:worldId/chunks/batch-save`
- Tile strokes register history entries with shape-specific labels such as rectangle stroke, circle stroke, brush stroke, and erase stroke.
- Height strokes register their own history labels and track the target Z layer.
- Layer editing is tightly coupled to the currently loaded chunk neighborhood in world mode.
- Brush behavior depends on catalog tile metadata resolved through runtime helpers.
## Important Data And Rules
## Relationships
- room layer `0` is the base layer
- non-base layers use sparse or transparent-style fill semantics
- height layers are separate structures from room layers
- visibility is tracked in popup session state
- painting in world mode must mark affected chunks dirty
- Depends on `Content Catalog And Records` for tile definitions and brush metadata.
- Depends on `Chunk Storage And Streaming` for the currently editable chunk neighborhood.
- Depends on `Persistence And Save Pipeline` for actually writing edits back to storage.
- Feeds `Rendering And Viewport`, which visualizes the edited layers and sparse height patches.
## Invariants And Constraints
## Triage Hints
- layer ordering matters to rendering and selection behavior
- layer visibility must not destroy layer data
- tile edits in world mode must map local viewport coordinates back to world chunk coordinates
- height layers are not the same thing as standard room layers
## Common Request Themes
- better layer reordering
- more precise tiling tools
- height/elevation features
- snapping or unsnapped placement
- erase or selection behavior
## Triage Questions
- Is this about normal room layers or height layers?
- Does the request affect paint semantics, visibility, ordering, or storage?
- Does it apply to map mode, world mode, or both?
Requests about rectangle tools, brush behavior, height painting, active layer confusion, background stamping, and tile-placement modes should usually start here, even when they also touch rendering or chunk persistence.

View file

@ -1,54 +1,47 @@
# Request Board
## What It Does
## What It Owns
The request board lives on the launcher and stores lightweight user submissions. It currently supports pending and active request states, filtering, expansion of active items, and deletion with confirmation.
The request board is the launcher-side intake, queue, moderation, and review surface for product requests. It stores raw submissions, structured analysis, active request rows, and admin-only review actions.
## Key Files
## Core Behaviors
- `src/WorldshaperLauncher.tsx`
- `server.js`
- `data/launcher_requests.json`
- Save public request submissions.
- Keep raw source text alongside normalized active requests.
- Store structured analysis metadata for pending requests.
- Filter by status and standardized tags.
- Protect admin review actions behind server-side password checks.
- Promote reviewed request items into active request rows.
## Endpoints It Uses
## Important Endpoints
- `GET /api/launcher-requests`
- `POST /api/launcher-requests`
- `PATCH /api/launcher-requests/:requestId`
- `DELETE /api/launcher-requests/:requestId`
- `POST /api/launcher-requests/:requestId/process-analysis`
- `POST /api/launcher-requests/process-pending`
- `GET /api/launcher-request-meta`
- `POST /api/admin/auth-check`
## Important Data Shape
Current request records include:
Request records can now contain:
- `id`
- `sourceSubmissionId`
- `title`
- `status`
- `category`
- `tags`
- `sourceText`
- `summary`
- `implementationNotes`
- `createdAt`
- `updatedAt`
- public fields such as title, category, tags, summary, and implementation notes
- raw `sourceText`
- `analysis.state`
- `analysis.items[]`
- per-item review rationale
- per-item possible options
- structured affected-system and affected-file hints
## Invariants And Constraints
## Relationships
- New submissions enter as pending unless promoted.
- One raw submission may contain multiple real feature requests.
- Active requests should be normalized, titled, and tagged.
- Deletion is destructive and should remain explicit.
- Depends on `Launcher Home` for the public site presentation.
- Depends on the KB under `docs/kb/` for model-grounded request parsing.
- Depends on the request-analysis worker for automated triage.
## Common Request Themes
## Triage Hints
- parse and split user submissions
- request tags and filtering
- moderation and cleanup
- queue automation and confidence review
## Triage Questions
- Is the user asking to change request storage, presentation, or parsing behavior?
- Should one submission become many active requests?
- Is the request really a bug report or a feature idea?
Requests about queue automation, moderation, review UI, request splitting, approval workflows, structured tags, and admin tooling should start here.

View file

@ -1,45 +1,44 @@
# World Overview
## What It Does
## What It Owns
World Overview is a separate window that displays a coarse map of all known chunks, supports bookmark navigation, and exposes chunk operations such as move, duplicate, rotate, flip, delete, and background adjustments.
The World Overview is the large-scale chunk map used for navigation, chunk inspection, bookmark placement, and chunk-level operations that would be awkward from the tile canvas alone.
## Key Files
## Core Behaviors
- Navigate directly to a chunk.
- Move chunk content.
- Duplicate chunk tiles.
- Flip and rotate chunk content.
- Delete a chunk and clear its tiles, height data, and placed entities.
- Paint or restore the chunk background tile.
- Create POIs and bookmarks from chunk positions.
## Important UI Surfaces
- World Overview floating window
- Chunk context menu
- Pending chunk-action flow
- Bookmark and POI display
## Important Files
- `src/worldshaperStudio/worldOverviewWindowController.ts`
- `src/worldshaperStudio/dom.ts`
- `server.js`
## Endpoints It Uses
## Known Behavior Notes
- `GET /api/world/:worldId/overview`
- `GET /api/world/:worldId/bookmarks`
- bookmark writes flow through the world save path
- The chunk context menu is already a rich operations hub, so many requests in this area are extensions to an existing menu rather than requests for a brand-new system.
- Pending move and duplicate actions are stateful; the overview can wait for a second chunk selection before executing.
- The overview also exposes quick background-painting workflows at the chunk level.
## Important Data And Rules
## Relationships
- overview currently requests the full set of world chunks from the server
- it draws its own chunk preview surfaces
- it merges server chunks with cached client chunks to reflect unsaved local state
- chunk context menus expose world-level operations
- Depends on `Chunk Storage And Streaming` for chunk records and chunk dimensions.
- Depends on `Windows` because the overview lives in its own floating tool window.
- Often intersects with `Layers` and `Tiling` when users want higher-level terrain or chunk painting workflows.
## Invariants And Constraints
## Triage Hints
- overview is only available in world mode
- overview actions should not silently discard unsaved chunk edits
- chunk transform actions must preserve chunk addressing rules
- the full overview payload can become a performance hotspot on larger worlds
## Common Request Themes
- chunk operations
- world navigation
- bookmark workflows
- overview performance
- better high-level visualization
## Triage Questions
- Is the issue specific to the overview window, or all world editing?
- Does it affect overview drawing only, or also chunk save/load behavior?
- Is the request about overview scale, navigation, or chunk manipulation?
Requests about chunk move, duplicate, rotate, delete, POIs, overview navigation, and world-scale map management should begin here.

132
docs/kb/tags.json Normal file
View file

@ -0,0 +1,132 @@
{
"schemaVersion": 1,
"generatedFromRepoDate": "2026-06-27",
"tags": [
{
"id": "launcher",
"label": "Launcher",
"description": "The public landing page and launch flow for Worldshaper Studio.",
"aliases": ["launcher home", "home page", "landing page", "main page"]
},
{
"id": "request-board",
"label": "Request Board",
"description": "The launcher request queue, review tooling, and moderation workflows.",
"aliases": ["requests", "request queue", "moderation", "request intake"]
},
{
"id": "chunks",
"label": "Chunks",
"description": "Chunk storage, chunk transforms, chunk loading, and chunk-level map operations.",
"aliases": ["chunk", "chunk loading", "chunk storage", "chunk overview"]
},
{
"id": "layers",
"label": "Layers",
"description": "Base room layers, height layers, and layer management workflows.",
"aliases": ["layer", "height layers", "z layers"]
},
{
"id": "tiling",
"label": "Tiling",
"description": "Tile painting, shape strokes, grid editing, and tile placement workflows.",
"aliases": ["tile", "tile editing", "tile painting", "brush"]
},
{
"id": "graphics-painter",
"label": "Graphics Painter",
"description": "The tile and sprite art editor, including transforms and frame editing.",
"aliases": ["graphic painter", "tile art", "sprite painter", "art editor"]
},
{
"id": "rendering",
"label": "Rendering",
"description": "Viewport drawing, visual composition, and renderer-facing behavior.",
"aliases": ["renderer", "viewport", "pixi", "draw"]
},
{
"id": "animation",
"label": "Animation",
"description": "Frame timelines, animation playback, and animated graphics behavior.",
"aliases": ["animated", "timeline", "frame playback"]
},
{
"id": "content",
"label": "Content",
"description": "Content records, tile and sprite catalogs, imported images, and asset metadata.",
"aliases": ["assets", "catalog", "tiles", "sprites", "images"]
},
{
"id": "world-overview",
"label": "World Overview",
"description": "The large-scale overview map, navigation, bookmarks, and chunk context actions.",
"aliases": ["overview", "world map", "bookmarks", "poi"]
},
{
"id": "windows",
"label": "Windows",
"description": "Floating windows, popouts, and dockable or movable tool shells.",
"aliases": ["windowing", "popout", "tool windows", "floating window"]
},
{
"id": "persistence",
"label": "Persistence",
"description": "Saving, history, API persistence, and data write pipelines.",
"aliases": ["saving", "save pipeline", "history", "undo", "redo"]
},
{
"id": "performance",
"label": "Performance",
"description": "Profiling, loading speed, rendering cost, and optimization work.",
"aliases": ["optimization", "slow", "lag", "streaming"]
},
{
"id": "ui-workflow",
"label": "UI / Workflow",
"description": "Interaction design, prompts, confirmations, and cross-tool user flow improvements.",
"aliases": ["workflow", "ux", "ui", "quality of life", "qol"]
},
{
"id": "worlds",
"label": "Worlds",
"description": "World-level bootstrap, defaults, map metadata, and multi-world concerns.",
"aliases": ["world", "world bootstrap", "startup"]
},
{
"id": "website",
"label": "Website",
"description": "The public site, launch presentation, and web-facing pages outside the editor runtime.",
"aliases": ["site", "web", "homepage"]
},
{
"id": "repo",
"label": "Repo",
"description": "Repository, source control, and Forgejo-facing workflows.",
"aliases": ["repository", "forgejo", "git"]
},
{
"id": "wiki",
"label": "Wiki",
"description": "Internal docs, guides, and knowledge-base style content.",
"aliases": ["kb", "knowledge base", "docs", "documentation"]
},
{
"id": "polish",
"label": "Polish",
"description": "Small fit-and-finish improvements that improve feel, clarity, or consistency.",
"aliases": ["cleanup", "fit and finish", "presentation"]
},
{
"id": "general",
"label": "General",
"description": "Broad requests that span multiple systems but still sound actionable.",
"aliases": ["broad", "misc", "cross-system"]
},
{
"id": "other",
"label": "Other",
"description": "Fallback tag for requests that do not match the current standardized catalog.",
"aliases": ["unknown", "unclear", "unsorted"]
}
]
}

View file

@ -6,6 +6,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..");
const kbRoot = path.join(repoRoot, "docs", "kb");
const requestTagCatalogPath = path.join(kbRoot, "tags.json");
const DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
const DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-flash";
const DEFAULT_PROVIDER = process.env.REQUEST_ANALYZER_PROVIDER
@ -231,6 +232,48 @@ function uniqueStrings(values) {
return next;
}
function normalizeRequestTagLookupValue(value) {
return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
}
async function loadRequestTagCatalog() {
const payload = JSON.parse(await fs.readFile(requestTagCatalogPath, "utf8"));
const tags = Array.isArray(payload?.tags)
? payload.tags
.map((entry) => ({
id: String(entry?.id || "").trim(),
label: String(entry?.label || "").trim(),
aliases: Array.isArray(entry?.aliases) ? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean) : [],
}))
.filter((entry) => entry.id && entry.label)
: [];
if (tags.length === 0) {
throw new Error("Request tag catalog did not contain any valid tags.");
}
return tags;
}
function buildRequestTagLookup(tagDefinitions) {
return new Map(
(Array.isArray(tagDefinitions) ? tagDefinitions : []).flatMap((entry) => [
[normalizeRequestTagLookupValue(entry.label), entry.label],
...(Array.isArray(entry.aliases) ? entry.aliases.map((alias) => [normalizeRequestTagLookupValue(alias), entry.label]) : []),
]),
);
}
function normalizeRequestTags(values, tagLookup, fallback = []) {
const normalized = uniqueStrings(Array.isArray(values) ? values : [])
.map((entry) => tagLookup.get(normalizeRequestTagLookupValue(entry)) || "")
.filter(Boolean);
if (normalized.length > 0) {
return uniqueStrings(normalized);
}
return uniqueStrings(Array.isArray(fallback) ? fallback : [])
.map((entry) => tagLookup.get(normalizeRequestTagLookupValue(entry)) || "")
.filter(Boolean);
}
function clampConfidence(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
@ -264,7 +307,7 @@ function buildFallbackTitle(text, fallback = "Pending request") {
return firstSentence.length > 72 ? `${firstSentence.slice(0, 69).trim()}...` : firstSentence;
}
function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSystems = []) {
function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSystems = [], kb = null) {
const source = rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)
? rawItem
: null;
@ -281,15 +324,21 @@ function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSyste
const primaryCategory = String(source.primaryCategory || source.category || "").trim()
|| String(relevantSystems[0]?.name || "Unsorted");
const affectedSystems = uniqueStrings(source.affectedSystems);
const defaultTags = uniqueStrings([
const defaultTags = [
primaryCategory,
...affectedSystems,
]);
const tags = uniqueStrings(Array.isArray(source.tags) ? source.tags : defaultTags);
];
const tags = normalizeRequestTags(
Array.isArray(source.tags) ? source.tags : defaultTags,
kb?.requestTagLookup || new Map(),
defaultTags,
);
const reviewRationale = String(source.reviewRationale || source.reviewReason || source.notes || "").trim();
const reviewOptions = uniqueStrings(source.reviewOptions);
return {
title,
primaryCategory,
tags: tags.length > 0 ? tags : ["Unsorted"],
tags: tags.length > 0 ? tags : ["General"],
statusRecommendation: normalizeItemStatusRecommendation(source.statusRecommendation || source.status),
parsedInterpretation,
implementationApproach,
@ -298,11 +347,13 @@ function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSyste
problemType: normalizeProblemType(source.problemType),
rawExcerpt: String(source.rawExcerpt || "").trim(),
confidence: clampConfidence(source.confidence),
reviewRationale,
reviewOptions,
notes: String(source.notes || "").trim(),
};
}
function normalizeAnalysisResult(rawResult, request, relevantSystems) {
function normalizeAnalysisResult(rawResult, request, relevantSystems, kb = null) {
const source = rawResult && typeof rawResult === "object" && !Array.isArray(rawResult)
? rawResult
: (Array.isArray(rawResult) ? { items: rawResult } : null);
@ -310,7 +361,7 @@ function normalizeAnalysisResult(rawResult, request, relevantSystems) {
throw new Error("Model response was not a JSON object.");
}
const items = (Array.isArray(source.items) ? source.items : [])
.map((item, index) => normalizeAnalysisItem(item, index, request, relevantSystems))
.map((item, index) => normalizeAnalysisItem(item, index, request, relevantSystems, kb))
.filter(Boolean);
if (items.length === 0) {
throw new Error("Model response did not contain any valid request items.");
@ -336,6 +387,7 @@ async function loadKnowledgeBase() {
const requestSchemaPath = path.join(kbRoot, "request-analysis-schema.json");
const systemsIndex = JSON.parse(await fs.readFile(systemsIndexPath, "utf8"));
const requestSchema = JSON.parse(await fs.readFile(requestSchemaPath, "utf8"));
const requestTagDefinitions = await loadRequestTagCatalog();
const docsById = new Map();
for (const system of Array.isArray(systemsIndex.systems) ? systemsIndex.systems : []) {
const docPath = path.join(repoRoot, String(system.docPath || "").replace(/\//g, path.sep));
@ -345,6 +397,8 @@ async function loadKnowledgeBase() {
return {
systemsIndex,
requestSchema,
requestTagDefinitions,
requestTagLookup: buildRequestTagLookup(requestTagDefinitions),
docsById,
};
}
@ -400,8 +454,11 @@ function pickRelevantSystems(kb, requestText, limit = 4) {
return ranked.slice(0, limit);
}
function buildPrompt(request, relevantSystems, schema) {
function buildPrompt(request, relevantSystems, schema, kb) {
const schemaSummary = JSON.stringify(schema, null, 2);
const tagCatalogSummary = Array.isArray(kb?.requestTagDefinitions)
? kb.requestTagDefinitions.map((entry) => `- ${entry.label}: ${entry.description || ""}`.trim()).join("\n")
: "";
const systemDocs = relevantSystems.map(({ system, docText }) => {
return [
`System: ${system.name}`,
@ -419,6 +476,8 @@ function buildPrompt(request, relevantSystems, schema) {
"You are processing Worldshaper editor requests.",
"Split a submission into one or more atomic requests.",
"Ground your decisions in the provided KB systems only.",
"Use only the standardized tags listed in the provided tag catalog.",
"Do not expose or simulate hidden chain-of-thought. Provide short structured review rationale instead.",
"Return only valid JSON.",
"Do not wrap the JSON in markdown fences.",
"If you are unsure, lower confidence and use statusRecommendation = \"needs_review\".",
@ -434,6 +493,9 @@ function buildPrompt(request, relevantSystems, schema) {
"Return JSON matching this schema:",
schemaSummary,
"",
"Standardized tags you may use:",
tagCatalogSummary,
"",
"Relevant KB systems:",
systemDocs,
].join("\n"),
@ -599,9 +661,9 @@ async function analyzeRequest(config, kb, request) {
if (!config.dryRun) {
await markRequestProcessing(config, request);
}
const prompt = buildPrompt(request, relevantSystems, kb.requestSchema);
const prompt = buildPrompt(request, relevantSystems, kb.requestSchema, kb);
const modelResult = await callModelApi(config, prompt);
const normalizedResult = normalizeAnalysisResult(modelResult, request, relevantSystems.map((entry) => entry.system));
const normalizedResult = normalizeAnalysisResult(modelResult, request, relevantSystems.map((entry) => entry.system), kb);
const action = shouldPromoteAnalysis(normalizedResult, config) ? "promote" : "review";
console.log(` Result: ${normalizedResult.items.length} item(s), action=${action}, confidence=${normalizedResult.confidence ?? "n/a"}`);
if (config.dryRun) {

View file

@ -11,6 +11,33 @@ const app = express();
const port = Number(process.env.PORT) || 5180;
const host = process.env.HOST || "0.0.0.0";
const launcherAdminPassword = String(process.env.LAUNCHER_ADMIN_PASSWORD || "").trim();
const DEFAULT_REQUEST_TAG_DEFINITIONS = [
{ id: "launcher", label: "Launcher", aliases: ["launcher home", "home page", "landing page", "main page"] },
{ id: "request-board", label: "Request Board", aliases: ["requests", "request queue", "moderation", "request intake"] },
{ id: "chunks", label: "Chunks", aliases: ["chunk", "chunk loading", "chunk storage", "chunk overview"] },
{ id: "layers", label: "Layers", aliases: ["layer", "height layers", "z layers"] },
{ id: "tiling", label: "Tiling", aliases: ["tile", "tile editing", "tile painting", "brush"] },
{ id: "graphics-painter", label: "Graphics Painter", aliases: ["graphic painter", "tile art", "sprite painter", "art editor"] },
{ id: "rendering", label: "Rendering", aliases: ["renderer", "viewport", "pixi", "draw"] },
{ id: "animation", label: "Animation", aliases: ["animated", "timeline", "frame playback"] },
{ id: "content", label: "Content", aliases: ["assets", "catalog", "tiles", "sprites", "images"] },
{ id: "world-overview", label: "World Overview", aliases: ["overview", "world map", "bookmarks", "poi"] },
{ id: "windows", label: "Windows", aliases: ["windowing", "popout", "tool windows", "floating window"] },
{ id: "persistence", label: "Persistence", aliases: ["saving", "save pipeline", "history", "undo", "redo"] },
{ id: "performance", label: "Performance", aliases: ["optimization", "slow", "lag", "streaming"] },
{ id: "ui-workflow", label: "UI / Workflow", aliases: ["workflow", "ux", "ui", "quality of life", "qol", "tools", "tooling"] },
{ id: "worlds", label: "Worlds", aliases: ["world", "world bootstrap", "startup", "world systems"] },
{ id: "website", label: "Website", aliases: ["site", "web", "homepage"] },
{ id: "repo", label: "Repo", aliases: ["repository", "forgejo", "git"] },
{ id: "wiki", label: "Wiki", aliases: ["kb", "knowledge base", "docs", "documentation"] },
{ id: "polish", label: "Polish", aliases: ["cleanup", "fit and finish", "presentation"] },
{ id: "general", label: "General", aliases: ["broad", "misc", "cross-system"] },
{ id: "other", label: "Other", aliases: ["unknown", "unclear", "unsorted"] },
];
function normalizeRequestTagLookupValue(value) {
return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
}
function isLauncherAdminProtectionEnabled() {
return Boolean(launcherAdminPassword);
@ -73,6 +100,7 @@ const dialogueNodeMetaPath = path.join(dataRoot, "dialogue_node_meta.json");
const editorSettingsPath = path.join(dataRoot, "editor_settings.json");
const launcherRequestsPath = path.join(dataRoot, "launcher_requests.json");
const requestAnalysisWorkerScriptPath = path.join(__dirname, "scripts", "request-analysis-worker.mjs");
const requestTagCatalogPath = path.join(__dirname, "docs", "kb", "tags.json");
const requestAnalysisRunState = {
child: null,
restartTimer: null,
@ -81,6 +109,17 @@ const imagesCatalogPath = path.join(contentRoot, "images.json");
const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json");
const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json");
const recentSaveEvents = [];
const REQUEST_TAG_DEFINITIONS = loadRequestTagCatalog();
const REQUEST_TAG_LABELS = REQUEST_TAG_DEFINITIONS.map((entry) => entry.label);
const REQUEST_TAG_LOOKUP = new Map(
REQUEST_TAG_DEFINITIONS.flatMap((entry) => {
const normalizedLabel = normalizeRequestTagLookupValue(entry.label);
return [
[normalizedLabel, entry.label],
...entry.aliases.map((alias) => [normalizeRequestTagLookupValue(alias), entry.label]),
];
}),
);
const DEFAULT_WORLDSHAPER_THEME_PRESET = "azure";
const WORLDSHAPER_THEME_PRESET_IDS = new Set(["azure", "verdant", "ember", "amethyst"]);
const LEGACY_LAUNCHER_REQUEST_MIGRATIONS = {
@ -417,8 +456,8 @@ function normalizeLauncherRequestStatus(value) {
function normalizeLauncherRequestTags(value) {
return normalizeUniqueStringList(value, {
normalizeValue: (entry) => String(entry || "").replace(/\s+/g, " ").trim(),
dedupeKey: (entry) => String(entry || "").replace(/\s+/g, " ").trim().toLowerCase(),
normalizeValue: (entry) => normalizeRequestTag(entry),
dedupeKey: (entry) => normalizeRequestTag(entry).toLowerCase(),
});
}
@ -445,6 +484,14 @@ function normalizeLauncherRequestAnalysisStringList(value) {
});
}
function normalizeLauncherRequestAnalysisTags(value, fallback = []) {
const normalized = normalizeLauncherRequestTags(Array.isArray(value) ? value : []);
if (normalized.length > 0) {
return normalized;
}
return normalizeLauncherRequestTags(Array.isArray(fallback) ? fallback : []);
}
function normalizeLauncherRequestAnalysisItem(item, index = 0) {
const source = item && typeof item === "object" && !Array.isArray(item)
? item
@ -455,9 +502,11 @@ function normalizeLauncherRequestAnalysisItem(item, index = 0) {
const fallbackTitle = `Analyzed request ${index + 1}`;
const title = String(source.title || "").trim() || fallbackTitle;
const primaryCategory = String(source.primaryCategory || source.category || "").trim() || "Unsorted";
const tags = normalizeLauncherRequestAnalysisStringList(source.tags);
const tags = normalizeLauncherRequestAnalysisTags(source.tags, [primaryCategory]);
const parsedInterpretation = String(source.parsedInterpretation || source.summary || "").trim();
const implementationApproach = String(source.implementationApproach || source.implementationNotes || "").trim();
const reviewRationale = String(source.reviewRationale || source.reviewReason || "").trim();
const reviewOptions = normalizeLauncherRequestAnalysisStringList(source.reviewOptions);
const statusRecommendationRaw = String(source.statusRecommendation || source.status || "").trim().toLowerCase();
const statusRecommendation = statusRecommendationRaw === "active"
|| statusRecommendationRaw === "duplicate"
@ -480,7 +529,7 @@ function normalizeLauncherRequestAnalysisItem(item, index = 0) {
return {
title,
primaryCategory,
tags,
tags: tags.length > 0 ? tags : ["General"],
statusRecommendation,
parsedInterpretation,
implementationApproach,
@ -489,6 +538,8 @@ function normalizeLauncherRequestAnalysisItem(item, index = 0) {
problemType,
rawExcerpt: String(source.rawExcerpt || "").trim(),
confidence: normalizeLauncherRequestAnalysisConfidence(source.confidence),
reviewRationale,
reviewOptions,
notes: String(source.notes || "").trim(),
};
}
@ -2166,6 +2217,37 @@ function normalizeUniqueStringList(value, options = {}) {
return normalized;
}
function loadRequestTagCatalog() {
try {
const payload = JSON.parse(fs.readFileSync(requestTagCatalogPath, "utf8"));
const tags = Array.isArray(payload?.tags)
? payload.tags
.map((entry) => ({
id: String(entry?.id || "").trim(),
label: String(entry?.label || "").trim(),
aliases: Array.isArray(entry?.aliases)
? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean)
: [],
}))
.filter((entry) => entry.id && entry.label)
: [];
if (tags.length > 0) {
return tags;
}
} catch {
// Fall back to built-in definitions when the KB file is unavailable.
}
return DEFAULT_REQUEST_TAG_DEFINITIONS;
}
function normalizeRequestTag(value) {
const normalizedValue = normalizeRequestTagLookupValue(value);
if (!normalizedValue) {
return "";
}
return REQUEST_TAG_LOOKUP.get(normalizedValue) || "";
}
function normalizeTagList(value) {
if (!Array.isArray(value)) {
return [];
@ -2710,6 +2792,13 @@ app.get("/api/debug/recent-saves", (req, res) => {
});
});
app.get("/api/launcher-request-meta", (_req, res) => {
res.json({
ok: true,
allowedTags: REQUEST_TAG_LABELS,
});
});
app.get("/api/launcher-requests", (_req, res) => {
try {
res.json(readLauncherRequestsPayload());

View file

@ -53,6 +53,8 @@ type LauncherRequest = {
problemType?: string;
rawExcerpt?: string;
confidence?: number | null;
reviewRationale?: string;
reviewOptions?: string[];
notes?: string;
}>;
};
@ -60,6 +62,8 @@ type LauncherRequest = {
updatedAt: string;
};
type LauncherRequestAnalysisItem = NonNullable<NonNullable<LauncherRequest["analysis"]>["items"]>[number];
type LauncherRequestsPayload = {
requests?: LauncherRequest[];
};
@ -95,6 +99,10 @@ type ProcessPendingPayload = {
pid?: number;
};
type LauncherRequestMetaPayload = {
allowedTags?: string[];
};
type AdminAuthPayload = {
ok?: boolean;
accessGranted?: boolean;
@ -148,6 +156,108 @@ function isAdminAccessError(error: unknown): boolean {
|| text.includes("admin access is not configured");
}
function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest {
return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest;
}
function getPrimaryAnalysisItem(requestEntry: LauncherRequest): LauncherRequestAnalysisItem | null {
const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis?.items : [];
return items.length > 0 ? items[0] : null;
}
function formatConfidence(value: number | null | undefined): string {
if (!Number.isFinite(Number(value))) {
return "Unscored";
}
return `${Math.round(Number(value) * 100)}%`;
}
function escapePopupHtml(value: string): string {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function openReviewDetailsPopup(requestEntry: LauncherRequest): void {
const popup = window.open("", `worldshaper-review-${requestEntry.id}`, "popup=yes,width=840,height=760,resizable=yes,scrollbars=yes");
if (!popup) {
return;
}
const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items : [];
const renderedItems = items.map((item, index) => {
const reviewOptions = Array.isArray(item?.reviewOptions) ? item.reviewOptions : [];
return `
<section class="review-card">
<div class="review-kicker">Review Item ${index + 1}</div>
<h2>${escapePopupHtml(String(item?.title || `Request ${index + 1}`))}</h2>
<div class="review-meta">
<span>${escapePopupHtml(String(item?.primaryCategory || "Unsorted"))}</span>
<span>${escapePopupHtml(String(item?.statusRecommendation || "needs_review"))}</span>
<span>${escapePopupHtml(formatConfidence(item?.confidence))}</span>
</div>
<div class="review-block">
<h3>Review Rationale</h3>
<p>${escapePopupHtml(String(item?.reviewRationale || "No structured review rationale was returned."))}</p>
</div>
<div class="review-block">
<h3>Parsed Interpretation</h3>
<p>${escapePopupHtml(String(item?.parsedInterpretation || ""))}</p>
</div>
<div class="review-block">
<h3>Implementation Approach</h3>
<p>${escapePopupHtml(String(item?.implementationApproach || ""))}</p>
</div>
<div class="review-block">
<h3>Possible Options</h3>
${reviewOptions.length > 0
? `<ul>${reviewOptions.map((option) => `<li>${escapePopupHtml(String(option || ""))}</li>`).join("")}</ul>`
: "<p>No structured options were returned.</p>"}
</div>
<div class="review-block">
<h3>Notes</h3>
<p>${escapePopupHtml(String(item?.notes || "No extra notes."))}</p>
</div>
</section>
`;
}).join("");
popup.document.open();
popup.document.write(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Worldshaper Review Details</title>
<style>
body { margin: 0; font-family: Georgia, "Segoe UI", sans-serif; background: #08111f; color: #e8f2ff; }
main { max-width: 920px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; }
.hero { padding: 18px 20px; border: 1px solid #365782; border-radius: 14px; background: linear-gradient(180deg, rgba(17,32,63,.96), rgba(10,19,38,.98)); }
.hero h1 { margin: 0 0 8px; font-size: 28px; }
.hero p { margin: 0; color: #b8cfee; line-height: 1.6; white-space: pre-wrap; }
.review-card { padding: 18px 20px; border: 1px solid #365782; border-radius: 14px; background: rgba(17,32,63,.88); display: grid; gap: 12px; }
.review-kicker { color: #ffd166; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.review-card h2, .review-block h3 { margin: 0; }
.review-meta { display: flex; flex-wrap: wrap; gap: 8px; }
.review-meta span { padding: 4px 8px; border: 1px solid #365782; border-radius: 999px; background: rgba(8,16,31,.75); font-size: 12px; }
.review-block { display: grid; gap: 6px; }
.review-block p, .review-block ul { margin: 0; color: #d7e7ff; line-height: 1.6; white-space: pre-wrap; }
.review-block ul { padding-left: 18px; }
</style>
</head>
<body>
<main>
<section class="hero">
<h1>${escapePopupHtml(requestEntry.title)}</h1>
<p>${escapePopupHtml(requestEntry.sourceText)}</p>
</section>
${renderedItems || '<section class="review-card"><h2>No Review Items</h2><p>This request does not have structured review details yet.</p></section>'}
</main>
</body>
</html>`);
popup.document.close();
}
function formatRequestTimestamp(value: string): string {
const parsed = Date.parse(String(value || ""));
if (!Number.isFinite(parsed)) {
@ -305,6 +415,7 @@ function WorldshaperLauncher() {
const [requestSubmitting, setRequestSubmitting] = useState(false);
const [requestMutatingId, setRequestMutatingId] = useState("");
const [requestFilter, setRequestFilter] = useState("all");
const [allowedRequestTags, setAllowedRequestTags] = useState<string[]>([]);
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
const [adminPanelOpen, setAdminPanelOpen] = useState(false);
const [adminAccessGranted, setAdminAccessGranted] = useState(false);
@ -312,6 +423,9 @@ function WorldshaperLauncher() {
const [adminPasswordDraft, setAdminPasswordDraft] = useState("");
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
const [adminPasswordError, setAdminPasswordError] = useState("");
const [selectedAdminRequestId, setSelectedAdminRequestId] = useState("");
const [adminEditorDraft, setAdminEditorDraft] = useState<LauncherRequest | null>(null);
const [adminSaving, setAdminSaving] = useState(false);
const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [logsError, setLogsError] = useState("");
@ -357,6 +471,15 @@ function WorldshaperLauncher() {
}
}
async function loadRequestMeta(): Promise<void> {
try {
const payload = await fetchJsonOrThrow<LauncherRequestMetaPayload>("/api/launcher-request-meta");
setAllowedRequestTags(Array.isArray(payload.allowedTags) ? payload.allowedTags : []);
} catch {
setAllowedRequestTags([]);
}
}
async function loadRecentSaveEvents(): Promise<void> {
setLogsLoading(true);
try {
@ -397,6 +520,7 @@ function WorldshaperLauncher() {
useEffect(() => {
void loadRequests();
void loadRequestMeta();
}, []);
useEffect(() => {
@ -412,13 +536,15 @@ function WorldshaperLauncher() {
}
let cancelled = false;
const refreshBoard = async (): Promise<void> => {
try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
if (!cancelled) {
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
if (!adminPanelOpen) {
try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
if (!cancelled) {
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
}
} catch {
// Keep the current list visible during background refresh failures.
}
} catch {
// Keep the current list visible during background refresh failures.
}
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
return;
@ -443,6 +569,26 @@ function WorldshaperLauncher() {
};
}, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]);
useEffect(() => {
if (!adminPanelOpen || !adminAccessGranted) {
return;
}
if (requests.length === 0) {
setSelectedAdminRequestId("");
setAdminEditorDraft(null);
return;
}
const selectedRequest = requests.find((entry) => entry.id === selectedAdminRequestId);
if (selectedRequest) {
if (!adminEditorDraft || adminEditorDraft.id !== selectedRequest.id) {
setAdminEditorDraft(cloneLauncherRequest(selectedRequest));
}
return;
}
setSelectedAdminRequestId(requests[0].id);
setAdminEditorDraft(cloneLauncherRequest(requests[0]));
}, [adminPanelOpen, adminAccessGranted, requests, selectedAdminRequestId, adminEditorDraft]);
async function handleLaunch(): Promise<void> {
setError("");
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
@ -539,6 +685,152 @@ function WorldshaperLauncher() {
}
}
function handleSelectAdminRequest(requestId: string): void {
const nextRequest = requests.find((entry) => entry.id === requestId);
setSelectedAdminRequestId(requestId);
setAdminEditorDraft(nextRequest ? cloneLauncherRequest(nextRequest) : null);
setAdminNotice("");
setAdminPasswordError("");
}
function updateAdminDraft(updater: (current: LauncherRequest) => LauncherRequest): void {
setAdminEditorDraft((current) => (current ? updater(current) : current));
}
function updateAdminDraftItem(
itemIndex: number,
updater: (item: LauncherRequestAnalysisItem) => LauncherRequestAnalysisItem,
): void {
updateAdminDraft((current) => {
const next = cloneLauncherRequest(current);
if (!next.analysis) {
next.analysis = {
state: "needs_review",
items: [],
};
}
const items = Array.isArray(next.analysis.items) ? [...next.analysis.items] : [];
const existingItem = items[itemIndex] || {};
items[itemIndex] = updater({
...existingItem,
tags: Array.isArray(existingItem.tags) ? [...existingItem.tags] : [],
affectedSystems: Array.isArray(existingItem.affectedSystems) ? [...existingItem.affectedSystems] : [],
affectedFiles: Array.isArray(existingItem.affectedFiles) ? [...existingItem.affectedFiles] : [],
reviewOptions: Array.isArray(existingItem.reviewOptions) ? [...existingItem.reviewOptions] : [],
});
next.analysis.items = items;
next.analysis.itemCount = items.length;
next.analysis.updatedAt = new Date().toISOString();
return next;
});
}
function buildAdminSavePayload(requestEntry: LauncherRequest): RequestInit {
return {
method: "PATCH",
headers: buildAdminHeaders(adminPassword, {
"Content-Type": "application/json",
}),
body: JSON.stringify({
title: requestEntry.title,
status: requestEntry.status,
category: requestEntry.category,
tags: requestEntry.tags,
sourceText: requestEntry.sourceText,
summary: requestEntry.summary,
implementationNotes: requestEntry.implementationNotes,
analysis: requestEntry.analysis,
}),
};
}
async function handleSaveAdminRequest(): Promise<void> {
if (!adminEditorDraft) {
return;
}
setAdminSaving(true);
try {
const payload = await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>(
`/api/launcher-requests/${encodeURIComponent(adminEditorDraft.id)}`,
buildAdminSavePayload(adminEditorDraft),
);
const nextRequests = Array.isArray(payload.requests) ? payload.requests : requests;
setRequests(nextRequests);
const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || payload.request || adminEditorDraft;
setAdminEditorDraft(cloneLauncherRequest(refreshed));
setAdminNotice(`Saved admin changes for "${adminEditorDraft.title}".`);
if (adminPanelOpen) {
void loadRecentSaveEvents();
}
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setLogsError(String(nextError || "Failed to save admin changes."));
} finally {
setAdminSaving(false);
}
}
async function handleApproveAdminRequest(): Promise<void> {
if (!adminEditorDraft) {
return;
}
const nextDraft = cloneLauncherRequest(adminEditorDraft);
if (!nextDraft.analysis) {
setLogsError("This request has no analysis to approve yet.");
return;
}
const items = Array.isArray(nextDraft.analysis.items) ? nextDraft.analysis.items : [];
nextDraft.analysis.items = items.map((item) => ({
...item,
statusRecommendation: "active",
}));
nextDraft.analysis.state = "processed";
nextDraft.analysis.updatedAt = new Date().toISOString();
setAdminEditorDraft(nextDraft);
setAdminSaving(true);
try {
await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>(
`/api/launcher-requests/${encodeURIComponent(nextDraft.id)}`,
buildAdminSavePayload(nextDraft),
);
const promotePayload = await fetchJsonOrThrow<LauncherRequestsPayload>(
`/api/launcher-requests/${encodeURIComponent(nextDraft.id)}/process-analysis`,
{
method: "POST",
headers: buildAdminHeaders(adminPassword, {
"Content-Type": "application/json",
}),
body: JSON.stringify({
action: "promote",
analysis: nextDraft.analysis,
}),
},
);
const nextRequests = Array.isArray(promotePayload.requests) ? promotePayload.requests : [];
setRequests(nextRequests);
const fallbackSelection = nextRequests[0] || null;
setSelectedAdminRequestId(fallbackSelection?.id || "");
setAdminEditorDraft(fallbackSelection ? cloneLauncherRequest(fallbackSelection) : null);
setAdminNotice(`Approved "${nextDraft.title}" and promoted its active request item${(nextDraft.analysis.items?.length || 0) === 1 ? "" : "s"}.`);
if (adminPanelOpen) {
void loadRecentSaveEvents();
}
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setLogsError(String(nextError || "Failed to approve this request."));
} finally {
setAdminSaving(false);
}
}
function handleToggleExpandedRequest(requestId: string): void {
setExpandedRequestIds((current) => (
current.includes(requestId)
@ -622,12 +914,14 @@ function WorldshaperLauncher() {
const activeRequestCount = requests.filter((entry) => entry.status === "active").length;
const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length;
const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length;
const requestTags = Array.from(new Set(
requests
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
.map((entry) => String(entry || "").trim())
.filter(Boolean),
)).sort((a, b) => a.localeCompare(b));
const requestTags = (allowedRequestTags.length > 0
? allowedRequestTags
: Array.from(new Set(
requests
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
.map((entry) => String(entry || "").trim())
.filter(Boolean),
))).sort((a, b) => a.localeCompare(b));
const filteredRequests = requests.filter((entry) => {
if (requestFilter === "status:pending") {
return entry.status === "pending";
@ -881,27 +1175,40 @@ function WorldshaperLauncher() {
<section className="launcher-request-admin-card">
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Request Management</h4>
<div className="launcher-request-admin-card-hint">Delete actions live here now.</div>
<div className="launcher-request-admin-card-hint">Select a request to review or edit it.</div>
</div>
<div className="launcher-request-admin-request-list">
{requests.map((requestEntry) => {
const isMutating = requestMutatingId === requestEntry.id;
const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state);
const reviewItem = getPrimaryAnalysisItem(requestEntry);
const isSelected = requestEntry.id === selectedAdminRequestId;
return (
<article key={`admin-${requestEntry.id}`} className="launcher-request-admin-request-row">
<article
key={`admin-${requestEntry.id}`}
className={`launcher-request-admin-request-row ${isSelected ? "is-selected" : ""}`}
onClick={() => handleSelectAdminRequest(requestEntry.id)}
>
<div className="launcher-request-admin-request-copy">
<div className="launcher-request-admin-request-title">{requestEntry.title}</div>
<div className="launcher-request-admin-request-meta">
<span>{formatRequestStatusLabel(requestEntry.status)}</span>
<span>{requestEntry.category}</span>
<span>{analysisState}</span>
<span>{formatConfidence(reviewItem?.confidence ?? requestEntry.analysis?.confidence)}</span>
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span>
</div>
{reviewItem?.reviewRationale ? (
<div className="launcher-request-admin-request-rationale">{reviewItem.reviewRationale}</div>
) : null}
</div>
<button
type="button"
className="launcher-request-delete-btn"
onClick={() => void handleDeleteRequest(requestEntry)}
onClick={(event) => {
event.stopPropagation();
void handleDeleteRequest(requestEntry);
}}
disabled={isMutating}
aria-label={`Delete ${requestEntry.title}`}
>
@ -912,6 +1219,253 @@ function WorldshaperLauncher() {
})}
</div>
</section>
<section className="launcher-request-admin-card launcher-request-admin-editor-card">
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Review Editor</h4>
<div className="launcher-request-admin-card-hint">Edit request fields, review details, and approval state.</div>
</div>
{!adminEditorDraft ? (
<div className="launcher-request-empty">Select a request from the list to review it.</div>
) : (
<div className="launcher-request-admin-editor">
<div className="launcher-request-admin-editor-grid">
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Title</span>
<input
type="text"
className="launcher-request-filter-select"
value={adminEditorDraft.title}
onChange={(event) => updateAdminDraft((current) => ({ ...current, title: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Category</span>
<input
type="text"
className="launcher-request-filter-select"
value={adminEditorDraft.category}
onChange={(event) => updateAdminDraft((current) => ({ ...current, category: event.target.value }))}
/>
</label>
</div>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Original Submission</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea"
value={adminEditorDraft.sourceText}
onChange={(event) => updateAdminDraft((current) => ({ ...current, sourceText: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Request Summary</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-sm"
value={adminEditorDraft.summary}
onChange={(event) => updateAdminDraft((current) => ({ ...current, summary: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Request Implementation Notes</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-sm"
value={adminEditorDraft.implementationNotes}
onChange={(event) => updateAdminDraft((current) => ({ ...current, implementationNotes: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Request Tags</span>
<div className="launcher-request-admin-tag-grid">
{requestTags.map((tag) => {
const isActive = adminEditorDraft.tags.includes(tag);
return (
<button
key={`draft-tag-${tag}`}
type="button"
className={`launcher-request-tag launcher-request-admin-tag-toggle ${isActive ? "is-active" : ""}`}
onClick={() => updateAdminDraft((current) => ({
...current,
tags: isActive
? current.tags.filter((entry) => entry !== tag)
: [...current.tags, tag].sort((left, right) => left.localeCompare(right)),
}))}
>
{tag}
</button>
);
})}
</div>
</label>
{(adminEditorDraft.analysis?.items || []).map((item, itemIndex) => (
<section key={`analysis-item-${itemIndex}`} className="launcher-request-admin-analysis-item">
<div className="launcher-request-admin-analysis-head">
<div>
<div className="launcher-request-admin-kicker">Review Item {itemIndex + 1}</div>
<div className="launcher-request-admin-request-title">{item.title || `Request ${itemIndex + 1}`}</div>
</div>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => openReviewDetailsPopup(adminEditorDraft)}
>
Open Review Popup
</button>
</div>
<div className="launcher-request-admin-editor-grid">
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Item Title</span>
<input
type="text"
className="launcher-request-filter-select"
value={String(item.title || "")}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, title: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Primary Category</span>
<input
type="text"
className="launcher-request-filter-select"
value={String(item.primaryCategory || "")}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, primaryCategory: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Recommendation</span>
<select
className="launcher-request-filter-select"
value={String(item.statusRecommendation || "needs_review")}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, statusRecommendation: event.target.value }))}
>
<option value="needs_review">Needs Review</option>
<option value="active">Active</option>
<option value="blocked">Blocked</option>
<option value="duplicate">Duplicate</option>
</select>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Problem Type</span>
<select
className="launcher-request-filter-select"
value={String(item.problemType || "unknown")}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, problemType: event.target.value }))}
>
<option value="feature">Feature</option>
<option value="bug">Bug</option>
<option value="workflow">Workflow</option>
<option value="performance">Performance</option>
<option value="ux">UX</option>
<option value="content">Content</option>
<option value="unknown">Unknown</option>
</select>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Confidence</span>
<input
type="number"
min="0"
max="1"
step="0.01"
className="launcher-request-filter-select"
value={Number.isFinite(Number(item.confidence)) ? String(item.confidence) : ""}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({
...current,
confidence: event.target.value === "" ? null : Number(event.target.value),
}))}
/>
</label>
</div>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Standardized Tags</span>
<div className="launcher-request-admin-tag-grid">
{requestTags.map((tag) => {
const isActive = Array.isArray(item.tags) && item.tags.includes(tag);
return (
<button
key={`item-${itemIndex}-tag-${tag}`}
type="button"
className={`launcher-request-tag launcher-request-admin-tag-toggle ${isActive ? "is-active" : ""}`}
onClick={() => updateAdminDraftItem(itemIndex, (current) => {
const currentTags = Array.isArray(current.tags) ? current.tags : [];
return {
...current,
tags: isActive
? currentTags.filter((entry) => entry !== tag)
: [...currentTags, tag].sort((left, right) => left.localeCompare(right)),
};
})}
>
{tag}
</button>
);
})}
</div>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Review Rationale</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea"
value={String(item.reviewRationale || "")}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, reviewRationale: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Possible Options</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
value={Array.isArray(item.reviewOptions) ? item.reviewOptions.join("\n") : ""}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({
...current,
reviewOptions: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
}))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Parsed Interpretation</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea"
value={String(item.parsedInterpretation || "")}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, parsedInterpretation: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Implementation Approach</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea"
value={String(item.implementationApproach || "")}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, implementationApproach: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Notes</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
value={String(item.notes || "")}
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, notes: event.target.value }))}
/>
</label>
</section>
))}
<div className="launcher-request-admin-actions">
<button
type="button"
className="launcher-primary-btn"
onClick={() => void handleSaveAdminRequest()}
disabled={adminSaving}
>
{adminSaving ? "Saving..." : "Save Review Changes"}
</button>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => void handleApproveAdminRequest()}
disabled={adminSaving}
>
Approve Request
</button>
</div>
</div>
)}
</section>
<section className="launcher-request-admin-card">
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Recent Logs</h4>
@ -997,6 +1551,7 @@ function WorldshaperLauncher() {
const isActiveRequest = requestEntry.status === "active";
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
const reviewItem = getPrimaryAnalysisItem(requestEntry);
const analysisStateLabel = requestEntry.status === "pending"
? formatAnalysisStateLabel(requestEntry.analysis?.state)
: "";
@ -1027,6 +1582,11 @@ function WorldshaperLauncher() {
<div className="launcher-request-entry-text">
{requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText}
</div>
{requestEntry.status === "pending" && reviewItem?.reviewRationale ? (
<div className="launcher-request-entry-review-note">
Review reason: {reviewItem.reviewRationale}
</div>
) : null}
<div className="launcher-request-entry-meta">
{requestEntry.status === "pending" ? `${analysisStateLabel} | ` : ""}
{formatRequestTimestamp(requestEntry.updatedAt || requestEntry.createdAt)}

View file

@ -448,6 +448,12 @@ body {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 10px;
cursor: pointer;
}
.launcher-request-admin-request-row.is-selected {
border-color: #63a6f2;
box-shadow: 0 0 0 1px rgba(99, 166, 242, 0.45);
}
.launcher-request-admin-request-copy {
@ -456,6 +462,12 @@ body {
gap: 5px;
}
.launcher-request-admin-request-rationale {
color: #d7e7ff;
font-size: 12px;
line-height: 1.45;
}
.launcher-request-admin-request-title,
.launcher-request-admin-log-title {
color: #eef6ff;
@ -500,6 +512,65 @@ body {
white-space: pre-wrap;
}
.launcher-request-admin-editor-card {
grid-column: 1 / -1;
}
.launcher-request-admin-editor {
display: grid;
gap: 12px;
min-height: 0;
overflow: auto;
max-height: 58dvh;
padding-right: 4px;
}
.launcher-request-admin-editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.launcher-request-admin-tag-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.launcher-request-admin-tag-toggle {
cursor: pointer;
}
.launcher-request-admin-tag-toggle.is-active {
border-color: #4fa87e;
background: rgba(19, 73, 50, 0.88);
color: #b7f0d5;
}
.launcher-request-admin-analysis-item {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #365782;
border-radius: 12px;
background: rgba(8, 16, 31, 0.72);
}
.launcher-request-admin-analysis-head {
display: flex;
justify-content: space-between;
align-items: start;
gap: 10px;
}
.launcher-request-admin-textarea {
min-height: 96px;
}
.launcher-request-admin-textarea-sm {
min-height: 74px;
}
.launcher-request-composer-label {
color: #d7e7ff;
font-size: 12px;
@ -680,6 +751,12 @@ body {
white-space: pre-wrap;
}
.launcher-request-entry-review-note {
color: #ffd5b0;
font-size: 12px;
line-height: 1.45;
}
.launcher-request-entry-meta,
.launcher-request-empty {
color: #9fb8e5;
@ -1934,6 +2011,15 @@ button.danger:not(:disabled):hover {
grid-template-columns: 1fr;
}
.launcher-request-admin-editor-grid {
grid-template-columns: 1fr;
}
.launcher-request-admin-analysis-head {
grid-template-columns: 1fr;
display: grid;
}
.launcher-request-filter {
min-width: 0;
}