release: OpenMAIC 1.0.0 — the agent workbench (#1228)

* feat(storage): add an agent-session store with PG backend and layered contracts (#1163)

* feat(storage): add agent-session store with PG backend and layered contracts

* test(storage): avoid BigInt literals for pre-ES2020 root typecheck

* fix(storage): close agent-session store review findings

* docs(storage): align hook ordering and contention-probe claims with the code

* ci: run on the agent-workbench integration branch

* chore(storage): bump to 0.5.0 for the agent-session store

* fix(storage): carry replay compaction across page boundaries

* feat(agent): add the driver model contract and stage route dialect (#1165)

* feat(agent): add the driver model contract and stage route dialect

* fix(agent): validate route context windows and clarify dialect precedence

* feat(agent): adapt the agent-session store and runtime foundations (#1167)

* feat(agent): adapt the agent-session store and runtime foundations

* feat(agent): resolve request owner identity via an anonymous cookie

* docs(agent): document the opt-in compaction default and harden edge cases

* feat(agent): add the background session runner (#1169)

* feat(agent): add the background session runner

* feat(agent): wire the runner into startup behind feature flags

* fix(agent): stop clean interruptions from consuming the attempt budget

* fix(storage): charge the attempt budget for abandoned leases but not clean parks

* docs(storage): document the attempt-charging contract and decouple its tests

* feat(agent): add agent session and owner event streams (#1170)

* feat(agent): add agent session and owner event streams

* fix(agent): close the session-existence oracle and document the owner seam

* feat(agent): add agent session lifecycle routes (#1171)

* feat(agent): add agent session lifecycle routes

* fix(agent): validate session-create input and preserve the owner cookie on errors

* refactor(storage): drop the unused active-stage API from the agent-session contract (#1174)

* refactor(storage): drop the unused active-stage API from the agent-session contract

Tools address stages explicitly on every call, so the store keeps no
mutable session-level stage pointer. Removes resolveActiveStage and
setActiveStage from the store interface, their PG implementations, the
active_stage_changed lifecycle event, the session_active_stage owner
event variant, and the contract tests pinning them. The active_stage_id
column and the DDL check constraint stay untouched for schema
compatibility.

* chore(storage): bump @openmaic/storage to 0.7.0 for the contract removal

* docs: document the agent runtime configuration surface (#1176)

* fix(agent): repair orphaned and late tool results across interruption boundaries (#1180)

* fix(agent): repair orphaned and late tool results across interruption boundaries

A crash, shutdown, or provider failure can leave the durable transcript with
tool calls that have no result, or with results ordered illegally for the
provider. Three failure modes were fixed:

- Orphaned tool calls: a run that died between an assistant tool-call frame
  and its result left a dangling call in the entry tree. Resume no longer
  synthesizes and persists receipts for it: interrupted results are a
  read-time provider view owned by a shared read-boundary repair, which
  returns the original array for a healthy transcript and never mutates the
  tree.

- Late parallel results: a parallel tool can finish while pi unwinds an
  aborted assistant frame, leaving result(A), assistant(aborted), result(B)
  in durable order. Strict providers reject non-contiguous results, so the
  read-boundary repair moves existing results next to their owning assistant
  frame (in call order), omits incomplete unwind frames, and synthesizes
  receipts only for genuinely missing calls.

- Interrupted calls at the write boundary: a call still in flight when the
  run winds down (shutdown, lease loss, cancellation, provider failure) had
  no receipt at all. The runner now tracks in-flight calls from their
  assistant frames and, before the terminal flush, appends an
  interrupted-result receipt for each still-orphaned call through the same
  attempt-fenced write chain, so a lease-stealing zombie never writes and
  the next claim sees a provider-safe transcript.

* test(agent): pin the runner wiring for interruption-boundary tool repair

* feat(agent): add neutral tool foundation libraries (#1184)

* feat(agent): register a web_search tool on the session runner (#1185)

* feat(storage): add a per-session URL trust gate (#1186)

* feat(agent): add the skills system (#1189)

* feat(agent): add the skills system (builtin directories and durable user skills)

* fix(storage): serialize the user-skill quota check-and-insert per owner

Two concurrent creates at the 50-skill boundary both counted 49 rows and
both inserted (READ COMMITTED, no lock), overshooting the quota contract.
The create transaction now takes a per-owner pg_advisory_xact_lock first,
and the same-name idempotency check runs before the count check so an
at-least-once retry of the create that committed as the owner's 50th row
still returns its durable receipt instead of a quota error. The 23505
backstop is retained for writes that do not take the lock.

* fix(agent): share unstorable-character validation and align skill lookup

* feat(agent): add session materials and a fetch_url tool behind the URL trust gate (#1190)

* feat(agent): add session materials and a fetch_url tool behind the URL trust gate

* fix(agent): harden session material fetching

* feat(storage): add an ownership scope to stage documents (#1191)

* feat(agent): add material read and search tools (#1192)

* feat(agent): add stage read and patch tools (#1194)

* feat(agent): add page generation and deck editing tools (#1198)

* test(storage): keep the PG contract suite order-independent (#1200)

* fix(agent): revoke deleted-session URL authority and reject private ISATAP endpoints (#1199)

* fix(storage): revoke deleted session URL authority

* fix(ssrf): reject private ISATAP endpoints in strict fetches

* chore(storage): bump to 0.11.1 for the session-URL authority fix

* feat(agent): add roster and voice registration tools (#1201)

* feat(agent): add folder organisation tools (#1202)

* feat(api): add stage and material HTTP routes (#1203)

* feat(workbench): add the client data layer (#1204)

* feat(workbench): add the client data layer

* docs(workbench): write the ported comments in English

* chore(edit): remove the in-editor agent panel (#1210)

* chore(edit): remove the in-editor agent panel

* style: apply prettier formatting

* fix(agent): report the runtime as unusable without a database (#1207)

* fix(agent): report the runtime as unusable without a database

* style: apply prettier formatting

* feat(agent): add image, video and pptx import tools (#1211)

* feat(workbench): add the agent chat surface (#1205)

* feat(workbench): add the agent chat surface

* docs(workbench): write the ported comments in English

* fix(workbench): label the folder and rename tools on the timeline

* fix(workbench): label the roster and voice tools on the timeline

The reconciliation test iterates every tool the runner registers and
requires a display label of its own. The roster and voice-clone tools
(list_voices, set_roster, clip_audio, register_voice) reached the
integration base with the roster/voice-registration tools but never
gained presentation rows, so they fell through to the default branch
and rendered their wire names. Port their rows from the reference
implementation (labels and i18n keys verbatim) and extend the
reconciliation allowlist with ROSTER_TOOL_NAMES and
VOICE_CLONE_TOOL_NAMES, so a future tool cannot enter the product
without a label.

* feat(agent): add the material extraction lifecycle (#1212)

* feat(storage): add material extraction lifecycle

* feat(agent): execute queued material extraction

* style: apply prettier formatting

* style: satisfy prefer-const in the extraction runner

* test: give material fixtures the extraction lifecycle fields

The media-tools slice and the extraction lifecycle slice were each green
in isolation but never compiled together: the lifecycle made derivedFrom
and extraction required on AgentSessionMaterial while the media-tool
fixtures predate them.

* chore: remove stray task notes

* fix(workbench): label the extraction lifecycle tools on the timeline

* feat(workbench): add the workspace shell (#1206)

* feat(workbench): add the workspace shell

* docs(workbench): write the ported comments in English

* i18n(workbench): align workspace keys across locales

* fix(workbench): adopt the landed data layer and label the extraction tools

- replace the sibling-slice seam stubs with the real data-layer modules
- drop ambient declarations now shadowed by landed files
- port timeline labels for the extraction lifecycle tools from the reference
- align the new i18n keys across all locales

* ci: retrigger

* feat(api): folder routes, stage-meta viewer surfaces, and the material upload contract (#1215)

* fix(storage): restore capability-based stage access

* fix(api): bind document access to request owner

* fix(agent): restore three-state stage access on the tool layer

Port probeStageAccess and the three-state StageAccess (owned / foreign /
missing / tombstoned) and gate every stageId-bearing stage tool on an owned
probe, mirroring the reference per tool:

- move_to_folder, rename_stage, read_stage_outline refuse a non-owned stage
  with the single not-yours message before touching the store.
- The course/DSL toolset and the roster toolset are wrapped by
  withOwnerStageAuthorization: read_stage, patch_stage, grep_stage and every
  writer refuse a foreign stage with the same message and refusal shape.
- Scene preview keeps its own probe and its own refusal text, and is
  registered beside the course toolset (never double-gated).
- The runner injects one probe factory at the three call sites.

Tests: the dsl cross-owner test premise (a foreign stage is readable by id)
encoded an invented capability-read policy that the reference does not have
at the tool layer; it now asserts foreign read/patch/grep are all refused
while the owner still reads. Curriculum cross-owner assertions were already
the reference's and now pass with the probes in place.

* docs: correct per-file test counts in the fidelity report

* test: fix type errors in stage-access fidelity test

* test: adapt media-tool and gate suites to the owner-scoped store seam

* feat(api): add owner-scoped course-folder HTTP routes

Port the reference implementation's /api/folders family (list, create,
rename, delete with ungroup/remove modes, and folder membership) onto the
owner-bound document store, replacing its provider-based auth with the
existing withRequestOwnerId / owner-scoped store seams.

The storage package's folder store grows the pieces the routes need:
DocumentFolder.order (schema column + max+1 assignment + ordering),
renameFolder, deleteFolder(mode) with captured member ids, and
setStageFolder(stageId, folderId | null) with idempotent un-filing.
FolderNameError moves into folder-name-validation.ts (stage-storage re-exports
it, keeping import sites intact).

Every route gates on the configured agent runtime (plain 404 when off or
unconfigured), keeps the reference's machine codes and envelopes, and is
covered by gate tests plus a behavior suite.

* feat(api): add stage-meta viewer surfaces for the classroom

Port the reference implementation's viewer-facing stage state — can-edit /
collected / published / generation-complete — on top of the stage-access
base (stage_meta + tombstones). stage_meta gains published_at and
generation_complete columns plus a stage_bookmarks table; the reference's
deployment-specific origin/claimed_at columns are stripped.

New gated routes: GET /api/stage-meta/[stageId] (per-viewer facts, 404 for
absent/tombstoned, never returns the owner id), GET /api/stages/[id]/status,
POST generation-complete / publish / unpublish (owner-only), POST
/api/bookmarks. The resolver lives in lib/server/stage-access.ts.

Wiring: a fetchStageMeta client with the reference's three-outcome contract,
stage-store isOwner/isBookmarked/readOnly fields (upstream single-user
defaults, no-op until the sidecar answers) plus setViewerAccess, the
classroom apply path computing readOnly = !(isOwner || isBookmarked), the
Stage editability gate, and a sidecar probe after each classroom load. A
sidecar 'absent' answer keeps the editable default here because the
classroom also serves local-only courses; server writes stay owner-enforced.

* feat(api): port the reference material upload contract

Rewrite POST /api/materials to the reference implementation's upload shape
so the workbench uploader (uploadWorkbenchMaterial, which posts no session
id and expects a flat 201 view) works unchanged: owner-scoped upload with
mime normalization/validation (415), per-class size caps checked on the
declared content-length and the streamed body (413), empty body (400),
quota (429), sha256 reserve->store->finalize lifecycle with abandon on
failure, flat { materialId, originalName, bytes, mime, extraction } 201,
and an x-request-id echo.

Adds the owner-scoped material library (owner_material table + quota +
24h lazy sweep, bytes in the host's asset registry as the neutral
replacement for the reference's object-storage byte path) and the material
cap configuration. The session-scoped GET list is left as-is; the
reference's owner-material extraction worker is not ported (the branch's
session-material extraction lifecycle already covers extraction).

Gate tests now cover all 23 persistence routes across the three runtime
env states; the materials behavior suite pins the new contract.

* feat(media): add an optional local ffmpeg media extractor (#1213)

Adds a local ffmpeg/ffprobe pipeline as a second media extraction
provider behind the extractor registry, ported faithfully from the
reference implementation: duration probing, keyframe-safe chunking,
per-chunk ASR with timeout and deadline budgets, and timestamped
transcript assembly.

- Availability probing feeds the registry's candidate selection: the
  provider simply is not a candidate when ffmpeg/ffprobe are absent.
- With neither ffmpeg nor a cloud provider configured, extraction fails
  with an actionable message naming both enablement paths.
- Media materials route through the same extraction lifecycle and lease
  fence as documents; no parallel queue.
- Tests inject the executable resolver so the missing-ffmpeg path is the
  default-tested one; the real pipeline test is skip-if-unavailable.
- @openmaic/storage 0.13.0 -> 0.14.0 (media routing in the material
  lifecycle surface).

* feat(storage): per-scene monotonic revisions via database triggers (#1214)

* feat(storage): per-scene monotonic revisions via database triggers

Restore the reference implementation's freshness granularity: a
per-scene monotonic revision maintained by database triggers, so every
writer (HTTP routes, agent tools, jobs, manual SQL) bumps it without
application cooperation.

- Companion revision tables + trigger functions in the storage package's
  idempotent schema bootstrap, with the lock-order invariant, pg_notify
  wakeup and the suppression switch for batch writers.
- ensureDocumentSchema gained a dollar-quote-aware statement splitter.
- The freshness and manifest routes serve per-scene revisions.
- Mutation-verified: dropping the triggers turns the revision tests red.
- @openmaic/storage 0.13.0 -> 0.14.0.

* fix: forward the freshness manifest through the owner-bound store

* feat(workbench): add the Pro entry points and preserve the mode-transition semantics (#1208)

* feat(workbench): add the Pro entry points

* feat(workbench): preserve Pro mode transition semantics

* fix(workbench): drop ambient declarations shadowed by landed slices

* fix(workbench): drop ambient declarations shadowed by the landed shell

* feat: port workspace shell sibling modules

Port the 16 leaf modules the Pro workspace shell imports but that were only
ambient-declared, replacing the compile-time bridge with real implementations
adapted from the sibling-slice reference: pure workbench helpers (session
title, rail tab, course-chat bootstrap, created-course tabs, course-tabs
memory, workspace navigation, pane navigation, pro-edit sizing,
existing-course minting, first-message session), the neutral brand context and
course-rename server API, the server-action session delete, the home
discovery hook, the classroom pane host with its load-policy leaf, the theme
toggle and floating-layer owner, plus the floating-layer-owner wiring the
dialog/dropdown/tooltip portals stamp.

Also add the workbench-shell locale copy for all 12 locales, port the
reference tests for the ported modules, and drop
types/workbench-sibling-slices.d.ts now that every declaration has a real
implementation.

* docs: keep ported comments in English and deployment-neutral

* docs: announce 1.0.0 and refresh the feature overview (#1216)

* docs: announce 1.0.0 and refresh the feature overview

* docs: finalize 1.0.0 README after feature merge

* fix(agent): control-plane routes answer 404, not 500, without a database

The agent control-plane routes gated only on the runtime flag, so an
enabled-but-unconfigured deployment (flag on, DATABASE_URL empty)
answered 500 from a store that cannot connect. Gate them on the
configured check instead, matching the stage/material routes: the
whole surface is cleanly absent until both the flag and the database
are present. The status probe keeps reporting both bits.

* test: mock both runtime gate exports in the control-plane route suites

* fix(agent): abort in-flight TTS on cancel and bound each provider request with a timeout (#1217)

The generate_tts / scene-tts path checked the runner's AbortSignal between
actions but never created the provider HTTP requests with it, so a session
cancel left a hung synthesis fetch in flight until a restart repaired the
tool result. Thread the signal end-to-end: TTSModelConfig carries an
optional signal, generateTTS combines it with a per-request timeout
(TTS_REQUEST_TIMEOUT_MS, default 30s, ported from the reference runtime's
TTS bounds) via AbortSignal.any, and every provider fetch (openai, azure,
glm, qwen incl. voice-clone + audio download, voxcpm, minimax, doubao,
elevenlabs, lemonade) is created with that signal.

A timeout now fails the tool call with TTSRequestTimeoutError (a clear
retryable error) instead of wedging the session; a caller cancel propagates
as the interruption so the runner settles the session as cancelled without
a restart.

Tests: hung-provider simulation rejects at the timeout with the retryable
error; abort mid-flight aborts the captured request signal and surfaces the
interrupted shape; removing the signal wiring makes the abort tests fail
(red), restoring them turns green.

* fix(workbench): PG-mode home listing via owner stages; keep the interrupted terminal course card (#1218)

Finding 1: with server persistence on, listStages resolved to the generic
GET /api/persistence/documents listing, which the capability model
deliberately answers 403 FORBIDDEN_DOCUMENTS for (reads by id, listings
owner-only). The home/workspace library now lists through the owner-scoped
GET /api/stages surface (same anonymous-owner cookie the workbench uses)
when server persistence is enabled; the server-side 403 is untouched.

Finding 2: a run interrupted (session_interrupted) and repaired
(session_resumed) that ends cancelled before agent_end stranded its pending
classroom sightings, so the timeline's terminal card lost the course the
answer produced. session_end (cancelled) now flushes the pending sightings
into the same course card set agent_end paints, before the stopped caption.

* chore(workbench): remove the bookmark concept and the saved-courses drawer (#1219)

* chore(classroom): remove the bookmark ('collected') concept entirely

The stage-meta viewer port introduced a bookmark surface (stage_bookmarks
table, POST /api/bookmarks, the isBookmarked sidecar field, and a
readOnly rule that let a saved course stay editable). The product has no
such concept, so remove it as a closure:

- delete the /api/bookmarks route and the stage_bookmarks table plus its
  query helpers from the persistence bootstrap
- drop isBookmarked from GET /api/stage-meta/[stageId]
- simplify the classroom read-only rule to readOnly = !isOwner across the
  sidecar client, ownership signal, classroom load, stage store and the
  classroom page
- keep publish/unpublish, generation-complete, isOwner and isPublic
  exactly as they were
- update the gate and stage-meta route suites and the README mentions

The workspace rail's Bookmark glyphs and comments describe the upstream
saved-courses (favorites) section, which is driven by isOwner and renders
no collect affordance; they are kept as unrelated homonyms.

* chore(workbench): remove the saved-courses drawer UI

The first pass removed the bookmark data model but kept the rail's
"Saved courses" drawer, judging it a separate surface driven by
`isOwner === false`. The home/workspace listing is owner-scoped, so that
flag can never occur: `allSaved` is permanently empty and the drawer
(plus the collapsed-rail Bookmark mini-button) is a dead affordance.
Remove it: the SavedDrawer component and its mount, the savedOpen /
savedSection state, the allSaved / matchedSaved derivations, the 'saved'
variant of the course-list renderers, the mini Bookmark glyph, the
drawer-only CSS, and the drawer's i18n keys from all 12 locales. The
courses tab is now exactly one folders tree.

The authored/favorites split in workspace-tree.ts goes with it; the tree
module no longer reads `isOwner`. The discovery course type keeps the
field — the shell still reads it for read-only gating.

Upstream has no collect concept; the drawer could only ever render empty
here. The reference implementation HAS this drawer (its favorites come
from its account system), so this removal is a deliberate upstream
product decision, not a fidelity bug.

* fix(workbench): restore the attach entry, add the rail settings entry, pin all three entry points (#1221)

* fix(workbench): restore the composer attach entry by gating it on the live runtime

The AttachButton's rollout probe read a `materialsEnabled` field that this
branch's /api/agent/runtime never answers (the materials routes gate on the
runtime itself, like the stages), so the gate could never pass and the attach
button never rendered — the Pro launch and chat composers showed only the
@-mention and enhance glyphs.

Substitute the field with the runtime's `enabled` value, which IS the upload
action's precondition: POST /api/materials answers 404 whenever it is false,
so the render condition now equals the action precondition (no dead button).

The button's label (`proMode.attach`) is a user-visible string that becomes
visible again; port the reference implementation's own translations verbatim
into the 11 locales that still carried the Chinese copy.

* feat(workbench): add the settings entry to the rail's bottom-left cluster

The reference's rail foot carries a cluster of utilities (its saved-courses
drawer, the language switcher, the display toggle). This branch removed the
drawer — it could only ever render empty here — and the product decision is
to fill that freed spot with the settings entry.

Add a settings trigger to the foot cluster (expanded rail, beside the
language and display toggles, and on the collapsed strip) and mount the
model/provider SettingsDialog in the rail, wired to the trigger. It is the
same dialog the classic home opens from its header pill; the workspace had no
settings entry of its own, so nothing is duplicated within a surface.

* test(workbench): pin the restored upload, attach, and settings entry points

Covers the three restored entry points:

- the courses-tab upload control: rendered beside the course name filter,
  wired to the discovery hook's ZIP import trigger, disabled while an import
  runs, and gated by the same condition as its action (the courses tab);
- the composer attach control: an actual render of AttachButton under both
  probe answers (visible when the runtime says the upload path is live,
  hidden otherwise), its mounts in the launch and chat composers, the
  branch's runtime-field substitution in the probe, and the reference's own
  `proMode.attach` copy in all 12 locales;
- the settings entry: the trigger in the rail's foot cluster (expanded and
  collapsed), beside the language and display toggles, opening the
  SettingsDialog the rail mounts.

* chore(config): the Pro workbench flag implies the MAIC Editor gate (#1223)

A workbench build without the editor toggle has no way to edit a course:
enabling NEXT_PUBLIC_PRO_WORKBENCH_ENABLED while forgetting
NEXT_PUBLIC_MAIC_EDITOR_ENABLED produced exactly that split-brain bundle.
The workbench IS Pro mode, so its flag now implies the editor gate; the
standalone flag remains for deployments that want the classroom editor
without the workbench. Documents both flags in .env.example.

* fix(agent): wake SSE tails and the runner on durable deltas (streaming fidelity) (#1222)

The Pro workbench chat did not stream: the session/owner SSE routes polled
the durable event log on a 5s/30s clock with no wakeup, so message_update
deltas (written at 150ms cadence) reached the browser in poll-sized blocks
and the thinking strip only mounted after the whole reasoning text had
accumulated.

Port the reference's LISTEN/NOTIFY delta path:

- storage: add in-transaction wake hooks (onSessionEventAppended,
  onOwnerEventAppended, onCancelRequested) so a host queues pg_notify in
  the same transaction as the durable append; align readEventsAfterForReplay
  to rank the bounded page so the first delta after the cursor is always
  kept (the live tail can never starve). Bump @openmaic/storage to 0.18.0.
- app: port the process-wide event-notify bus (dedicated LISTEN client,
  self-check probe, reconnect backoff; notify through the storage
  transaction surface), wire the store hooks, subscribe both SSE routes
  before the initial read with the reference's initializing gate, and give
  the runner one {kind:'session'} subscription whose wake runs the cancel
  check and the message drain. Polls stay as the lossy-NOTIFY backstop.
- lifecycle: start/stop the bus from instrumentation.

Tests: storage hook + compaction contract; route wakeup latency; runner
wakeup wiring with a fake agent; bus unit tests; PG contracts proving a
real append wakes the routes and a live SSE route forwards a message_update
on the wakeup, and that a rolled-back append never wakes. Also fix the
pre-existing park-attempt-budget PG test TRUNCATE (missing CASCADE against
newer FK tables).

* fix(storage): asset writes self-deadlocked against pooled PostgreSQL (#1225)

* fix(storage): refuse the non-transactional byte-write deadlock configuration

A byte store whose plain write() runs on its own pooled connection cannot be
invoked from inside a registry write transaction: after the transaction has
claimed the blob-row lock, that write blocks on the lock the transaction just
took while the transaction waits on the write - a self-deadlock PostgreSQL
cannot detect (one side is idle in transaction).

There is no lock-safe ordering for such a writer: bytes must be written after
the row claim (writing before it lets the collector delete the bytes while the
upsert waits), and any second-connection write after the claim is the
deadlock. The configuration is therefore detected and refused:

- AssetByteStore gains writesOutsideRegistryDatabase?: true, declaring that the
  layer's plain byte operations cannot contend for the registry's row locks.
- PgAssetStore refuses put()/replace() up front (and defends coordinatedWrite)
  when the byte store has no writeWith and does not declare the flag, throwing
  a clear configuration error before any row is claimed.
- The collector mirrors the guard on its delete path (deleteWith or a declared
  out-of-registry layer, else a configuration error).
- The object store declares the flag (its out-of-transaction write remains
  legitimate); the in-registry PostgreSQL byte column provides writeWith /
  deleteWith instead.
- Write transactions (put/replace/remove) set SET LOCAL lock_timeout = 30s so
  any future lock-contention variant fails loudly instead of hanging.

Bumps @openmaic/storage to 0.18.0.

* fix(persistence): forward the transactional byte methods through the lazy asset byte-store wrapper

The no-bucket case of lazyAssetByteStore returned a bare { write, read,
delete } and dropped writeWith/readWith even though the underlying
PgAssetByteStore has them. The registry's hasTransactionalWriter duck check
then failed and put() fell back to the byte store's own pooled connection,
which blocks forever on the blob-row lock the registry transaction just took
when the bytes live in the same PostgreSQL - the production self-deadlock.

The no-bucket layer is statically PgAssetByteStore, so its transaction-pinned
methods are forwarded eagerly (typed against the real signatures via
PgForwardedByteStore). The bucket case keeps its lazy-probing semantics: no
transactional writer exists there, the signed-URL method stays absent or lazy
exactly as documented, and the wrapper now declares writesOutsideRegistryDatabase
so the registry may run the plain write inside its transaction.

New tests pin the wrapper's transactional capability red-to-green and assert
put()/resolve() route byte traffic through the transaction-pinned queryable.

* fix(home): cap the generate-prep ingest drain at 3s so Generate never waits the full server budget

The classic home flow's Generate click drained in-flight ingests for the full
15s server budget. Cap the wait at GENERATE_DRAIN_CAP_MS (3000ms, documented as
a UX bound) and reuse the existing timeout fallback: sources that miss the cap
proceed on the legacy byte path and each late-resolving id is released.

* chore(storage): bump to 0.19.0 over the concurrently landed 0.18.0

* fix(agent): bound every tool call with a timeout; never resurrect a cancelled session (#1226)

* fix(agent): bound every tool call with a global timeout and settle it on cancel

A tool await that neither resolves nor rejects wedges the session forever:
the lease keeps heartbeating and the driver never reaches its next cancel
checkpoint. Race every tool execution (in buildAgent) against a hard budget
(OPENMAIC_AGENT_TOOL_TIMEOUT_MS, default 10 min, per-tool overrides for
known long runners) and against the caller's AbortSignal, so even a
signal-ignoring await cannot keep a cancelled session running.

On timeout the call rejects with AgentToolTimeoutError; the agent loop turns
the rejection into a structured error tool-result the agent can retry or
proceed from, and the abort signal is delivered to the tool's in-flight work
through a derived controller. Zombie-tool updates after settlement are
dropped.

* fix(storage): never re-lease a cancel-requested session; settle it as cancelled on claim

The claim scan treated a session with cancel_requested_at set as a normal
claim candidate: after a restart it re-leased the same session for attempt
N+1 and resumed generating despite the pending cancel. claimNextSession now
settles such candidates as cancelled under the claim lock (status cancelled,
attempt reset, lease and cancel request cleared, terminal session_end event
and owner projection) instead of leasing them, then keeps scanning.

Bump @openmaic/storage to 0.18.0.

* docs: takeaway-style 1.0.0 announcement with bilingual guide links

The 1.0.0 head is now a short takeaway block — badge links to the
official user guides (English and Chinese), five one-line highlights,
and pointers into Features and the workbench setup section — instead of
six dense paragraphs. The detailed provider-neutrality and freshness
notes move into the Features workbench section, phrased database-
neutrally (the announcement no longer names a specific database).
Release date corrected to August 27.

* fix(workbench): restore editor chrome, mode transition, streaming, materials, mentions, folders (#1229)

* fix(workbench): wire workspace folder routes

* fix(editor): restore reference workbench chrome

* fix(workbench): persist composer materials and course refs

* fix(workbench): preserve live reasoning frames

* fix(persistence): back off failed streaming saves

* chore(workbench): retire stale slice seams

* test(editor): cover element pin layer

* chore(storage): bump to 0.21.0 for the user-message ref/material fields

* chore(editor): translate ported code comments to English

* fix(agent): fence durable tool writes and consume cancel requests atomically (#1230)

* fix(agent): enforce provider force-off in agent tools and scrub vendor identity from tool results (#1231)

* fix(materials): serialize per-owner quota reservations and make crashed uploads reclaimable (#1232)

* fix(editor): resolve dock-bar i18n keys, remove dock height drag, wire element referencing (#1233)

* fix(workbench): send the opening session message exactly once with refs intact (#1234)

* feat(editor): port timeline TTS preview single-flight and voice-all state latching (#1235)

* fix(media): restore the reference classic media chain (#1236)

* fix(import): adapt imported PPTX canvas size so decks render without overflow (#1237)

* fix(editor): complete element referencing — renderer DOM contract and GenUI picking aligned with the reference (#1238)

* test(providers): reconcile the provider-config vendor-token debt count after the main merge

The integration line's AK/SK fallback for the managed document provider adds
occurrences that main's allowlist snapshot predates. Same mixed-composition
debt category the group already documents; no new vendor behavior.

* test(providers): reconcile vendor-token debt counts with the integration line

The main-merge brought main's neutrality-guard snapshot next to integration
features it predates (media-extractor fallback chain, local voice-profile
deletion semantics, the enabled-TTS helper). Same debt categories the guard
already documents; counts updated to the guard's own tally and two grouped
entries added. No new vendor behavior.

* fix(agent): carry reasoning through the completions dialect so the thinking strip renders (#1239)

* feat(skills): add Feynman and spiral curriculum methods (#1240)

* feat(agent): port missing reference tools and skills (parity audit) (#1241)

* feat(media): retire asset-registry wiring; media and materials follow the reference byte model (#1242)

* fix(classroom): center adapted canvases in the stage and send back navigation home during generation (#1243)

* feat(settings): skill management with real list, download, delete, and upload (#1244)

* feat(settings): skill management section with real list, detail, and zip download

* feat(skills): owner skill delete and upload across storage, API, and settings

* fixup! feat(settings): skill management section with real list, detail, and zip download

chore: neutralize a reference note in the settings header comment

* fix(media): persist origin-independent classroom-media references from the agent runtime (#1245)

* feat(editor): float the insert toolbar in the outer frame with collapse (#1246)

The insert strip was bounded to the slide card, so it could only ever sit on
top of slide content: the card's overflow clipped it and it could not be parked
in the padding beside the slide. Move it into the studio frame the element
picker's panel already roams (CanvasOverlayPortal + the frame selector), so both
canvas overlays share one bounding container and their handles behave the same.
While picking, the strip rises over the picker and goes inert, which is the
z-order CANVAS_OVERLAY_Z already documents.

Add a fold beside the grip: the chevron collapses the strip to that grip row and
back, with the buttons unmounted rather than hidden. The fold is session-local
state owned by EditShell, next to the drag offset, so a surface swap keeps it;
nothing is persisted. Expanding a strip parked at the bottom edge re-clamps
through the same bounds rule the keyboard move uses.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(workbench): align the chat timeline's left edge with the composer (#1247)

* fix(agent): fence session claims while an ask_user question is outstanding (#1248)

* fix(agent): settle-time rescue tracks real delivery instead of a count offset (#1249)

* fix(persistence): migrate owner_material to oss_key and drop legacy asset_id (#1250)

* docs(readme): surface the 1.0.0 user guide badges at the top (#1253)

* fix(workbench): show newly created folders in the sidebar without reload (#1254)

* docs(readme): add the release version prefix and drop the opt-in framing

* fix(workbench): single-source the chat gutter so timeline and composer share a left edge (#1255)

The transcript and the composer each established their own column: their own
`px-*` gutter and their own `mx-auto w-full max-w-*` centering wrapper. Equal
padding values were never enough, because the two columns are centered inside
different containing blocks — the transcript's is a scroll container, whose
content box is narrower than the composer footer's by the scrollbar's width:

  transcript text left = pad + (pane - 2*pad - scrollbar - measure) / 2
  composer box  left   = pad + (pane - 2*pad             - measure) / 2

The padding cancels out of the difference and what remains is `-scrollbar/2` at
every padding value, so the transcript sat half a scrollbar to the left of the
composer and tuning the two paddings against each other could not move it.

The column is now established once, by the nearest common ancestor of both
(`chatColumn`), and the scroll viewport and the composer footer are siblings
inside it that add no horizontal inset of their own. The cap carries the gutter
on top of the 760px reading measure, so the text column keeps its width. The
handed-over question row drops the padding that indented it past the agent's
prose; framed rows keep their own inner padding, which is what a card's border
sitting on the column edge means.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(workbench): lock pane-embedded classroom to edit mode (#1256)

The workspace right pane painted the full learning chrome — speed control,
play button, learner avatars, mic bar — for a course the agent had just
created, then flipped to edit once the first scene landed.

resolveStageChromeMode treated playback as the DEFAULT branch for a hosted
classroom, so every shortfall fell into it: a course whose tab opens at
stage_link time has no scenes yet, so currentSceneId is null and
isHostedSceneEditable is false. A folded pane parked the playback root
behind the fold and cross-faded it out over the pane on unfold, and a
failed editor chunk dropped into playback permanently.

Lock it at the pane instead of defaulting per entry path:

- WorkbenchPanelProvider — the single element that mounts a classroom into
  the workspace — publishes editPinned (visible && !playback). Every entry
  path passes through it, so none of them decides.
- The hosted resolution can no longer degrade to playback. Start Learning
  (workbenchLearning, new input, split out from pane visibility) is the one
  door; everything else resolves between the neutral loading shell and edit.
- Stage's chrome dispatch is exhaustive on chromeMode, so the playback root
  is no longer the else-branch of a condition about the current scene.

No flicker: chromeMode is resolved during render, and preloadEditor now
answers synchronously (isEditorPreloaded) so a remount with the chunk
already registered paints edit on the first frame. A failed import is no
longer cached forever, so the lock cannot strand the pane.

Standalone classrooms keep their stored mode unchanged.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wyuc
2026-08-27 19:26:25 +08:00
committed by GitHub
co-authored by Claude Fable 5
parent 97ceb11a14
commit 04621578de
744 changed files with 117746 additions and 31976 deletions
+74 -3
View File
@@ -170,6 +170,12 @@ ASR_AZURE_BASE_URL=https://{region}.api.cognitive.microsoft.com
# ASR_OPENAI_ENABLED=false
# ASR_BROWSER_NATIVE_ENABLED=false
# Optional local audio/video material extraction uses the first enabled server
# ASR provider above. It also requires the system `ffmpeg` and `ffprobe`
# executables on PATH; no bundled binary or npm dependency is installed.
# Without both executables, OpenMAIC skips the local extractor and uses a
# configured AliDocMind cloud extractor when available. With neither path
# enabled, media materials fail cleanly with setup guidance.
# --- PDF Processing -----------------------------------------------------------
@@ -295,7 +301,14 @@ WEB_SEARCH_CLAUDE_MODELS=
# Boolean feature flags accept "true" or "1". NEXT_PUBLIC_* values are compiled
# into the browser bundle at build time, so changing them requires a rebuild.
# Master gate for the MAIC Editor Pro-mode entry point.
# Enable the Pro workbench entry (the workbench also requires the agent
# runtime to be configured server-side; see the Agent Runtime section).
# Implies the MAIC Editor gate below — Pro mode always ships with the editor.
# NEXT_PUBLIC_PRO_WORKBENCH_ENABLED=true
# Master gate for the MAIC Editor Pro-mode entry point. Implied by
# NEXT_PUBLIC_PRO_WORKBENCH_ENABLED; set it alone to enable the classroom
# editor on a deployment without the workbench.
# NEXT_PUBLIC_MAIC_EDITOR_ENABLED=true
# Select @openmaic/editor inside Pro mode. This does not enable Pro mode by itself.
@@ -321,6 +334,58 @@ WEB_SEARCH_CLAUDE_MODELS=
# blank defaults to open.maic.chat; set to "off" to omit it.
# NEXT_PUBLIC_VIDEO_EXPORT_CTA_DESTINATION=open.maic.chat
# --- Agent Runtime (experimental) ---------------------------------------------
# Server-only gate for durable background agent sessions: the /api/agent
# session and owner-event control-plane routes plus the in-process session
# runner. Default OFF — while disabled, every /api/agent/sessions* and
# /api/agent/owner-events route answers 404. Truthy values are "true" or "1";
# anything else (including unset) is treated as disabled.
# OPENMAIC_AGENT_RUNTIME_ENABLED=true
# The runtime is server-backed and requires the PostgreSQL connection from the
# "Server-backed Persistence" section below: without a non-empty DATABASE_URL
# the runner never starts and the session store rejects requests, even with the
# flag above enabled.
# DATABASE_URL=postgres://openmaic:password@postgres:5432/openmaic
# REQUIRED while the runtime is enabled: MODEL_ROUTES must explicitly route the
# "maic-agent-driver" stage to a provider-prefixed model id. There is
# intentionally no fallback — without this route every agent session fails at
# run start. The route object must set api (or its alias dialect) to
# "openai-completions" or "openai-responses"; any other value is rejected, and
# a bare model id without a provider prefix is rejected too. Optional fields:
# contextWindow pins the effective context window below the provider catalog
# value (used by compaction thresholds), and thinking must never set effort
# (the tool-using driver cannot combine reasoning_effort with function tools on
# this transport).
# MODEL_ROUTES='{"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'
# Runner tuning. Defaults are shown; only relevant once the runtime is enabled.
# OPENMAIC_AGENT_RUNTIME_SCAN_INTERVAL_MS=1000
# OPENMAIC_AGENT_RUNTIME_HEARTBEAT_MS=2000
# OPENMAIC_AGENT_RUNTIME_LEASE_TTL_MS=10000
# OPENMAIC_AGENT_RUNTIME_MAX_CONCURRENT=2
# OPENMAIC_AGENT_RUNTIME_MAX_ATTEMPTS=5
# Global per-tool-call execution bound for every agent run (ms). A tool call
# that neither resolves nor rejects within the budget is aborted and settles as
# an error tool-result the agent can retry or proceed from; the session does
# not die. Default 600000 (10 minutes). Tools with known longer budgets (media
# synthesis, material extraction) carry their own explicit bounds in code.
# OPENMAIC_AGENT_TOOL_TIMEOUT_MS=600000
# Conversation compaction is reserved and OFF by default. The reusable
# compaction runtime is not implemented yet — it lands in a later slice of
# work — and until then the runner runs without context transformation, so
# these knobs are inert placeholders.
# OPENMAIC_AGENT_COMPACTION_ENABLED=true
# OPENMAIC_AGENT_COMPACTION_RESERVE_TOKENS=0
# OPENMAIC_AGENT_COMPACTION_KEEP_RECENT_TOKENS=0
# Session prompts and follow-up messages are capped server-side at a fixed
# 100,000 characters; this limit is a constant and is not configurable.
# --- Proxy (optional) --------------------------------------------------------
# HTTP_PROXY=
@@ -353,13 +418,19 @@ DEFAULT_MODEL=
# but are deprecated — write provider:model). Warnings only: the server starts
# regardless, so a bad value is caught here instead of at request time.
# Routable stages: scene-outlines-stream, scene-content, scene-actions,
# agent-profiles, quiz-grade, pbl-chat, chat-adapter, generate-classroom,
# web-search-query-rewrite.
# agent-profiles, quiz-grade, pbl-chat, pbl-v2-runtime, chat-adapter,
# generate-classroom, web-search-query-rewrite, maic-agent,
# maic-agent-driver.
# scene-content can also be routed per scene type with composite keys:
# scene-content:slide, scene-content:quiz, scene-content:interactive,
# scene-content:pbl. A type falls back to the base scene-content route when it
# has no key of its own (so scene-content:<type> > scene-content > x-model >
# DEFAULT_MODEL).
# pbl-v2-runtime follows the same composite fallback pattern with
# pbl-v2-runtime:instructor, pbl-v2-runtime:open-task, pbl-v2-runtime:evaluate
# and pbl-v2-runtime:simulator, falling back to the base pbl-v2-runtime route.
# maic-agent-driver is REQUIRED when the agent runtime is enabled; see the
# "Agent runtime (experimental)" section for its api/dialect constraints.
# A route value can be a model string, OR an object {"model","thinking"} where
# `thinking` is the full ThinkingConfig: mode (default|disabled|enabled|auto),
# effort (none|minimal|low|medium|high|xhigh|max), level (minimal|low|medium|
+2 -1
View File
@@ -6,7 +6,7 @@ on:
# only ever proves one part merged into the branch, so without a push
# trigger the accumulated state of the branch that eventually reaches main
# is never built on its own.
branches: [main, integration/kv-asset-server-backend]
branches: [main, integration/kv-asset-server-backend, integration/agent-workbench]
pull_request:
branches:
[
@@ -15,6 +15,7 @@ on:
feat/maic-editor-v1,
runtime-server-backend,
integration/kv-asset-server-backend,
integration/agent-workbench,
]
concurrency:
+6
View File
@@ -10,6 +10,12 @@
一键生成沉浸式多智能体互动课堂。
</p>
<p align="center">
<a href="https://lcn6dqn3m0yr.feishu.cn/wiki/CkQSwHFdzibQFvkGzwPcmUOfnXg"><img src="https://img.shields.io/badge/%F0%9F%93%99%20%E4%BD%93%E9%AA%8C%E6%8C%87%E5%8D%97-v1.0.0%20%C2%B7%20%E4%B8%AD%E6%96%87-FF6B35?style=for-the-badge" alt="v1.0.0 体验指南(中文)"/></a>
&nbsp;&nbsp;
<a href="https://my.feishu.cn/wiki/UIfKw9Knti0LcKkTxDNcqlUrnzh"><img src="https://img.shields.io/badge/%F0%9F%93%98%20User%20Guide-v1.0.0%20%C2%B7%20English-4F8EF7?style=for-the-badge" alt="v1.0.0 User Guide (English)"/></a>
</p>
<p align="center">
<a href="https://jcst.ict.ac.cn/en/article/doi/10.1007/s11390-025-6000-0"><img src="https://img.shields.io/badge/Paper-JCST'26-blue?style=flat-square" alt="Paper"/></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green.svg?style=flat-square" alt="License: MIT"/></a>
+135 -4
View File
@@ -10,6 +10,12 @@
Get an immersive, multi-agent learning experience in just one click
</p>
<p align="center">
<a href="https://my.feishu.cn/wiki/UIfKw9Knti0LcKkTxDNcqlUrnzh"><img src="https://img.shields.io/badge/%F0%9F%93%98%20User%20Guide-v1.0.0%20%C2%B7%20English-4F8EF7?style=for-the-badge" alt="v1.0.0 User Guide (English)"/></a>
&nbsp;&nbsp;
<a href="https://lcn6dqn3m0yr.feishu.cn/wiki/CkQSwHFdzibQFvkGzwPcmUOfnXg"><img src="https://img.shields.io/badge/%F0%9F%93%99%20%E4%BD%93%E9%AA%8C%E6%8C%87%E5%8D%97-v1.0.0%20%C2%B7%20%E4%B8%AD%E6%96%87-FF6B35?style=for-the-badge" alt="v1.0.0 体验指南(中文)"/></a>
</p>
<p align="center">
<a href="https://jcst.ict.ac.cn/en/article/doi/10.1007/s11390-025-6000-0"><img src="https://img.shields.io/badge/Paper-JCST'26-blue?style=flat-square" alt="Paper"/></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green.svg?style=flat-square" alt="License: MIT"/></a>
@@ -21,7 +27,7 @@
<br/>
<a href="https://discord.gg/p8Pf2r3SaG"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"/></a>
&nbsp;
<a href="community/feishu.md"><img src="https://img.shields.io/badge/Feishu-飞书交流群-00D6B9?style=for-the-badge&logo=bytedance&logoColor=white" alt="Feishu"/></a>
<a href="community/feishu.md"><img src="https://img.shields.io/badge/Feishu-Community-00D6B9?style=for-the-badge&logo=bytedance&logoColor=white" alt="Feishu Community"/></a>
<br/>
<img src="https://img.shields.io/badge/Next.js-16-black?style=flat-square&logo=next.js" alt="Next.js"/>
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React"/>
@@ -31,14 +37,27 @@
</p>
<p align="center">
<a href="./README.md">English</a> | <a href="./README-zh.md">简体中文</a>
<a href="./README.md">English</a> | <a href="./README-zh.md">Simplified Chinese</a>
<br/>
<a href="https://open.maic.chat/">Live Demo</a> · <a href="#-quick-start">Quick Start</a> · <a href="#lemonade-local-ai">Lemonade</a> · <a href="#funasr-local-asr">FunASR</a> · <a href="#-features">Features</a> · <a href="#-use-cases">Use Cases</a> · <a href="#-openclaw-integration">OpenClaw</a>
</p>
## 🎉 OpenMAIC v1.0.0 — Build courses with an agent
**One prompt in, a whole course out — and now you can steer.** Released August 27, 2026, OpenMAIC v1.0.0 adds a **Pro workbench** alongside the classic one-click generator: chat with an agent that plans your curriculum, builds and revises every page, and works straight from your materials.
- 🤖 **Agent workbench** — a chat-first workspace that plans, builds, and revises whole courses
- 💾 **Durable sessions** — server-backed runs survive restarts; cancel, resume, and steer anytime
- 📎 **Session materials** — upload documents, audio, and video, or pull from web search; the agent builds from them
- 🧰 **Course tools + 20 built-in skills** — slides, quizzes, interactives, PBL, images, video, voices, `.pptx` import
- 🔌 **Neutral by design** — bring your own models, media, search providers, and storage backend
Take the full tour in [Features](#-features), then set it up with [Agent workbench and runtime](#optional-agent-workbench-and-runtime).
## 🗞️ News
- **2026-08-27** — **OpenMAIC v1.0.0:** an agent workbench, durable course-building sessions, reusable skills, session materials, provider-neutral server capabilities, and a pluggable persistence stack.
- **2026-08-14** — [v0.3.2 released!](https://github.com/THU-MAIC/OpenMAIC/releases/tag/v0.3.2) Video export hardening (deterministic Quiz/PBL covers, fidelity polish, interactive HTML capture, CPU resource profiles); server-backed persistence completed (full document cutover, one-command Postgres stack, incremental saves) plus the asset registry; the `@openmaic/generation` package; four new locales; Amazon Bedrock, Atlas Cloud, and Claude search providers; FunASR ASR. See [changelog](CHANGELOG.md).
- **2026-07-21** — [v0.3.1 released!](https://github.com/THU-MAIC/OpenMAIC/releases/tag/v0.3.1) One-click MP4 video export; server-backed runtime storage with a Postgres reference server; direct slide manipulation in the editor (drag, resize, rotate, multi-select); smarter "Edit with AI" (validated JSON Patch edits, multi-session history); expanded Document Parsing (multi-format upload, audio/video extraction, AliDocMind, MinerU); new providers (Azure OpenAI, SearXNG, ComfyUI) and the GPT-5.6 model family; action-level playback navigation; SSRF hardening. See [changelog](CHANGELOG.md).
- **2026-06-28** — [v0.3.0 released!](https://github.com/THU-MAIC/OpenMAIC/releases/tag/v0.3.0) Project-Based Learning (PBL) v2 with classroom UI; "Edit with AI" Pro-mode editor agent; the `@openmaic/*` SDK family (DSL/renderer/importer) published to npm; optional per-stage model routing; new models (GLM-5.2, Kimi K2.7 Code, Qwen3.7 Plus/Max); a vocational-learning task engine; Korean (ko-KR) locale; and relicensing from AGPL-3.0 to MIT. See [changelog](CHANGELOG.md).
@@ -187,6 +206,12 @@ ASR_FUNASR_BASE_URL=http://localhost:8000/v1
Use `funasr-server --device cpu --model sensevoice` for a CPU-only setup. See the [FunASR deployment guide](https://github.com/modelscope/FunASR#deploy) for production options.
### Optional: Local Audio and Video Extraction
OpenMAIC can extract timestamped transcripts and prepared video keyframes locally. Install the system `ffmpeg` package so both `ffmpeg` and `ffprobe` are executable on `PATH`, then configure one server ASR provider (for example FunASR, Lemonade, or OpenAI) using the variables above. The application resolves the executables at extraction time; ffmpeg is not an npm dependency and is not required to start or use OpenMAIC.
If the executables are unavailable, the local extractor is skipped. A configured AliDocMind provider remains available as the cloud extraction path. When neither local ffmpeg extraction nor AliDocMind is available, audio/video materials are marked failed with an actionable setup message instead of hanging or completing with an empty transcript.
OpenAI quick example:
```env
@@ -409,6 +434,36 @@ and
Leave `NEXT_PUBLIC_PERSISTENCE` unset to retain the existing browser-only
behavior.
### Optional: Agent workbench and runtime
The Pro workbench is a usable course-building surface entered from the home
page. Its collapsible navigation rail, conversation pane, and tabbed classroom
pane share `/api/agent/*` control-plane routes and an in-process session runner.
It is off by default. Enable its build-time entry point and the server runtime
with the same PostgreSQL connection used by server-backed persistence:
```env
NEXT_PUBLIC_PRO_WORKBENCH_ENABLED=true
OPENMAIC_AGENT_RUNTIME_ENABLED=true
DATABASE_URL=postgres://openmaic:openmaic-dev@postgres:5432/openmaic
MODEL_ROUTES='{"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'
```
While the flag is off, the `/api/agent/sessions*` and `/api/agent/owner-events`
routes answer `404`. Enabling it
without a `DATABASE_URL` never starts the runner and makes the session routes
error, so the runtime is server-backed by design. `MODEL_ROUTES` must explicitly
route `maic-agent-driver` to a provider-prefixed model with an
`openai-completions` or `openai-responses` `api`/`dialect`; there is intentionally
no fallback.
To make the browser use the same server-backed document and runtime stores,
also build with `NEXT_PUBLIC_PERSISTENCE=1` and configure the matching
development tokens described in [Server-backed persistence](#server-backed-persistence-postgresql).
Without these opt-ins, OpenMAIC retains its existing browser-only behavior.
Runner cadence (scan interval, heartbeat, lease TTL, concurrency, attempts) and
the reserved compaction knobs are listed in `.env.example`.
### Optional: MP4 Video Export (Render Service)
The "Export Video" menu builds a self-contained [Hyperframes](https://www.npmjs.com/package/@hyperframes/producer) project entirely in the browser. Turning that into an MP4 needs Chromium + FFmpeg on Node 22, so it runs in an isolated `render-service` container rather than the app.
@@ -463,6 +518,64 @@ TTS_VOXCPM_BASE_URL=http://localhost:8000/v1
## ✨ Features
### Agent Workbench and Pro Mode (v1.0.0)
The workbench adds a conversational course-building agent to OpenMAIC.
Its durable sessions can be resumed after a worker restart, accept follow-up
instructions while running, and stream a replayable event history to the chat
surface.
Open it from the Pro control on the home page. The workspace combines a
transient, collapsible folders/conversations rail with a chat pane and a
classroom pane whose open courses stay in tabs. Workspace controls return to
classic mode, and either entry remains gated by the public workbench flag plus
the configured server runtime.
The agent works through explicit, validated tools rather than editing opaque
blobs:
| Area | Capabilities |
| --- | --- |
| **Plan and organize** | Plan multi-lesson curricula; create courses and folders; rename and move courses |
| **Build and edit** | Read/search the stage DSL; atomically patch one scene; generate, duplicate, insert, delete, and reorder pages; edit narration and deck structure |
| **Use materials** | Upload files; extract documents, audio, and video; search extracted text; fetch trusted web URLs; reuse material media |
| **Create media** | Generate images and videos through configured server providers; generate narration audio |
| **Import and inspect** | Import `.pptx` slides with their layout preserved; render scene previews for visual inspection when available |
| **Configure the classroom** | List available voices, set the agent roster, and clone/register a voice when a pluggable registration adapter is configured |
Twenty built-in skills cover curriculum planning, deep research, interactive,
lecture, workshop, vocational, and other teaching styles, slide/stage craft,
PPTX import, editing, and style reuse. User-authored skills are stored per owner
and can be created, read, and patched through the same runtime.
The server-backed workbench also exposes owner-scoped folder routes and a
per-viewer stage metadata sidecar for ownership, publication, and
generation-complete state. A stage ID acts as the capability for reading a
non-deleted course, but stage mutations remain restricted to its owner. The
material upload contract stores supported source bytes before lease-fenced
document or media extraction records derived text and images; media extraction
can select AliDocMind or the optional local ffmpeg/ffprobe provider.
Under the hood, agent sessions are database-backed with leases, heartbeats,
crash resume, cancellation, and follow-up steering, and database-maintained
revision counters keep per-stage and per-scene freshness monotonic so the
workbench refetches only the scenes that changed. Server routes resolve LLM,
media, ASR/TTS, and search configuration provider-neutrally: credentials never
reach the browser, uniform `<CAP>_<PREFIX>_ENABLED=false` switches can force
off any served capability, startup validation warns about bad model
configuration, and unresolved model routes fail loudly instead of guessing a
vendor.
### Pluggable Storage
OpenMAIC runs without a database by default: course documents, learner runtime
records, device/account KV values, and assets use browser storage. The
`@openmaic/storage` package defines swappable stores for those primitives and
adds PostgreSQL-backed documents, learner runtime, assets, durable agent
sessions, session materials, and user skills. HTTP clients connect the browser
to the embedded persistence endpoint, while the server asset layer can keep
bytes in PostgreSQL or S3.
### Deep Interactive Mode (New!)
**Passive listening? ❌ Hands-on exploration! ✅**
@@ -573,7 +686,10 @@ If you are looking for a version with richer functionality, stronger interactivi
### Lesson Generation
Describe what you want to learn or attach reference materials. OpenMAIC's two-stage pipeline handles the rest:
Describe what you want to learn or attach reference materials. PDF, Word,
PowerPoint, spreadsheet, text, image, audio, and video inputs can enter the
material pipeline; configured extractors turn supported sources into content
for generation. OpenMAIC's classic two-stage pipeline handles the rest:
| Stage | What Happens |
|-------|-------------|
@@ -739,6 +855,8 @@ Optional config in `~/.openclaw/openclaw.json`:
- **Text-to-Speech** — Multiple voice providers with customizable voices
- **Speech Recognition** — Talk to your AI teacher using your microphone
- **Web Search** — Agents search the web for up-to-date information during class
- **Provider controls** — Server-side capability discovery, model resolution, force-off switches, and fail-loud routing keep deployments explicit
- **Course freshness** — Database-triggered per-scene revision counters, freshness events, and targeted scene fetches keep workbench views synchronized
- **i18n** — Interface supports 12 locales across 11 languages: Simplified Chinese, Traditional Chinese, English, Japanese, Korean, Russian, Arabic, Portuguese (Brazil), Spanish (Mexico), French, Vietnamese, and German
- **Dark Mode** — Easy on the eyes for late-night study sessions
@@ -792,7 +910,9 @@ We welcome contributions from the community! Whether it's bug reports, feature i
```
OpenMAIC/
├── app/ # Next.js App Router
│ ├── api/ # Server API routes (~18 endpoints)
│ ├── api/ # Generation, media, persistence, and agent APIs
│ │ ├── agent/ # Durable session, event, material, and skill control plane
│ │ ├── stages/ # Owner-scoped course reads, writes, manifests, and scene fetches
│ │ ├── generate/ # Scene generation pipeline (outlines, content, images, TTS …)
│ │ ├── generate-classroom/ # Async classroom job submission + polling
│ │ ├── chat/ # Multi-agent discussion (SSE streaming)
@@ -812,6 +932,8 @@ OpenMAIC/
│ ├── types/ # Centralized TypeScript type definitions
│ ├── audio/ # TTS & ASR providers
│ ├── media/ # Image & video generation providers
│ ├── persistence/ # Browser/server persistence wiring and PostgreSQL provider
│ ├── server/agent-runtime/ # Durable runner, skills, materials, and course-building tools
│ ├── export/ # PPTX & HTML export
│ ├── hooks/ # React custom hooks (55+)
│ ├── i18n/ # Internationalization (zh-CN, zh-TW, en-US, ja-JP, ko-KR, ru-RU, ar-SA, pt-BR, es-MX, fr-FR, vi-VN, de-DE)
@@ -823,6 +945,7 @@ OpenMAIC/
│ │ └── components/element/ # Element renderers (text, image, shape, table, chart …)
│ ├── scene-renderers/ # Quiz, Interactive, PBL scene renderers
│ ├── generation/ # Lesson generation toolbar & progress
│ ├── workbench/ # Pro workbench conversation and course-reference UI
│ ├── chat/ # Chat area & session management
│ ├── settings/ # Settings panel (providers, TTS, ASR, media …)
│ ├── whiteboard/ # SVG-based whiteboard drawing
@@ -831,6 +954,12 @@ OpenMAIC/
│ └── ... # audio, roundtable, stage, ai-elements
│
├── packages/ # Workspace packages
│ ├── @openmaic/dsl/ # Versioned course/slide data contract and validators
│ ├── @openmaic/renderer/ # React renderer for the slide DSL
│ ├── @openmaic/editor/ # Composable slide editing core and React surface
│ ├── @openmaic/importer/ # PPTX → OpenMAIC slide importer
│ ├── @openmaic/generation/ # Generation contracts, pipeline, and prompt assets
│ ├── @openmaic/storage/ # Browser, HTTP, PostgreSQL, and S3 persistence primitives
│ ├── pptxgenjs/ # Customized PowerPoint generation
│ └── mathml2omml/ # MathML → Office Math conversion
│
@@ -846,6 +975,8 @@ OpenMAIC/
### Key Architecture
- **Generation Pipeline** (`@openmaic/generation`) — Two-stage: outline generation → scene content generation
- **Agent Runtime** (`lib/server/agent-runtime/`) — PostgreSQL-backed sessions with leased execution, resume/steer semantics, skills, materials, and validated course tools
- **Persistence Layer** (`@openmaic/storage`) — Swappable document, runtime, KV, asset, agent-session, material, and user-skill stores
- **Multi-Agent Orchestration** (`lib/orchestration/`) — LangGraph state machine managing agent turns and discussions
- **Playback Engine** (`lib/playback/`) — State machine driving classroom playback and live interaction
- **Action Engine** (`lib/action/`) — Executes 28+ action types (speech, whiteboard draw/text/shape/chart, spotlight, laser …)
-210
View File
@@ -1,210 +0,0 @@
/**
* MAIC Agent — SSE transport endpoint.
*
* Hosts a server-side pi Agent and streams its `AgentEvent`s to the editor
* sidebar as Server-Sent Events. The whole feature is gated behind the master
* editor flag.
*/
import type { NextRequest } from 'next/server';
import type { AgentEvent, AgentMessage } from '@earendil-works/pi-agent-core';
import { isMaicEditorEnabled } from '@/lib/config/feature-flags';
import { resolveModelFromRequest } from '@/lib/server/resolve-model';
import type { LlmStage } from '@/lib/server/model-routes';
import { createCallLlmStreamFn } from '@/lib/agent/runtime/stream-fn';
import { buildAgent, buildSystemPrompt } from '@/lib/agent/runtime/build-agent';
import { buildToolset } from '@/lib/agent/tools/registry';
import { callLLM } from '@/lib/ai/llm';
import { createLogger } from '@/lib/logger';
import type { SceneContext } from '@/lib/agent/tools/regenerate-scene-actions';
const log = createLogger('MAIC Agent');
// A single `regenerate_scene` tool call runs slide content generation *and*
// action generation inside this SSE turn, matching the dedicated scene-content
// route's budget (300s) — not the 60s a plain chat turn needs. Cap to 300 so
// slow models / media-heavy slides aren't terminated mid-stream.
export const maxDuration = 300;
/**
* Scene/stage context map sent by the client.
* Keyed by scene id; the client reads `useStageStore` to build this so the
* server never has to access a (non-existent) server-side scene store.
*/
export type SceneContextMap = Record<string, SceneContext>;
interface AgentEditBody {
message: string;
scene?: { id: string; title: string };
/**
* Prior conversation turns (text only) sent by the client so the agent has
* multi-turn memory — without this each request is stateless and the agent
* cannot recall earlier exchanges.
*/
history?: Array<{ role: 'user' | 'assistant'; text: string }>;
/**
* Trusted scene/stage context for every scene the agent may act on.
* The client includes the active scene (and all sibling scenes) so the
* `regenerate_scene_actions` tool can resolve outline + content without
* relying on model-fabricated arguments.
*/
sceneContextMap?: SceneContextMap;
/**
* Current canvas selection (element ids) for selection-aware `edit_elements`.
* Client-sourced from `useCanvasStore.activeElementIdList`.
*/
selection?: string[];
}
/** Max prior turns carried into context (keeps the prompt bounded). */
const MAX_HISTORY_TURNS = 24;
/** Convert the client's text-only history into pi `AgentMessage`s. */
function toHistoryMessages(history: AgentEditBody['history']): AgentMessage[] {
if (!Array.isArray(history)) return [];
const turns = history
.filter(
(m): m is { role: 'user' | 'assistant'; text: string } =>
!!m &&
(m.role === 'user' || m.role === 'assistant') &&
typeof m.text === 'string' &&
m.text.trim().length > 0,
)
.slice(-MAX_HISTORY_TURNS);
// Don't let the seeded transcript end on a user turn: agent.prompt() appends
// the new user message, and two consecutive user messages degrade on some
// providers. (Trailing user turns are dropped tool-call-only replies, etc.)
while (turns.length > 0 && turns[turns.length - 1].role === 'user') turns.pop();
return turns.map((m) =>
m.role === 'user'
? ({ role: 'user', content: m.text } as AgentMessage)
: ({ role: 'assistant', content: [{ type: 'text', text: m.text }] } as AgentMessage),
);
}
export async function POST(req: NextRequest) {
if (!isMaicEditorEnabled()) {
return new Response('Not found', { status: 404 });
}
const body = (await req.json()) as AgentEditBody & Record<string, unknown>;
const message = (body.message ?? '').toString().trim();
if (!message) {
return new Response('message is required', { status: 400 });
}
// Resolve via the 'maic-agent' stage so operators can route the editor agent
// to a dedicated model via MODEL_ROUTES (per-stage config). When unrouted it
// falls back to the client's active frontend model config (x-model headers +
// thinkingConfig body), then DEFAULT_MODEL — see resolveModel.
const { model, modelInfo, thinkingConfig, modelString } = await resolveModelFromRequest(
req,
body,
'maic-agent',
);
// Per-stage model resolution for the generation tools. Each tool is a
// self-contained black box that names the generation stage it produces (e.g.
// `scene-content:interactive`, `scene-content:slide`, `scene-actions`); we
// resolve that stage's model via MODEL_ROUTES (cached per stage for this turn),
// independent of the `maic-agent` conversation model that drives streamFn below.
// Unrouted stages fall back to the client's active frontend model, so default
// behaviour is unchanged unless an operator routes a stage explicitly.
const stageCache = new Map<LlmStage, Awaited<ReturnType<typeof resolveModelFromRequest>>>();
const aiCall = async (
stage: LlmStage,
system: string,
prompt: string,
signal?: AbortSignal,
): Promise<string> => {
let resolved = stageCache.get(stage);
if (!resolved) {
resolved = await resolveModelFromRequest(req, body, stage);
stageCache.set(stage, resolved);
}
const r = await callLLM(
{
model: resolved.model,
system,
prompt,
maxOutputTokens: resolved.modelInfo?.outputWindow,
// Abort the in-flight generation when the user cancels the turn — pi
// passes each tool an AbortSignal, which the tools thread through here.
abortSignal: signal,
},
'maic-agent-regen',
undefined,
resolved.thinkingConfig,
);
return r.text;
};
const sceneContextMap: SceneContextMap = body.sceneContextMap ?? {};
const selectionIds: readonly string[] = Array.isArray(body.selection)
? body.selection.filter((id): id is string => typeof id === 'string')
: [];
const tools = buildToolset({
aiCall,
getSceneContext: (sceneId) => sceneContextMap[sceneId],
activeSceneId: body.scene?.id,
getSelection: () => selectionIds,
});
const abortController = new AbortController();
const streamFn = createCallLlmStreamFn({
languageModel: model,
maxOutputTokens: modelInfo?.outputWindow,
thinkingConfig,
source: 'maic-agent',
abortSignal: abortController.signal,
});
const agent = buildAgent({
streamFn,
systemPrompt: buildSystemPrompt(body.scene),
tools,
history: toHistoryMessages(body.history),
});
log.info(`agent edit turn [model=${modelString}] scene=${body.scene?.id ?? 'none'}`);
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: AgentEvent) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
/* controller closed */
}
};
const unsubscribe = agent.subscribe((event) => {
send(event);
});
try {
await agent.prompt(message);
await agent.waitForIdle();
} catch (err) {
log.error(`agent run failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
unsubscribe();
try {
controller.enqueue(encoder.encode('event: close\ndata: {}\n\n'));
} catch {
/* ignore */
}
controller.close();
}
},
cancel() {
agent.abort();
abortController.abort();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}
+323
View File
@@ -0,0 +1,323 @@
/**
* Durable, sparse SSE tail for the current owner's session summaries.
* A degraded caught_up is not authoritative: clients should schedule one full
* reconciliation and may later receive a non-degraded caught_up on recovery.
*
* Access model: there is no per-route auth challenge. Every request is
* granted an anonymous cookie identity, and every store read is scoped to
* that identity.
*/
import type { PersistedOwnerSessionEvent } from '@openmaic/storage';
import type { NextRequest } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { subscribeAgentEventWakeup } from '@/lib/server/agent-runtime/event-notify-bus';
import { resolveRequestOwnerId } from '@/lib/server/agent-runtime/owner';
import { getAgentSessionStore } from '@/lib/server/agent-runtime/store';
export const runtime = 'nodejs';
// Self-hosted `next start` ignores maxDuration; Vercel's adapter can still use
// it. The 25s heartbeat keeps this sparse stream active through idle periods.
export const maxDuration = 300;
// LISTEN/NOTIFY supplies low latency. This is deliberately retained as a
// correctness fallback because NOTIFY is lossy across listener disconnects.
export const OWNER_EVENT_POLL_INTERVAL_MS = 30_000;
export const SSE_HEARTBEAT_INTERVAL_MS = 25_000;
export const OWNER_EVENT_REPLAY_LIMIT = 1_000;
const BACKLOG_PAGE = 500;
function parseLastEventId(value: string | null): bigint {
if (!value || !/^\d+$/.test(value)) return BigInt(0);
try {
return BigInt(value);
} catch {
return BigInt(0);
}
}
export async function GET(req: NextRequest) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
// Identity belongs to the request, not the URL. EventSource reconnects to
// this same stable path with the anonymous cookie minted on first attach.
// This slice resolves only the anonymous cookie identity; a future auth
// integration must thread `authenticatedOwnerId` through here, or sessions
// created under authenticated identities would be unreachable by their own
// owner.
const responseHeaders = new Headers();
const ownerId = resolveRequestOwnerId(req, responseHeaders);
const store = await getAgentSessionStore();
const url = new URL(req.url);
const lastEventId = parseLastEventId(
req.headers.get('last-event-id') ?? url.searchParams.get('lastEventId'),
);
const encoder = new TextEncoder();
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let unsubscribeWakeup: (() => void) | null = null;
let closed = false;
const clearTimers = () => {
if (pollTimer) clearTimeout(pollTimer);
if (heartbeatTimer) clearInterval(heartbeatTimer);
pollTimer = null;
heartbeatTimer = null;
unsubscribeWakeup?.();
unsubscribeWakeup = null;
};
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let backlogDone = false;
let sentSinceAttach = 0;
let cursor = lastEventId;
let retirementCheckInFlight = false;
let consecutiveBacklogFailures = 0;
let degradedCaughtUp = false;
let attachCursorChecked = false;
let backlogSkippedForResync = false;
let initializing = true;
let wakeDuringInitialization = false;
let repollRequested = false;
let pollInFlight: Promise<void> | null = null;
const write = (chunk: string) => {
if (closed) return false;
try {
controller.enqueue(encoder.encode(chunk));
return true;
} catch {
// Some runtimes do not invoke cancel() for every broken socket.
// Treat an enqueue failure as closure so this dead connection cannot
// retain its heartbeat and poll timers indefinitely.
closed = true;
clearTimers();
return false;
}
};
const close = () => {
if (closed) return;
closed = true;
clearTimers();
try {
controller.close();
} catch {
// The request closed between the guard and close.
}
};
const emitCaughtUp = (degraded = false) =>
write(
`event: caught_up\ndata: ${JSON.stringify({
type: 'caught_up',
replayed: sentSinceAttach,
fromEventId: lastEventId.toString(),
...(degraded ? { degraded: true } : {}),
})}\n\n`,
);
const markCaughtUp = (degraded = false) => {
if (backlogDone) return;
if (!emitCaughtUp(degraded)) return;
backlogDone = true;
degradedCaughtUp = degraded;
};
const checkAttachCursor = async () => {
if (attachCursorChecked) return true;
let currentEventId: bigint;
try {
currentEventId = await store.readMaxId(ownerId);
} catch {
consecutiveBacklogFailures += 1;
if (consecutiveBacklogFailures >= 3) markCaughtUp(true);
return false;
}
consecutiveBacklogFailures = 0;
attachCursorChecked = true;
const reason =
lastEventId > currentEventId
? 'cursor_ahead'
: currentEventId - lastEventId > BigInt(OWNER_EVENT_REPLAY_LIMIT)
? 'too_far_behind'
: null;
if (!reason) return true;
cursor = currentEventId;
backlogDone = true;
degradedCaughtUp = false;
backlogSkippedForResync = true;
return write(
`event: resync_required\ndata: ${JSON.stringify({
type: 'resync_required',
reason,
fromEventId: lastEventId.toString(),
currentEventId: currentEventId.toString(),
})}\n\n`,
);
};
const writePage = (events: PersistedOwnerSessionEvent[]) => {
for (const event of events) {
if (
!write(
`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify({
...event,
// A degraded catch-up set `backlogDone` without draining, so
// the events that arrive while recovering are still history:
// keep labelling them backlog until the real signal goes out.
phase: backlogDone && !degradedCaughtUp ? 'live' : 'backlog',
})}\n\n`,
)
) {
return false;
}
cursor = BigInt(event.id);
sentSinceAttach += 1;
}
return true;
};
const readPage = () => store.readAfter(ownerId, cursor, BACKLOG_PAGE);
const drainBacklog = async () => {
if (!(await checkAttachCursor()) || backlogSkippedForResync) return;
for (;;) {
if (closed) return;
let page: PersistedOwnerSessionEvent[];
try {
page = await readPage();
} catch {
consecutiveBacklogFailures += 1;
if (consecutiveBacklogFailures >= 3) markCaughtUp(true);
return;
}
consecutiveBacklogFailures = 0;
if (!writePage(page)) return;
if (page.length < BACKLOG_PAGE) break;
}
markCaughtUp();
};
const poll = async () => {
if (closed) return;
if (!attachCursorChecked) {
if (!(await checkAttachCursor()) || backlogSkippedForResync) return;
}
try {
const page = await readPage();
consecutiveBacklogFailures = 0;
if (!writePage(page)) return;
if (degradedCaughtUp) {
// The authoritative signal has to pass the SAME exhaustion check
// as the normal path. A degraded window is exactly when backlog
// piles up, so the first successful page is often full: emitting
// here would announce "the list is authoritative now" while
// thousands of events are still queued behind it.
if (page.length < BACKLOG_PAGE && emitCaughtUp()) degradedCaughtUp = false;
return;
}
if (!backlogDone && page.length < BACKLOG_PAGE) markCaughtUp();
} catch {
// Before initial catch-up, retain backlog mode and retry. Only after
// three consecutive failures do we unblock the UI with an explicit
// degraded signal. Live-tail failures simply retry next tick.
if (!backlogDone) {
consecutiveBacklogFailures += 1;
if (consecutiveBacklogFailures >= 3) markCaughtUp(true);
}
}
};
// Both timer and NOTIFY enter the same serialized gate. If a wakeup
// lands during a read, exactly one follow-up read runs after it settles;
// concurrent reads could duplicate frames or move the cursor backwards.
const requestPoll = (): Promise<void> => {
if (closed) return Promise.resolve();
if (initializing) {
wakeDuringInitialization = true;
return Promise.resolve();
}
if (pollInFlight) {
repollRequested = true;
return pollInFlight;
}
pollInFlight = (async () => {
do {
repollRequested = false;
await poll();
} while (repollRequested && !closed);
})().finally(() => {
pollInFlight = null;
});
return pollInFlight;
};
const tick = () => {
if (closed) return;
pollTimer = setTimeout(() => {
if (closed) return;
void requestPoll().then(tick, tick);
}, OWNER_EVENT_POLL_INTERVAL_MS);
};
// Retirement is checked on the independent heartbeat, not every event
// poll. Owner merges are rare; this caps idle cost at one indexed lookup
// per 25s while noticing established stale streams.
heartbeatTimer = setInterval(() => {
if (closed) return;
write(': ping\n\n');
if (closed || retirementCheckInFlight) return;
retirementCheckInFlight = true;
void store
.readRetirement(ownerId)
.then((newOwnerId) => {
if (!newOwnerId || closed) return;
// Native EventSource reconnects a clean 200 EOF with the same
// Last-Event-ID. The client MUST close this instance, construct a
// new EventSource without that cursor, and perform one full session
// list reconciliation because session_created omits list fields
// such as prompt/stageId.
write(
`event: owner_moved\ndata: ${JSON.stringify({
type: 'owner_moved',
newOwnerId,
action: 'reconnect',
})}\n\n`,
);
close();
})
.catch(() => {
// A transient PG failure is retried on the next heartbeat.
})
.finally(() => {
retirementCheckInFlight = false;
});
}, SSE_HEARTBEAT_INTERVAL_MS);
// Register before the initial read so a commit racing with backlog
// exhaustion cannot fall into the 30s fallback window. The callback is
// removed on every stream close path together with both timers.
unsubscribeWakeup = subscribeAgentEventWakeup({ kind: 'owner', ownerId }, () => {
void requestPoll();
});
await drainBacklog();
initializing = false;
if (wakeDuringInitialization) await requestPoll();
tick();
},
cancel() {
closed = true;
clearTimers();
},
});
responseHeaders.set('Content-Type', 'text/event-stream; charset=utf-8');
responseHeaders.set('Cache-Control', 'no-cache, no-transform');
responseHeaders.set('Connection', 'keep-alive');
return new Response(stream, { headers: responseHeaders });
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Server-side agent runtime status probe.
*
* GET /api/agent/runtime -> { enabled: boolean, runtimeEnabled: boolean }
*
* `enabled` reports usability, not intent: the workbench client gates its
* entry on this field, so it is true only when the runtime can actually serve
* a request — the flag AND a `DATABASE_URL` (the runner and every
* persistence-touching route need the store). `runtimeEnabled` carries the
* raw intent flag so a client can tell "off by choice" (`runtimeEnabled:
* false`) from "on but unusable" (`runtimeEnabled: true`, missing
* DATABASE_URL).
*/
import { isAgentRuntimeConfigured, isAgentRuntimeEnabled } from '@/lib/config/feature-flags';
export const runtime = 'nodejs';
export async function GET() {
// Intentionally no materials flag: isAgentMaterialsEnabled does not exist in
// this repo (the materials routes gate on the runtime, like the stages).
return Response.json({
enabled: isAgentRuntimeConfigured(),
runtimeEnabled: isAgentRuntimeEnabled(),
});
}
@@ -0,0 +1,45 @@
/**
* Agent runtime control plane for cancellation.
*
* The route makes the request durable. The lease holder observes it and
* writes the terminal event, keeping the event log single-writer.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { getAgentSessionStore } from '@/lib/server/agent-runtime/store';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isAgentRuntimeConfigured()) {
return new Response('Not found', { status: 404 });
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const store = await getAgentSessionStore();
const meta = await store.getSession(id);
if (!meta || meta.ownerId !== ownerId) {
return new Response('Not found', { status: 404, headers: responseHeaders });
}
if (meta.status === 'succeeded' || meta.status === 'failed' || meta.status === 'cancelled') {
return NextResponse.json(
{
code: 'SESSION_ALREADY_TERMINAL',
status: meta.status,
error: `session is already ${meta.status}`,
},
{ status: 409, headers: responseHeaders },
);
}
await store.requestCancel(id);
return NextResponse.json(
{ id, cancelRequested: true },
{ status: 202, headers: responseHeaders },
);
});
}
+300
View File
@@ -0,0 +1,300 @@
/**
* Agent runtime control plane — the session event stream (SSE).
*
* GET /api/agent/sessions/:id/events
* Honors `Last-Event-ID` (header or `?lastEventId=`): everything durable
* with `seq > lastEventId` is replayed first (intermediate `message_update`
* tokens are dropped; the last update in each run already has the full
* text), then whatever lands afterwards. A client attaching mid-run and
* one attaching between runs take the exact same path. The log is the
* single source of truth; the live stream is just its tail.
*
* Frames:
* - one `caught_up` event when the backlog has been drained (a real, named
* event rather than a comment, which `EventSource` would drop). A
* `degraded: true` caught_up is not authoritative and asks the client to
* schedule a full reconciliation; recovery emits one plain caught_up;
* - runner/pi/control-plane events as `id:` + `event:` + `data:`.
*
* The stream does NOT close at `session_end`: a session is a long-lived
* conversation (continuous chat), and a run boundary is just another frame.
* The attach ends when the client disconnects or an HTTP intermediary cuts
* the stream. Native `EventSource` then reconnects with `Last-Event-ID` and
* resumes through the same replay path without losing durable events.
*
* Access model: there is no per-route auth challenge. Every request is
* granted an anonymous cookie identity, and every store read is scoped to
* that identity. A session owned by another identity is indistinguishable
* from a missing one — both are 404 with the same response.
*
* This handler is a pure READER of the store. A disconnect closes this
* reader and nothing else: the runner keeps running, and its events keep
* landing in the log.
*/
import type { PersistedAgentSessionEvent } from '@openmaic/storage';
import type { NextRequest } from 'next/server';
import { HOST_AGENT_LIFECYCLE as LIFECYCLE } from '@/lib/agent-runtime/lifecycle';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { subscribeAgentEventWakeup } from '@/lib/server/agent-runtime/event-notify-bus';
import { resolveRequestOwnerId } from '@/lib/server/agent-runtime/owner';
import { getAgentSessionStore } from '@/lib/server/agent-runtime/store';
export const runtime = 'nodejs';
// Self-hosted `next start` does not enforce maxDuration; it remains useful to
// Vercel's build adapter. EventSource resumes durable events with Last-Event-ID.
// The 25s heartbeat prevents idle intermediaries from ending the stream early.
export const maxDuration = 300;
// LISTEN/NOTIFY supplies low latency. Polling remains an explicit correctness
// fallback for notifications lost during disconnects; terminal streams retain
// their longer backoff.
export const POLL_INTERVAL_MS = 5_000;
// Terminal streams poll less often than active ones, but 10s is the ceiling.
// The worst case is "session already terminal -> user steers": this is exactly
// the moment the user is waiting for the result. Terminal streams exist only
// while the user actually has that session open, so the extra cost is small.
export const TERMINAL_POLL_INTERVAL_MS = 10_000;
const HEARTBEAT_INTERVAL_MS = 25_000;
/** Same default as `readEventsAfter`. A full page means more backlog remains. */
const BACKLOG_PAGE = 500;
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isAgentRuntimeConfigured()) {
return new Response('Not found', { status: 404 });
}
const { id } = await params;
const responseHeaders = new Headers();
// The identity cookie is minted for the requester regardless of the target
// session, and the owner is resolved before the session lookup: a request
// for a missing session and one for a session owned by someone else return
// byte-identical 404s (same status, body, and cookie headers), so the
// response cannot be used to probe whether a session UUID exists. This
// slice resolves only the anonymous cookie identity; a future auth
// integration must thread `authenticatedOwnerId` through here, or sessions
// created under authenticated identities would be unreachable by their own
// owner.
const ownerId = resolveRequestOwnerId(req, responseHeaders);
const store = await getAgentSessionStore();
const meta = await store.getSession(id);
if (!meta) {
return new Response('Not found', { status: 404, headers: responseHeaders });
}
if (meta.ownerId !== ownerId) {
return new Response('Not found', { status: 404, headers: responseHeaders });
}
const url = new URL(req.url);
const headerId = req.headers.get('last-event-id');
const lastEventId = Number(headerId ?? url.searchParams.get('lastEventId') ?? 0) || 0;
const encoder = new TextEncoder();
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let unsubscribeWakeup: (() => void) | null = null;
// Hoisted so cancel() can stop an in-flight-then-scheduled poll, not just
// the timer: after a client disconnect, `closed` makes every later poll a
// no-op and `write` a dead end.
let closed = false;
const clearTimers = () => {
if (pollTimer) clearTimeout(pollTimer);
if (heartbeatTimer) clearInterval(heartbeatTimer);
pollTimer = null;
heartbeatTimer = null;
unsubscribeWakeup?.();
unsubscribeWakeup = null;
};
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let backlogDone = false;
let sentSinceAttach = 0;
let cursor = lastEventId;
let terminal = false;
let consecutiveBacklogFailures = 0;
let degradedCaughtUp = false;
let initializing = true;
let wakeDuringInitialization = false;
let repollRequested = false;
let pollInFlight: Promise<void> | null = null;
const write = (chunk: string) => {
if (closed) return false;
try {
controller.enqueue(encoder.encode(chunk));
return true;
} catch {
// A broken socket is not guaranteed to invoke cancel() in every
// runtime. Stop both timers as soon as enqueue proves it is closed.
closed = true;
clearTimers();
return false;
}
};
// "You are caught up" is a real, named SSE event, not a comment: a
// client attaching during a long tool call would otherwise sit on
// "replaying" until the next event, and a re-attach at the last event
// id of an idle session would receive zero frames and wait forever.
const emitCaughtUp = (degraded = false) =>
write(
`event: caught_up\ndata: ${JSON.stringify({
type: 'caught_up',
replayed: sentSinceAttach,
fromEventId: lastEventId,
...(degraded ? { degraded: true } : {}),
})}\n\n`,
);
const markCaughtUp = (degraded = false) => {
if (backlogDone) return;
if (!emitCaughtUp(degraded)) return;
backlogDone = true;
degradedCaughtUp = degraded;
};
const writePage = (events: PersistedAgentSessionEvent[]) => {
for (const event of events) {
if (
!write(
`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify({
...event,
// A degraded catch-up set `backlogDone` without draining, so
// events arriving while recovering are still history: keep
// labelling them backlog until the real signal goes out.
phase: backlogDone && !degradedCaughtUp ? 'live' : 'backlog',
})}\n\n`,
)
) {
return false;
}
cursor = event.id;
sentSinceAttach += 1;
const data = event.data as { status?: unknown } | null;
const isTerminalEnd =
event.type === LIFECYCLE.sessionEnd &&
(data?.status === 'succeeded' ||
data?.status === 'failed' ||
data?.status === 'cancelled');
// A terminal session_end is normally the last frame. Any later
// durable frame proves activity resumed (usually user_message then
// session_start/session_resumed), so polling switches both ways.
if (isTerminalEnd) terminal = true;
else if (terminal) terminal = false;
}
return true;
};
const drainBacklog = async () => {
for (;;) {
if (closed) return;
let page;
try {
page = await store.readEventsAfterForReplay(id, cursor, BACKLOG_PAGE);
} catch {
consecutiveBacklogFailures += 1;
if (consecutiveBacklogFailures >= 3) markCaughtUp(true);
return;
}
consecutiveBacklogFailures = 0;
if (!writePage(page.events)) return;
// Pagination judges by the RAW page size (`scanned`), not the
// compacted length: a page of pure message_update compacts to two
// frames and would otherwise look "exhausted" mid-log.
if (page.scanned < BACKLOG_PAGE) break;
}
markCaughtUp();
};
const poll = async () => {
if (closed) return;
let page;
try {
page = await store.readEventsAfterForReplay(id, cursor, BACKLOG_PAGE);
} catch {
if (!backlogDone) {
consecutiveBacklogFailures += 1;
if (consecutiveBacklogFailures >= 3) markCaughtUp(true);
}
return; // transient PG hiccup — the next poll retries
}
consecutiveBacklogFailures = 0;
if (!writePage(page.events)) return;
if (degradedCaughtUp) {
// Same exhaustion check as the normal path below: a degraded window
// is when backlog piles up, so the first successful page is often
// full and announcing catch-up there would be another lie.
if (page.scanned < BACKLOG_PAGE && emitCaughtUp()) degradedCaughtUp = false;
return;
}
if (!backlogDone && page.scanned < BACKLOG_PAGE) markCaughtUp();
};
const requestPoll = (): Promise<void> => {
if (closed) return Promise.resolve();
if (initializing) {
wakeDuringInitialization = true;
return Promise.resolve();
}
if (pollInFlight) {
repollRequested = true;
return pollInFlight;
}
pollInFlight = (async () => {
do {
repollRequested = false;
await poll();
} while (repollRequested && !closed);
})().finally(() => {
pollInFlight = null;
});
return pollInFlight;
};
// Serialized polling: the next poll is scheduled only after the
// previous one has SETTLED, so a PG read slower than the interval can
// never start a second concurrent poll. Two in-flight polls share the
// cursor and would emit duplicate frames — worse, the slower one would
// rewind the cursor for the poll after it.
const tick = () => {
if (closed) return;
// An idle historical session may wait up to 10 seconds for its first
// reactivation frame; the user action already has optimistic UI. Once
// that frame arrives, the next read returns to the 5s cadence.
const interval = terminal ? TERMINAL_POLL_INTERVAL_MS : POLL_INTERVAL_MS;
pollTimer = setTimeout(() => {
if (closed) return;
// Reschedule only after poll settles. tick then re-reads terminal, so
// a reactivation discovered by this poll restores the 5s cadence.
void requestPoll().then(tick, tick);
}, interval);
};
write(`: replaying from event ${lastEventId}\n\n`);
heartbeatTimer = setInterval(() => {
if (closed) return;
write(': ping\n\n');
}, HEARTBEAT_INTERVAL_MS);
// Register before the initial read so a commit racing with backlog
// exhaustion cannot fall into the 5s fallback window. The callback is
// removed on every stream close path together with both timers.
unsubscribeWakeup = subscribeAgentEventWakeup({ kind: 'session', sessionId: id }, () => {
void requestPoll();
});
await drainBacklog();
initializing = false;
if (wakeDuringInitialization) await requestPoll();
tick();
},
cancel() {
closed = true;
clearTimers();
},
});
responseHeaders.set('Content-Type', 'text/event-stream; charset=utf-8');
responseHeaders.set('Cache-Control', 'no-cache, no-transform');
responseHeaders.set('Connection', 'keep-alive');
return new Response(stream, { headers: responseHeaders });
}
@@ -0,0 +1,122 @@
/** Agent runtime control plane for durable follow-up messages. */
import { AgentSessionAccessError } from '@openmaic/storage';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { apiError } from '@/lib/server/api-response';
import { MAX_SESSION_TEXT_LENGTH } from '@/lib/server/agent-runtime/limits';
import { getAgentSessionStore } from '@/lib/server/agent-runtime/store';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
import { decodeElementRefs } from '@/lib/workbench/element-refs';
import { decodeCourseRefs } from '@/lib/workbench/course-refs';
import {
bindOwnerMaterialsToSession,
SessionMaterialBindingError,
} from '@/lib/server/agent-runtime/session-materials';
export const runtime = 'nodejs';
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isAgentRuntimeConfigured()) {
return new Response('Not found', { status: 404 });
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const store = await getAgentSessionStore();
const meta = await store.getSession(id);
if (!meta || meta.ownerId !== ownerId) {
return new Response('Not found', { status: 404, headers: responseHeaders });
}
let body: {
text?: string;
materialIds?: unknown;
elementRefs?: unknown;
courseRefs?: unknown;
} = {};
try {
body = ((await req.json()) ?? {}) as typeof body;
} catch {
const response = apiError('INVALID_REQUEST', 400, 'invalid JSON body');
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
const text = (body.text ?? '').toString().trim();
if (
body.materialIds !== undefined &&
(!Array.isArray(body.materialIds) || body.materialIds.some((id) => typeof id !== 'string'))
) {
const response = apiError('INVALID_REQUEST', 400, 'materialIds must be an array of strings');
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
const materialIds = [...new Set((body.materialIds ?? []).map((id: string) => id.trim()))];
if (materialIds.length > 20 || materialIds.some((id) => !id)) {
const response = apiError('INVALID_REQUEST', 400, 'materialIds are invalid');
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
const decodedElementRefs = decodeElementRefs(body.elementRefs ?? []);
if (!decodedElementRefs.ok) {
const response = apiError('INVALID_REQUEST', 400, decodedElementRefs.error);
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
const decodedCourseRefs = decodeCourseRefs(body.courseRefs ?? []);
if (!decodedCourseRefs.ok) {
const response = apiError('INVALID_REQUEST', 400, decodedCourseRefs.error);
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
if (!text && materialIds.length === 0) {
const response = apiError('MISSING_REQUIRED_FIELD', 400, 'text is required');
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
if (text.length > MAX_SESSION_TEXT_LENGTH) {
const response = apiError(
'INVALID_REQUEST',
400,
`text exceeds the ${MAX_SESSION_TEXT_LENGTH} character limit`,
);
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
try {
const materials = materialIds.length
? await bindOwnerMaterialsToSession(id, ownerId, materialIds)
: [];
const posted = await store.postUserMessage(
id,
{
text,
...(materials.length ? { materials } : {}),
...(decodedElementRefs.refs.length ? { elementRefs: decodedElementRefs.refs } : {}),
...(decodedCourseRefs.refs.length ? { courseRefs: decodedCourseRefs.refs } : {}),
},
{ expectedOwnerId: ownerId },
);
return NextResponse.json(
{
id,
message: { seq: posted.seq, text, delivery: posted.delivery },
elementRefsAccepted: decodedElementRefs.refs.length > 0,
courseRefsAccepted: decodedCourseRefs.refs.length > 0,
},
{ status: 202, headers: responseHeaders },
);
} catch (error) {
if (error instanceof SessionMaterialBindingError) {
return new Response('Not found', { status: 404, headers: responseHeaders });
}
if (error instanceof AgentSessionAccessError) {
return new Response('Forbidden', { status: 403, headers: responseHeaders });
}
throw error;
}
});
}
+25
View File
@@ -0,0 +1,25 @@
/** Agent runtime control plane for reading one owned session. */
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { getAgentSessionStore } from '@/lib/server/agent-runtime/store';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isAgentRuntimeConfigured()) {
return new Response('Not found', { status: 404 });
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const store = await getAgentSessionStore();
const meta = await store.getSession(id);
if (!meta || meta.ownerId !== ownerId) {
return new Response('Not found', { status: 404, headers: responseHeaders });
}
return NextResponse.json(meta, { headers: responseHeaders });
});
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Agent runtime control plane for session creation and listing.
*
* These handlers only use the durable session store. A separately running
* worker claims queued sessions after the request has returned.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { apiError } from '@/lib/server/api-response';
import { MAX_SESSION_TEXT_LENGTH } from '@/lib/server/agent-runtime/limits';
import { findSkill, inferSkillIdFromPrompt, listSkills } from '@/lib/server/agent-runtime/skills';
import { getAgentSessionStore } from '@/lib/server/agent-runtime/store';
import {
bindOwnerMaterialsToSession,
SessionMaterialBindingError,
} from '@/lib/server/agent-runtime/session-materials';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
import { buildRequestOrigin, isValidClassroomId } from '@/lib/server/classroom-storage';
import { decodeCourseRefs } from '@/lib/workbench/course-refs';
export const runtime = 'nodejs';
interface CreateSessionBody {
prompt?: string;
stageId?: string;
skill?: string;
/** Attach to an already-built classroom instead of starting a new course. */
existingCourse?: boolean;
/** Existing owner-library uploads to bind before the first run is queued. */
materialIds?: unknown;
/** Classrooms named on the opening message. */
courseRefs?: unknown;
}
export async function POST(req: NextRequest) {
if (!isAgentRuntimeConfigured()) {
return new Response('Not found', { status: 404 });
}
let body: CreateSessionBody = {};
try {
body = ((await req.json()) ?? {}) as CreateSessionBody;
} catch {
return apiError('INVALID_REQUEST', 400, 'invalid JSON body');
}
const existingCourse = body.existingCourse === true;
const stageId = body.stageId?.toString().trim() || undefined;
if (existingCourse && !stageId) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'existingCourse requires stageId');
}
if (existingCourse && stageId && !isValidClassroomId(stageId)) {
return apiError('INVALID_REQUEST', 400, 'existingCourse stageId has an invalid format');
}
const prompt =
(body.prompt ?? '').toString().trim() || (existingCourse ? (stageId ?? 'existing-course') : '');
if (!prompt) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'prompt is required');
}
if (prompt.length > MAX_SESSION_TEXT_LENGTH) {
return apiError(
'INVALID_REQUEST',
400,
`prompt exceeds the ${MAX_SESSION_TEXT_LENGTH} character limit`,
);
}
if (
body.materialIds !== undefined &&
(!Array.isArray(body.materialIds) || body.materialIds.some((id) => typeof id !== 'string'))
) {
return apiError('INVALID_REQUEST', 400, 'materialIds must be an array of strings');
}
const materialIds = [...new Set(((body.materialIds ?? []) as string[]).map((id) => id.trim()))];
if (materialIds.length > 20 || materialIds.some((id) => !id)) {
return apiError('INVALID_REQUEST', 400, 'materialIds are invalid');
}
if (existingCourse && materialIds.length > 0) {
return apiError(
'INVALID_REQUEST',
400,
'existingCourse does not accept attachments; send them on the first message instead',
);
}
const decodedCourseRefs = decodeCourseRefs(body.courseRefs ?? []);
if (!decodedCourseRefs.ok) {
return apiError('INVALID_REQUEST', 400, decodedCourseRefs.error);
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
// An EXPLICIT skill — a `?skill=` launch link, not composer UI — is
// rejected here rather than at claim time: a session created with a typo'd
// skill would otherwise sit queued and then quietly build an ordinary
// conversation. The runner's `findSkill` matches a reference by id OR name
// (a user skill's natural handle is `name`, `my-*`), so the route validates
// with the same lookup and freezes the resolved id.
let explicitSkillId = (body.skill ?? '').toString().trim() || undefined;
if (explicitSkillId) {
const found = await findSkill(explicitSkillId, ownerId);
if (!found) {
const known = await listSkills(ownerId);
return new NextResponse(
JSON.stringify({
success: false as const,
errorCode: 'INVALID_REQUEST',
error: `unknown skill "${explicitSkillId}"; installed: ${
known.map((s) => s.id).join(', ') || '(none)'
}`,
}),
{ status: 400, headers: responseHeaders },
);
}
explicitSkillId = found.id;
}
/**
* Otherwise, read the skill off the message itself.
*
* Skills are written as `/handle` TEXT — there is no chip and no skill
* field in the UI, because an input box is an input box. Nothing needs to
* parse that text for the agent (skills are listed in the system prompt
* and opened with pi's native `read`), but the session's `skillId` still
* feeds the outline-constraint pointer. So the SERVER recognises the
* structure in the text and records it.
*
* Forgiving by design: an unrecognised handle simply means no skill — never
* an error, never a fallback to a default — and the text stays in the
* prompt either way, so the model still sees what the user asked for.
*/
const skillId = explicitSkillId ?? (await inferSkillIdFromPrompt(prompt, ownerId));
// Upstream classrooms do not carry an owner partition, so existing-course
// sessions validate only the identifier format here. Full existence and
// ownership validation is deferred until a later slice consumes stageId —
// the upstream document store has no owner partition yet.
const store = await getAgentSessionStore();
const hasOpeningContext = materialIds.length > 0 || decodedCourseRefs.refs.length > 0;
const meta = await store.createSession({
ownerId,
prompt,
...(stageId ? { stageId } : {}),
...(skillId ? { skillId } : {}),
existingCourse,
origin: buildRequestOrigin(req),
// Keep the runner from claiming the session until its opening materials
// and references are durable. postUserMessage below atomically requeues it.
...(existingCourse || hasOpeningContext ? { status: 'succeeded' as const } : {}),
});
if (!hasOpeningContext) {
return NextResponse.json(meta, { status: 202, headers: responseHeaders });
}
try {
const materials = materialIds.length
? await bindOwnerMaterialsToSession(meta.id, ownerId, materialIds)
: [];
await store.postUserMessage(
meta.id,
{
text: prompt,
...(materials.length ? { materials } : {}),
...(decodedCourseRefs.refs.length ? { courseRefs: decodedCourseRefs.refs } : {}),
},
{ expectedOwnerId: ownerId },
);
return NextResponse.json(
{
...meta,
status: 'queued',
...(decodedCourseRefs.refs.length ? { courseRefs: decodedCourseRefs.refs } : {}),
},
{ status: 202, headers: responseHeaders },
);
} catch (error) {
await store.softDeleteSession(meta.id, ownerId).catch(() => false);
if (error instanceof SessionMaterialBindingError) {
return new Response('Not found', { status: 404, headers: responseHeaders });
}
throw error;
}
});
}
export async function GET(req: NextRequest) {
if (!isAgentRuntimeConfigured()) {
return new Response('Not found', { status: 404 });
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const store = await getAgentSessionStore();
const sessions = await store.listSessionsByOwner(ownerId);
return NextResponse.json(sessions, { headers: responseHeaders });
});
}
+20
View File
@@ -0,0 +1,20 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { getAgentSessionStore } from '@/lib/server/agent-runtime/store';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
/** Return a sparse status map for all sessions visible to this owner. */
export async function GET(req: NextRequest) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const store = await getAgentSessionStore();
const sessions = await store.listSessionsByOwner(ownerId);
const statuses = Object.fromEntries(sessions.map((session) => [session.id, session.status]));
return NextResponse.json(statuses, { headers: responseHeaders });
});
}
+50
View File
@@ -0,0 +1,50 @@
/**
* One user-owned Skill body, without bloating the global picker payload.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import {
deleteUserSkill,
findUserSkill,
UserSkillError,
} from '@/lib/server/agent-runtime/user-skills';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const skill = await findUserSkill(id, ownerId);
if (!skill) return new Response('Not found', { status: 404 });
return NextResponse.json(
{ id: skill.id, content: skill.content },
{ headers: responseHeaders },
);
});
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
if (!id.startsWith('usk_')) {
return new Response('Built-in skills cannot be deleted.', {
status: 405,
headers: responseHeaders,
});
}
try {
await deleteUserSkill(ownerId, id);
return new Response(null, { status: 204, headers: responseHeaders });
} catch (error) {
if (error instanceof UserSkillError && error.code === 'not-found') {
return new Response('Not found', { status: 404, headers: responseHeaders });
}
throw error;
}
});
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Agent runtime control plane — the installed skills.
*
* GET /api/agent/skills -> [{ id, name, title, description, hasConstraints, source }]
*
* Drives the `/` picker (a skill the user names there becomes the session's
* user-locked skill at creation). `title` is the skill's display name from its
* frontmatter; every surface shows it beside the id, which stays the English
* contract.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { listSkills } from '@/lib/server/agent-runtime/skills';
import { createUserSkill, UserSkillError } from '@/lib/server/agent-runtime/user-skills';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
import {
parseUserSkillMarkdown,
parseUserSkillZip,
UserSkillUploadError,
} from '@/lib/server/skill-export';
export const runtime = 'nodejs';
export async function GET(req: NextRequest) {
if (!isAgentRuntimeConfigured()) {
return new Response('Not found', { status: 404 });
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const skills = await listSkills(ownerId);
return NextResponse.json(
skills.map((s) => ({
id: s.id,
name: s.name,
...(s.title ? { title: s.title } : {}),
description: s.description,
hasConstraints: !!s.constraints,
source: s.source,
})),
{ headers: responseHeaders },
);
});
}
/** Upload one owner Skill as the exporter zip or a bare canonical SKILL.md. */
export async function POST(req: NextRequest) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
try {
const form = await req.formData();
const upload = form.get('file');
if (!upload || typeof upload === 'string' || typeof upload.arrayBuffer !== 'function') {
return new Response('A skill file is required.', {
status: 400,
headers: responseHeaders,
});
}
const bytes = Buffer.from(await upload.arrayBuffer());
// Exported owner zips are at most a little over the 64 KiB content cap.
// Bound compressed input before JSZip expands it; field validation below
// remains the authoritative create limit after parsing.
if (bytes.byteLength > 1_048_576) {
return new Response('The skill upload is too large.', {
status: 413,
headers: responseHeaders,
});
}
const input = upload.name.toLowerCase().endsWith('.zip')
? await parseUserSkillZip(bytes)
: parseUserSkillMarkdown(bytes.toString('utf8'));
const skill = await createUserSkill(ownerId, input);
return NextResponse.json(
{
id: skill.id,
name: skill.name,
title: skill.title,
description: skill.description,
hasConstraints: false,
source: 'user',
},
{ status: 201, headers: responseHeaders },
);
} catch (error) {
if (error instanceof UserSkillError) {
const status = error.code === 'duplicate' || error.code === 'quota' ? 409 : 400;
return NextResponse.json(
{ error: error.code, message: error.message },
{ status, headers: responseHeaders },
);
}
if (error instanceof UserSkillUploadError) {
return NextResponse.json(
{ error: 'invalid-upload', message: error.message },
{ status: 400, headers: responseHeaders },
);
}
throw error;
}
});
}
+15 -6
View File
@@ -228,19 +228,26 @@ async function runExtraction(
// flattened to the same text shape documents produce. Same route, same
// downstream generation path.
if (SUPPORTED_MEDIA_MIME_TYPES.includes(mimeType)) {
logState.resolvedProviderId = requestConfig.providerId || 'alidocmind';
logState.resolvedProviderId = requestConfig.providerId || '';
// Reject a document-only provider (e.g. unpdf/mineru) for a media upload
// with a clear 4xx instead of forwarding it into the media registry and
// surfacing an opaque 500.
const mediaProvider = getMediaExtractorProvider(logState.resolvedProviderId);
if (!mediaProvider || !mediaProvider.supportedMimeTypes.includes(mimeType)) {
const mediaProvider = requestConfig.providerId
? getMediaExtractorProvider(requestConfig.providerId)
: undefined;
if (
requestConfig.providerId &&
(!mediaProvider || !mediaProvider.supportedMimeTypes.includes(mimeType))
) {
return apiError(
'INVALID_REQUEST',
400,
`Provider "${logState.resolvedProviderId}" cannot extract ${mimeType}. Choose a media-capable provider (e.g. AliDocMind).`,
`Provider "${requestConfig.providerId}" cannot extract ${mimeType}. Choose a media-capable provider (AliDocMind or local ffmpeg).`,
);
}
const mediaManaged = isServerConfiguredProvider('pdf', logState.resolvedProviderId);
const mediaManaged =
requestConfig.providerId !== 'local-ffmpeg' &&
isServerConfiguredProvider('pdf', 'alidocmind');
// When managed, resolve the server-owned AK/SK (env OR YAML) explicitly so
// a YAML-only deployment works — the client-level env fallback reads env
// vars only. Client-entered creds are used only when unmanaged.
@@ -260,7 +267,7 @@ async function runExtraction(
fileSize,
mimeType,
config: {
providerId: logState.resolvedProviderId,
providerId: requestConfig.providerId || '',
apiKey: mediaManaged ? undefined : requestConfig.apiKey || undefined,
baseUrl: mediaManaged ? mediaManagedCreds?.baseUrl : mediaClientBaseUrl,
accessKeyId: mediaManaged
@@ -274,6 +281,8 @@ async function runExtraction(
allowEnvFallback: mediaManaged,
},
});
logState.resolvedProviderId =
mediaArtifact.metadata.providerId || requestConfig.providerId || '';
const mediaText = mediaArtifactToText(mediaArtifact);
// An artifact with no transcript, keyframes, or synopsis carries no usable
+121
View File
@@ -0,0 +1,121 @@
/**
* PATCH /api/folders/[id] — rename { name }
* DELETE /api/folders/[id]?mode=ungroup|remove — delete a folder
*
* `mode=ungroup` (default): the folder is dropped and its courses become
* unfiled. `mode=remove`: the folder is dropped and the captured member
* course ids are returned, so the caller can run its own cascade (the
* workbench deletes the owner courses it captured).
*
* Every handler is owner-scoped exactly like the other workbench routes (see
* `app/api/folders/route.ts`), and the whole family is gated on the
* configured runtime.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import type { DocumentFolder, DocumentFolderStore } from '@openmaic/storage';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { getOwnerScopedDocumentStore } from '@/lib/server/agent-runtime/owner-scoped-documents';
import { ownerJson } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
import { folderNameErrorResponse } from '@/lib/server/folder-name-errors';
import { validateFolderName } from '@/lib/utils/folder-name-validation';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string }> };
/** The wire shape is the reference's `FolderItem`; see `app/api/folders/route.ts`. */
function folderResponse(folder: DocumentFolder, userKey: string) {
return { ...folder, userKey };
}
function jsonError(status: number, code: string, message: string, headers?: Headers): NextResponse {
return NextResponse.json({ error: { code, message } }, { status, headers });
}
// PATCH /api/folders/[id] — rename { name }.
export async function PATCH(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
let body: unknown;
try {
body = await req.json();
} catch {
return jsonError(400, 'INVALID_BODY', 'request body must be JSON');
}
const name = (body as { name?: unknown })?.name;
if (typeof name !== 'string') {
return jsonError(400, 'FOLDER_NAME_INVALID', 'name must be a string');
}
const trimmed = name.trim();
const check = validateFolderName(trimmed);
if (!check.ok) {
return jsonError(
400,
check.kind === 'empty' ? 'FOLDER_NAME_EMPTY' : 'FOLDER_NAME_TOO_LONG',
check.kind === 'empty' ? 'folder name must not be empty' : 'folder name is too long',
);
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
try {
const store = (await getOwnerScopedDocumentStore(ownerId)) as unknown as DocumentFolderStore;
// Excluding itself, same case-insensitive rule as create.
const existing = await store.listFolders();
const clash = existing.some(
(folder) => folder.id !== id && folder.name.toLowerCase() === trimmed.toLowerCase(),
);
if (clash) {
return jsonError(
409,
'FOLDER_NAME_DUPLICATE',
'a folder with this name already exists',
responseHeaders,
);
}
const updated = await store.renameFolder(id, trimmed);
if (!updated) {
return jsonError(404, 'FOLDER_NOT_FOUND', 'folder not found', responseHeaders);
}
return ownerJson({ folder: folderResponse(updated, ownerId) }, 200, responseHeaders);
} catch (error) {
// The rename re-checks the name through the unique index; a duplicate
// that slipped past the pre-check answers the same 409.
const nameError = folderNameErrorResponse(error);
if (nameError) {
for (const [key, value] of responseHeaders) nameError.headers.append(key, value);
return nameError;
}
console.error(`[Folders] Failed to rename [owner=${ownerId}, id=${id}]:`, error);
return jsonError(500, 'FOLDER_RENAME_FAILED', 'Failed to rename folder', responseHeaders);
}
});
}
// DELETE /api/folders/[id]?mode=ungroup|remove
export async function DELETE(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
const modeParam = req.nextUrl.searchParams.get('mode');
const mode: 'ungroup' | 'remove' = modeParam === 'remove' ? 'remove' : 'ungroup';
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
try {
const store = (await getOwnerScopedDocumentStore(ownerId)) as unknown as DocumentFolderStore;
const result = await store.deleteFolder(id, mode);
if (!result) {
return jsonError(404, 'FOLDER_NOT_FOUND', 'folder not found', responseHeaders);
}
return ownerJson({ ok: true, removedStageIds: result.removedStageIds }, 200, responseHeaders);
} catch (error) {
console.error(`[Folders] Failed to delete [owner=${ownerId}, id=${id}]:`, error);
return jsonError(500, 'FOLDER_DELETE_FAILED', 'Failed to delete folder', responseHeaders);
}
});
}
+73
View File
@@ -0,0 +1,73 @@
/**
* POST /api/folders/members — set which folder a course belongs to.
*
* Body: { stageId: string, folderId: string | null }
* folderId = string → file the course into that folder (must belong to the
* caller, else 404 FOLDER_NOT_FOUND).
* folderId = null → unfile the course (the membership is removed; an
* absent membership already means unfiled, so this is
* idempotent).
*
* Membership is a pure (owner, stage) → folder organization row on the
* document row — deliberately decoupled from the course list itself (the
* document index is the UI's gate), and `stageId` is a soft reference: the
* server may delete a stage.
*
* Owner-scoped and gated exactly like the rest of the folder family.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import type { DocumentFolderStore } from '@openmaic/storage';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { getOwnerScopedDocumentStore } from '@/lib/server/agent-runtime/owner-scoped-documents';
import { ownerJson } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
function jsonError(status: number, code: string, message: string, headers?: Headers): NextResponse {
return NextResponse.json({ error: { code, message } }, { status, headers });
}
// POST /api/folders/members
export async function POST(req: NextRequest) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
let body: unknown;
try {
body = await req.json();
} catch {
return jsonError(400, 'INVALID_BODY', 'request body must be JSON');
}
const { stageId, folderId } = (body ?? {}) as { stageId?: unknown; folderId?: unknown };
if (typeof stageId !== 'string' || stageId.length === 0) {
return jsonError(400, 'MISSING_STAGE_ID', 'stageId must be a non-empty string');
}
if (folderId !== null && (typeof folderId !== 'string' || folderId.length === 0)) {
return jsonError(400, 'INVALID_FOLDER_ID', 'folderId must be a non-empty string or null');
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
try {
const store = (await getOwnerScopedDocumentStore(ownerId)) as unknown as DocumentFolderStore;
const ok = await store.setStageFolder(stageId, folderId);
if (!ok) {
return jsonError(404, 'FOLDER_NOT_FOUND', 'folder not found', responseHeaders);
}
return ownerJson({ ok: true }, 200, responseHeaders);
} catch (error) {
console.error(
`[Folders] Failed to set membership [owner=${ownerId}, stage=${stageId}]:`,
error,
);
return jsonError(
500,
'FOLDER_MEMBER_FAILED',
'Failed to set folder membership',
responseHeaders,
);
}
});
}
+117
View File
@@ -0,0 +1,117 @@
/**
* GET/POST /api/folders — the workbench's course-folder API (server-side
* counterpart of the local `lib/utils/stage-storage.ts` folder API; the
* configured runtime routes the seam through these handlers instead of the
* Dexie tables).
*
* Every handler is owner-scoped exactly like the other workbench routes: the
* owner resolves from the anonymous cookie (`withRequestOwnerId`) and is never
* a request parameter, and all reads and writes go through the owner-bound
* document store (`getOwnerScopedDocumentStore`), the same seam the runner
* binds for the stage tools. A folder created here is visible to this browser
* and to nobody else.
*
* Name validation is the shared display-width rule (full-width = 2, half-width
* = 1, ≤ 40) from `lib/utils/folder-name-validation.ts` — the same module the
* client dialogs import, so the two ends cannot drift.
*
* The configured runtime gates the whole family (see `app/api/stages/route.ts`):
* off, or on without a DATABASE_URL, answers the same plain 404.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import type { DocumentFolder, DocumentFolderStore } from '@openmaic/storage';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { getOwnerScopedDocumentStore } from '@/lib/server/agent-runtime/owner-scoped-documents';
import { ownerJson } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
import { folderNameErrorResponse } from '@/lib/server/folder-name-errors';
import { createFolderForOwner, listFoldersForOwner } from '@/lib/server/folder-persistence';
import { validateFolderName } from '@/lib/utils/folder-name-validation';
export const runtime = 'nodejs';
/**
* The wire shape is the reference's `FolderItem`: the owner id the folder
* belongs to (its `userKey`) plus the stored row. The owner-bound store is
* partitioned by `owner_id`, which is exactly the reference's `user_key`, so
* the request owner IS the folder's user key.
*/
function folderResponse(folder: DocumentFolder, userKey: string) {
return { ...folder, userKey };
}
/** The reference's error envelope: `{ error: { code, message } }`. */
function jsonError(status: number, code: string, message: string, headers?: Headers): NextResponse {
return NextResponse.json({ error: { code, message } }, { status, headers });
}
// GET /api/folders — list the caller's folders, ordered by `order` asc.
export async function GET(req: NextRequest) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
try {
const store = (await getOwnerScopedDocumentStore(ownerId)) as unknown as DocumentFolderStore;
const folders = await listFoldersForOwner(store);
return ownerJson(
{ folders: folders.map((folder) => folderResponse(folder, ownerId)) },
200,
responseHeaders,
);
} catch (error) {
console.error(`[Folders] Failed to list [owner=${ownerId}]:`, error);
return jsonError(500, 'FOLDER_LIST_FAILED', 'Failed to list folders', responseHeaders);
}
});
}
// POST /api/folders — create a folder { name }.
//
// Validation happens before owner resolution, like the stage routes: a
// malformed body must not mint an anonymous cookie partition for a request
// that will not proceed.
export async function POST(req: NextRequest) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
let body: unknown;
try {
body = await req.json();
} catch {
return jsonError(400, 'INVALID_BODY', 'request body must be JSON');
}
const name = (body as { name?: unknown })?.name;
if (typeof name !== 'string') {
return jsonError(400, 'FOLDER_NAME_INVALID', 'name must be a string');
}
const trimmed = name.trim();
const check = validateFolderName(trimmed);
if (!check.ok) {
return jsonError(
400,
check.kind === 'empty' ? 'FOLDER_NAME_EMPTY' : 'FOLDER_NAME_TOO_LONG',
check.kind === 'empty' ? 'folder name must not be empty' : 'folder name is too long',
);
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
try {
const store = (await getOwnerScopedDocumentStore(ownerId)) as unknown as DocumentFolderStore;
const { folder } = await createFolderForOwner(store, trimmed, { reuseExisting: false });
return ownerJson({ folder: folderResponse(folder, ownerId) }, 200, responseHeaders);
} catch (error) {
// The storage re-checks duplicates + count limit inside its owner-scoped
// transaction; map its refusals onto the same machine codes the
// pre-checks use.
const nameError = folderNameErrorResponse(error);
if (nameError) {
for (const [key, value] of responseHeaders) nameError.headers.append(key, value);
return nameError;
}
console.error(`[Folders] Failed to create [owner=${ownerId}]:`, error);
return jsonError(500, 'FOLDER_CREATE_FAILED', 'Failed to create folder', responseHeaders);
}
});
}
+1 -2
View File
@@ -28,7 +28,6 @@ import { resolveModelFromRequest } from '@/lib/server/resolve-model';
import { resolveVocationalActive } from '@/lib/config/feature-flags';
import { MAX_VISION_IMAGES } from '@/lib/constants/generation';
import { sortDocumentImagesForVision } from '@/lib/document/bundle';
import { DEFAULT_INGEST_AWAIT_TIMEOUT_MS } from '@/lib/document/extract-source';
import {
resolveVisionImagesForPrompt,
type VisionPromptImage,
@@ -47,7 +46,7 @@ export const maxDuration = 300;
* sequentially: when the budget expires the phase STOPS and generation
* proceeds with whatever resolved so far (degrade, never fail).
*/
const VISION_RESOLUTION_BUDGET_MS = DEFAULT_INGEST_AWAIT_TIMEOUT_MS;
const VISION_RESOLUTION_BUDGET_MS = 15_000;
/**
* Consecutive-failure fuse for the resolve-with-refill loop: after this many
+44
View File
@@ -0,0 +1,44 @@
/**
* GET /api/materials/[id]?sessionId= — one owned session's material, in the
* same public projection the list and the agent's `list_materials` tool use.
*
* Materials are session-scoped; the client names the session and the session's
* owner row is the authorization. A foreign or missing session, and a material
* id that does not exist or belongs to another session, all answer the same
* plain 404 (no existence oracle).
*
* Deletion is deliberately not exposed: the session-material store from the
* materials slice has no delete operation, and this slice adds no persistence
* — a later slice grows deletion on the store, then the route.
*/
import type { NextRequest } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { apiError } from '@/lib/server/api-response';
import {
getSessionMaterial,
publicMaterialView,
resolveOwnedSession,
} from '@/lib/server/agent-runtime/session-materials';
import { ownerJson, ownerNotFound } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string }> };
export async function GET(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
const sessionId = new URL(req.url).searchParams.get('sessionId')?.trim();
if (!sessionId) return apiError('MISSING_REQUIRED_FIELD', 400, 'sessionId is required');
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const session = await resolveOwnedSession(sessionId, ownerId);
if (!session) return ownerNotFound(responseHeaders);
const { id } = await params;
const material = await getSessionMaterial(sessionId, id);
if (!material) return ownerNotFound(responseHeaders);
return ownerJson({ material: publicMaterialView(material) }, 200, responseHeaders);
});
}
+416
View File
@@ -0,0 +1,416 @@
/**
* /api/materials — the workbench's material list and upload face.
*
* The uploader (`uploadWorkbenchMaterial` in `lib/workbench/session-store.ts`)
* is OWNER-scoped: it posts a file with no session id and expects the flat
* `{ materialId, originalName, bytes, mime, extraction }` 201 view. This route
* implements the reference's upload contract on the owner-scoped material
* library (`lib/persistence/owner-materials.ts`) with the neutral local
* material byte store.
*
* ## Upload contract (the reference's)
*
* - `content-type` is the MIME type; it is normalized and validated against
* the workbench policy — an unsupported type answers 415.
* - `x-material-filename` is the display name (required).
* - Size caps are per class: media (audio/video) uploads cap at
* `maxUploadBytes`, documents/images at `min(maxDocumentBytes,
* maxUploadBytes)` — both 413 when exceeded, checked on the declared
* `content-length` AND on the streamed body.
* - Lifecycle: the upload reclaims crashed `uploading` leftovers older than
* 24 hours (their byte objects first, then the reservations), reserves a
* quota-checked `uploading` row (429 when the owner's count or byte quota is
* exceeded), streams the bytes through a sha256 meter into the byte store,
* and finalizes the row to `ready` with the digest. Failures
* abandon the reservation; crash leftovers are reclaimed by the next
* upload's 24-hour sweep.
* - Every response echoes the `x-request-id` header so the uploader can pair
* a failure with its log line.
*
* The configured runtime gates the family (the workbench is agent-runtime
* territory): off, or on without a DATABASE_URL, answers the same plain 404.
*/
import { createHash, randomUUID } from 'node:crypto';
import { basename } from 'node:path';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { createMaterialId } from '@openmaic/storage';
import type { ConnectableQueryable } from '@openmaic/storage/server/reference';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { apiError } from '@/lib/server/api-response';
import { agentRuntimeConfig } from '@/lib/server/agent-runtime/config';
import { ownerJson, ownerNotFound } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
import {
resolveOwnedSession,
listSessionMaterials,
publicMaterialView,
} from '@/lib/server/agent-runtime/session-materials';
import {
abandonOwnerMaterial,
finalizeOwnerMaterial,
MaterialQuotaExceededError,
publicMaterial,
reclaimStaleOwnerMaterialUploads,
registerOwnerMaterial,
} from '@/lib/persistence/owner-materials';
import { getServerPersistenceProvider } from '@/lib/persistence/server-provider';
import { getMaterialByteStore } from '@/lib/server/materials/bytes';
import {
isWorkbenchMaterialMime,
MEDIA_MIME_TYPES,
normalizeWorkbenchMaterialMime,
} from '@/lib/workbench/material-upload-policy';
export const runtime = 'nodejs';
const DOCUMENT_UPLOAD_LIMIT = Math.min(
agentRuntimeConfig.maxDocumentBytes,
agentRuntimeConfig.maxUploadBytes,
);
const MEDIA_MIME_SET = new Set<string>(MEDIA_MIME_TYPES);
/** The store's keyset-paging ceiling (default 50, capped at 200). */
export const MAX_MATERIAL_LIST_LIMIT = 200;
class MaterialPayloadTooLarge extends Error {}
/** The `x-material-filename` header, sanitized to a bare file name. */
function materialFilename(req: NextRequest): string | null {
const raw = req.headers.get('x-material-filename');
if (!raw) return null;
let decoded = raw;
try {
decoded = decodeURIComponent(raw);
} catch {
// Preserve a plain header value; malformed percent escapes are not paths.
}
const name = basename(decoded.replace(/\\/g, '/')).trim().slice(0, 512);
return name || null;
}
function materialUploadRequestId(req: NextRequest): string {
const upstream = req.headers.get('x-request-id')?.trim();
return upstream && /^[A-Za-z0-9._:-]{1,128}$/.test(upstream) ? upstream : randomUUID();
}
function parseLimit(raw: string | null): { limit?: number } | { invalid: true } {
if (raw === null || raw === '') return {};
if (!/^\d+$/.test(raw)) return { invalid: true };
const parsed = Number(raw);
if (parsed < 1 || parsed > MAX_MATERIAL_LIST_LIMIT) return { invalid: true };
return { limit: parsed };
}
// GET /api/materials?sessionId=&limit=&before= — list one owned session's
// materials, newest first, keyset-paged (the agent-tools list surface).
export async function GET(req: NextRequest) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
const url = new URL(req.url);
const sessionId = url.searchParams.get('sessionId')?.trim();
if (!sessionId) return apiError('MISSING_REQUIRED_FIELD', 400, 'sessionId is required');
const parsedLimit = parseLimit(url.searchParams.get('limit'));
if ('invalid' in parsedLimit) {
return apiError(
'INVALID_REQUEST',
400,
`limit must be an integer between 1 and ${MAX_MATERIAL_LIST_LIMIT}`,
);
}
const before = url.searchParams.get('before')?.trim() || undefined;
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const session = await resolveOwnedSession(sessionId, ownerId);
if (!session) return ownerNotFound(responseHeaders);
const materials = await listSessionMaterials(sessionId, {
...(parsedLimit.limit === undefined ? {} : { limit: parsedLimit.limit }),
...(before ? { before } : {}),
});
return ownerJson(
{ materials: materials.map((material) => publicMaterialView(material)) },
200,
responseHeaders,
);
});
}
// POST /api/materials — upload a source file into the caller's durable
// material library. The raw bytes ride the body; `content-type` is the MIME
// type and `x-material-filename` the display name.
export async function POST(req: NextRequest) {
const requestId = materialUploadRequestId(req);
const startedAt = Date.now();
let phase = 'feature_gate';
let materialId: string | undefined;
let mime = '';
let declaredBytes = 0;
let receivedBytes = 0;
let failureLogged = false;
const context = (extra: Record<string, unknown> = {}) => ({
requestId,
phase,
...(materialId ? { materialId } : {}),
...(mime ? { mime } : {}),
declaredBytes,
receivedBytes,
durationMs: Date.now() - startedAt,
...extra,
});
const reject = (response: Response, reason: string, headers: Headers) => {
console.warn('material upload rejected', context({ status: response.status, reason }));
response.headers.set('x-request-id', requestId);
for (const [key, value] of headers) response.headers.append(key, value);
return response;
};
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
try {
phase = 'validate_request';
const rawMime = (req.headers.get('content-type') ?? '').split(';', 1)[0];
mime = normalizeWorkbenchMaterialMime(rawMime);
if (!isWorkbenchMaterialMime(mime)) {
return reject(
apiError(
'INVALID_REQUEST',
415,
`unsupported material mime type: ${mime || '(missing)'}`,
),
'unsupported_mime',
responseHeaders,
);
}
const uploadLimit = MEDIA_MIME_SET.has(mime)
? agentRuntimeConfig.maxUploadBytes
: DOCUMENT_UPLOAD_LIMIT;
declaredBytes = Number(req.headers.get('content-length') ?? 0);
if (Number.isFinite(declaredBytes) && declaredBytes > uploadLimit) {
return reject(
apiError('INVALID_REQUEST', 413, `upload exceeds ${uploadLimit} bytes`),
'declared_body_too_large',
responseHeaders,
);
}
if (!req.body) {
return reject(
apiError('INVALID_REQUEST', 400, 'empty body'),
'empty_body',
responseHeaders,
);
}
const originalName = materialFilename(req);
if (!originalName) {
return reject(
apiError('MISSING_REQUIRED_FIELD', 400, 'x-material-filename header is required'),
'missing_filename',
responseHeaders,
);
}
const createdMaterialId = createMaterialId();
materialId = createdMaterialId;
const ossKey = `materials/${ownerId}/${createdMaterialId}`;
const provider = await getServerPersistenceProvider(process.env.DATABASE_URL ?? '');
const byteStore = getMaterialByteStore();
// Browsers send Content-Length for a File body. When an intermediary
// strips it, reserve the per-file maximum so an unmeasured stream can
// never bypass the owner byte quota; finalize shrinks the reservation to
// its actual size.
const reservedBytes =
Number.isFinite(declaredBytes) && declaredBytes > 0 ? declaredBytes : uploadLimit;
// Reclaim uploads that crashed before finalize and are older than the
// 24-hour horizon. Each reservation's byte object is removed first; the reservation is
// deleted only after that, so a failure here keeps the reservation for
// the next pass instead of losing the pointer to its bytes.
phase = 'reclaim_stale_uploads';
await reclaimStaleOwnerMaterialUploads(
provider.pool as unknown as ConnectableQueryable,
ownerId,
async (objectKey) => {
try {
await byteStore.delete(objectKey);
} catch (error) {
console.warn(
'material stale byte deletion failed; keeping its reservation for the next pass',
context({ objectKey }),
error,
);
throw error;
}
},
).catch((error) => {
console.warn(
'material stale-upload reclaim failed; retrying on the next upload',
context(),
error,
);
});
phase = 'reserve_material';
try {
await registerOwnerMaterial(
provider.pool as unknown as ConnectableQueryable,
{
id: createdMaterialId,
ownerId,
kind: 'source',
mime,
bytes: reservedBytes,
originalName,
ossKey,
extraction: { status: 'idle' },
},
{
maxCount: agentRuntimeConfig.maxMaterialsPerOwner,
maxTotalBytes: agentRuntimeConfig.maxMaterialBytesPerOwner,
},
);
} catch (error) {
if (error instanceof MaterialQuotaExceededError) {
return reject(
apiError('INVALID_REQUEST', 429, error.message),
'quota_exceeded',
responseHeaders,
);
}
throw error;
}
phase = 'store_bytes';
// Read the body through a sha256 meter, enforcing the per-class cap on
// the streamed size (an unmeasured stream cannot bypass the cap).
let bytes: Buffer;
try {
bytes = await readMeteredBody(req, uploadLimit);
receivedBytes = bytes.byteLength;
} catch (error) {
if (error instanceof MaterialPayloadTooLarge) {
await abandonOwnerMaterial(
provider.pool as unknown as ConnectableQueryable,
createdMaterialId,
).catch(() => undefined);
return reject(
apiError('INVALID_REQUEST', 413, `upload exceeds ${uploadLimit} bytes`),
'streamed_body_too_large',
responseHeaders,
);
}
failureLogged = true;
await abandonOwnerMaterial(
provider.pool as unknown as ConnectableQueryable,
createdMaterialId,
).catch(() => undefined);
throw error;
}
if (bytes.byteLength === 0) {
await abandonOwnerMaterial(
provider.pool as unknown as ConnectableQueryable,
createdMaterialId,
).catch(() => undefined);
return reject(
apiError('INVALID_REQUEST', 400, 'empty body'),
'empty_stream',
responseHeaders,
);
}
if (bytes.byteLength > reservedBytes) {
await abandonOwnerMaterial(
provider.pool as unknown as ConnectableQueryable,
createdMaterialId,
).catch(() => undefined);
return reject(
apiError('INVALID_REQUEST', 413, 'upload body exceeds its declared content length'),
'declared_length_mismatch',
responseHeaders,
);
}
// The object key is recorded by the reservation before bytes are stored.
// A crash after the write therefore leaves a durable pointer for the
// 24-hour reclaim, preserving delete-before-reservation-removal order.
const hash = createHash('sha256').update(bytes).digest('hex');
let bytesStored = false;
try {
await byteStore.put(ossKey, bytes, mime);
bytesStored = true;
const row = await finalizeOwnerMaterial(
provider.pool as unknown as ConnectableQueryable,
createdMaterialId,
bytes.byteLength,
hash,
);
const view = publicMaterial(row);
const res = NextResponse.json(
{
materialId: view.materialId,
originalName: view.originalName,
bytes: view.bytes,
mime: view.mime,
extraction: view.extraction,
},
{ status: 201 },
);
res.headers.set('x-request-id', requestId);
for (const [key, value] of responseHeaders) res.headers.append(key, value);
console.info('material upload completed', context({ status: 201 }));
return res;
} catch (error) {
let bytesDeleted = !bytesStored;
if (bytesStored) {
try {
await byteStore.delete(ossKey);
bytesDeleted = true;
} catch (cleanupError) {
console.warn(
'material byte cleanup failed; keeping its reservation for stale reclaim',
context({ objectKey: ossKey }),
cleanupError,
);
}
}
if (bytesDeleted) {
await abandonOwnerMaterial(
provider.pool as unknown as ConnectableQueryable,
createdMaterialId,
).catch(() => undefined);
}
throw error;
}
} catch (error) {
if (!failureLogged) console.error('material upload failed', context({ status: 500 }), error);
const res = apiError('INTERNAL_ERROR', 500, 'material upload failed');
res.headers.set('x-request-id', requestId);
for (const [key, value] of responseHeaders) res.headers.append(key, value);
return res;
}
});
}
/** Read the body up to `limit` bytes; throws {@link MaterialPayloadTooLarge} over the cap. */
async function readMeteredBody(req: NextRequest, limit: number): Promise<Buffer> {
const chunks: Buffer[] = [];
let total = 0;
const reader = req.body!.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const buffer = Buffer.from(value);
total += buffer.byteLength;
if (total > limit) {
await reader.cancel().catch(() => undefined);
throw new MaterialPayloadTooLarge();
}
chunks.push(buffer);
}
return Buffer.concat(chunks);
}
+70 -50
View File
@@ -9,28 +9,25 @@ import {
import { validateAppScene, validateAppStage } from '@/lib/document-store/validators';
import { resolveAssetCollectionGraceMs } from '@/lib/persistence/asset-collection-grace';
import {
decideDocumentAccess,
parseDocumentAction,
type DocumentAccess,
} from '@/lib/persistence/document-access';
import { createOwnerBoundDocumentStore } from '@/lib/persistence/owner-bound-document-store';
import { authenticatePersistenceRequest } from '@/lib/persistence/server-auth';
import {
getServerPersistenceProvider,
type PersistencePoolFactory,
} from '@/lib/persistence/server-provider';
import { readStageMeta } from '@/lib/persistence/stage-meta';
import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
const ROUTE_PREFIX = '/api/persistence';
interface PersistenceHandlerState {
connectionString?: string;
handlerPromise?: Promise<RequestListener>;
}
const HANDLER_STATE_KEY = Symbol.for('openmaic.persistence-route.handler');
const globalState = globalThis as typeof globalThis & {
[key: symbol]: PersistenceHandlerState | undefined;
};
const handlerState = (globalState[HANDLER_STATE_KEY] ??= {});
function jsonError(status: number, code: string, message: string): Response {
return Response.json({ error: { code, message } }, { status });
}
@@ -81,19 +78,25 @@ function indirectEgressWithinGrace(
async function createPersistenceHandler(
connectionString: string,
ownerId: string,
access: DocumentAccess,
poolFactory?: PersistencePoolFactory,
): Promise<RequestListener> {
const { runtimeStore, documentStore, assetStore } = await getServerPersistenceProvider(
const { pool, runtimeStore, assetStore } = await getServerPersistenceProvider(
connectionString,
poolFactory,
);
// The asset contract requires a server-derived principal; this development
// authenticator instead takes the partition key from a client-supplied header.
// Cross-principal isolation is therefore not in force: asset bytes are as
// reachable as documents and runtime records under this authenticator. Before
// asset routes carry anything that matters, production must replace
// authenticatePersistenceRequest with real session verification. See
// lib/persistence/server-auth.ts for the token's limits.
const documentStore = createOwnerBoundDocumentStore({
pool,
ownerId,
validateScene: validateAppScene,
validateStage: validateAppStage,
});
// Runtime and asset requests retain the development authenticator, which
// takes their partition key from a client-supplied header. Document requests
// use the server-resolved anonymous owner below. Before runtime or asset
// routes carry production data, their authenticator must also be replaced
// with real session verification.
// Reclamation is not scheduled from here, and must not be: a route module
// has no once-per-process guarantee and no shutdown hook. AssetCollector
// runs from instrumentation.ts instead, over the byte store this same
@@ -103,10 +106,13 @@ async function createPersistenceHandler(
configuredAssetByteEgress(process.env.ASSET_BYTE_EGRESS),
);
return createStorageHttpHandler(runtimeStore, documentStore, {
authenticate: authenticatePersistenceRequest,
authenticate: async (request) =>
request.url?.startsWith('/documents')
? { learnerKey: ownerId }
: authenticatePersistenceRequest(request),
authorizeMerge: async () => false,
authorizeAdmin: async () => false,
authorizeDocuments: async () => true,
authorizeDocuments: async () => access === 'allow',
validateScene: validateAppScene,
validateStage: validateAppStage,
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
@@ -115,26 +121,9 @@ async function createPersistenceHandler(
});
}
function getPersistenceHandler(
connectionString: string,
poolFactory?: PersistencePoolFactory,
): Promise<RequestListener> {
if (handlerState.handlerPromise && handlerState.connectionString === connectionString) {
return handlerState.handlerPromise;
}
handlerState.connectionString = connectionString;
const initialization = createPersistenceHandler(connectionString, poolFactory).catch((error) => {
// Do not poison the singleton with a rejected promise. createPersistenceHandler
// has already closed its failed pool, and the next request gets a clean retry.
if (handlerState.handlerPromise === initialization) {
handlerState.handlerPromise = undefined;
handlerState.connectionString = undefined;
}
throw error;
});
handlerState.handlerPromise = initialization;
return initialization;
function routeRelativePath(request: Request): string {
const pathname = new URL(request.url).pathname;
return pathname.startsWith(ROUTE_PREFIX) ? pathname.slice(ROUTE_PREFIX.length) || '/' : pathname;
}
function nodeRequest(request: Request): IncomingMessage {
@@ -291,15 +280,46 @@ export async function handlePersistenceRequest(
);
}
try {
return await runNodeHandler(
await getPersistenceHandler(connectionString, deps.poolFactory),
request,
);
} catch (error) {
console.error('Embedded persistence route initialization failed', error);
return jsonError(500, 'PERSISTENCE_INIT_FAILED', 'server persistence initialization failed');
}
return withRequestOwnerId(request, async (ownerId, responseHeaders) => {
try {
const path = routeRelativePath(request);
const action = parseDocumentAction(request.method, path);
let access: DocumentAccess = 'allow';
if (path === '/documents' || path.startsWith('/documents/')) {
const { pool } = await getServerPersistenceProvider(connectionString, deps.poolFactory);
const queryable = pool;
access = await decideDocumentAccess(
action,
ownerId,
(stageId) => readStageMeta(queryable, stageId),
(stageId) =>
pool
.query('SELECT 1 FROM document_stages WHERE id = $1', [stageId])
.then((result) => result.rows.length > 0),
(stageId) => readStageMeta(queryable, stageId),
);
}
const response =
access === 'not-found'
? jsonError(404, 'DOCUMENT_NOT_FOUND', '@openmaic/storage: document not found')
: await runNodeHandler(
await createPersistenceHandler(connectionString, ownerId, access, deps.poolFactory),
request,
);
for (const [name, value] of responseHeaders.entries()) response.headers.append(name, value);
return response;
} catch (error) {
console.error('Embedded persistence route initialization failed', error);
const response = jsonError(
500,
'PERSISTENCE_INIT_FAILED',
'server persistence initialization failed',
);
for (const [name, value] of responseHeaders.entries()) response.headers.append(name, value);
return response;
}
});
}
export const GET = (request: Request) => handlePersistenceRequest(request);
+49
View File
@@ -0,0 +1,49 @@
/** Download the OpenMAIC skill, a builtin agent skill, or one owner skill as zip. */
import type { NextRequest } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { findUserSkill } from '@/lib/server/agent-runtime/user-skills';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
import {
buildBuiltinSkillZip,
buildOpenClawSkillZip,
buildUserSkillZip,
isSafeSkillId,
} from '@/lib/server/skill-export';
export const runtime = 'nodejs';
function zipResponse(id: string, zip: Buffer, headers = new Headers()): Response {
headers.set('Content-Type', 'application/zip');
headers.set('Content-Disposition', `attachment; filename="${id}-skill.zip"`);
headers.set('Cache-Control', 'no-store');
return new Response(new Uint8Array(zip), { headers });
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
const { id } = await params;
if (!isSafeSkillId(id)) return new Response('Invalid skill id', { status: 400 });
if (id === 'openmaic') {
const zip = await buildOpenClawSkillZip();
return zip ? zipResponse(id, zip) : new Response('Not found', { status: 404 });
}
const builtin = await buildBuiltinSkillZip(id);
if (builtin) return zipResponse(id, builtin);
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const skill = await findUserSkill(id, ownerId);
if (!skill) return new Response('Not found', { status: 404, headers: responseHeaders });
return zipResponse(
id,
await buildUserSkillZip({
name: skill.name,
title: skill.title,
description: skill.description,
content: skill.content,
}),
responseHeaders,
);
});
}
+80
View File
@@ -0,0 +1,80 @@
/**
* GET /api/stage-meta/[stageId] — the per-viewer facts a document does not carry
* (the reference's stage-meta sidecar, ported onto this branch's owner model).
*
* The document seam returns a DOCUMENT: stage + scenes + outline, and nothing
* about who is asking. The classroom branches on exactly that — `isOwner`
* decides read-only vs editable — so the split is explicit: the document
* carries content, this sidecar carries tenancy, and the client fetches both
* in parallel.
*
* ## Everything here is fail-closed on the tombstone
*
* `resolveStageAccess` answers `null` for a deleted course exactly as it does
* for one that never existed, so a deleted course 404s here too. This endpoint
* is unauthenticated-friendly (any visitor may ask about any id), so if it
* leaked `{isPublic: true}` for a tombstoned course it would be a public oracle
* for "this course used to exist".
*
* ## No `ownerId` in the response, ever
*
* `isOwner` is a boolean derived server-side. Returning the owner's identity
* key would hand every visitor a stable cross-course identifier for the author.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { resolveStageAccess } from '@/lib/server/stage-access';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
// Per-viewer and mutable on every publish/unpublish/delete: this response must
// never be cached, by Next or by anything in front of it.
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
type Params = { params: Promise<{ stageId: string }> };
export async function GET(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { stageId } = await params;
try {
const access = await resolveStageAccess(stageId);
// Absent or tombstoned — indistinguishable, deliberately.
if (!access) {
return NextResponse.json({ error: 'not_found' }, { status: 404, headers: responseHeaders });
}
// Identity comparison, and nothing else: this boolean is the client's
// ONLY owner signal, so a `true` here must mean every write through the
// owner-bound store will be accepted (the store re-checks the owner
// scope inside its write transactions).
const isOwner = access.ownerId === ownerId;
return NextResponse.json(
{
isOwner,
isPublic: access.isPublic,
publishedAt: access.publishedAt,
generationComplete: access.generationComplete,
// Which layer answered. Diagnostic only — the client must not branch
// on it.
source: access.source,
},
{ status: 200, headers: responseHeaders },
);
} catch (error) {
console.error('Failed to resolve stage meta', {
stageId,
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'internal_error' },
{ status: 500, headers: responseHeaders },
);
}
});
}
+152
View File
@@ -0,0 +1,152 @@
/**
* GET /api/stages/[id]/freshness — volatile freshness SSE for one course (the
* reference's `stages/:id/freshness`, ported onto the owner-bound store).
*
* The workbench's push side: when the stage's revision moves, this stream
* sends the client one frame carrying the current `rev`; the client reacts by
* pulling the manifest and re-fetching only the scenes that changed.
*
* The reference woke this stream from a DB trigger's NOTIFY; the upstream
* storage package exposes no LISTEN/NOTIFY, so this stream POLLS the
* owner-bound store for the stage's trigger-maintained revision (the same
* correctness mechanism as `app/api/agent/owner-events/route.ts`). A frame is
* emitted when the rev moves; the first frame is sent on connect. `rev` is the
* per-stage monotonic revision the manifest exposes, so a frame that arrives
* always reflects the same signal the manifest serves.
*
* Degradation is by design: this stream is a pure optimization. A dead or
* missing stream only costs latency — the client's low-frequency fallback
* poll still converges. Conventions mirror the sibling SSE routes: a 25s
* heartbeat comment frame keeps intermediaries from ending the stream, an
* explicit `retry:` hint sets the browser's reconnect delay, the stream never
* closes on a terminal state, and a broken socket is the client's
* EventSource problem, not this route's.
*/
import type { NextRequest } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { resolveRequestOwnerId } from '@/lib/server/agent-runtime/owner';
import { getOwnerScopedDocumentStore } from '@/lib/server/agent-runtime/owner-scoped-documents';
import { ownerNotFound } from '@/lib/server/agent-runtime/route-response';
export const runtime = 'nodejs';
// Self-hosted `next start` ignores maxDuration; Vercel's adapter can still use
// it. The 25s heartbeat keeps this sparse stream active through idle periods.
export const maxDuration = 300;
/** How often the stream re-checks the stage's revision. */
export const STAGE_FRESHNESS_POLL_INTERVAL_MS = 5_000;
/** Same ceiling as the sibling SSE streams. */
export const STAGE_FRESHNESS_HEARTBEAT_MS = 25_000;
/** Browser reconnect delay, sent as the SSE `retry:` field. */
export const STAGE_FRESHNESS_RETRY_MS = 3_000;
type Params = { params: Promise<{ id: string }> };
export async function GET(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
const responseHeaders = new Headers();
const ownerId = resolveRequestOwnerId(req, responseHeaders);
const { id: stageId } = await params;
// Existence-gated, exactly like the manifest route: the owner-bound store
// reads a foreign or missing stage as absent, and the 404 carries the
// owner cookie the same way every response of this family does.
const store = await getOwnerScopedDocumentStore(ownerId);
const initial = await store.readFreshnessManifest(stageId);
if (!initial) return ownerNotFound(responseHeaders);
const encoder = new TextEncoder();
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let closed = false;
const clearTimers = () => {
if (pollTimer) clearTimeout(pollTimer);
if (heartbeatTimer) clearInterval(heartbeatTimer);
pollTimer = null;
heartbeatTimer = null;
};
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const write = (chunk: string) => {
if (closed) return false;
try {
controller.enqueue(encoder.encode(chunk));
return true;
} catch {
// A broken socket is not guaranteed to invoke cancel() in every
// runtime; stop the timers the moment enqueue proves it closed.
closed = true;
clearTimers();
return false;
}
};
let lastRev = 0;
// The frame carries the current rev so a client that wants to skip the
// manifest round-trip when nothing changed can, but the contract is
// "pull the manifest on every frame" — rev is informational, never
// authoritative. A read failure sends rev 0 (never a terminal state: a
// stage can always be written again), matching the reference.
const emitFreshness = async () => {
if (closed) return;
let rev = 0;
try {
const manifest = await store.readFreshnessManifest(stageId);
rev = manifest ? manifest.rev : 0;
} catch {
// Fall through to the rev-0 frame.
}
if (rev === lastRev) return;
lastRev = rev;
write(
`event: stage_freshness\ndata: ${JSON.stringify({
type: 'stage_freshness',
stageId,
rev,
})}\n\n`,
);
};
// Chained setTimeout: the next poll is scheduled only after the previous
// read settles, so polls can never overlap.
const schedulePoll = () => {
if (closed) return;
pollTimer = setTimeout(async () => {
try {
await emitFreshness();
} finally {
schedulePoll();
}
}, STAGE_FRESHNESS_POLL_INTERVAL_MS);
};
// Reconnect hint + the first frame of the stream.
write(`retry: ${STAGE_FRESHNESS_RETRY_MS}\n\n`);
await emitFreshness();
heartbeatTimer = setInterval(() => {
if (closed) return;
write(': ping\n\n');
}, STAGE_FRESHNESS_HEARTBEAT_MS);
schedulePoll();
},
cancel() {
closed = true;
clearTimers();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
...Object.fromEntries(responseHeaders),
},
});
}
@@ -0,0 +1,60 @@
/**
* POST /api/stages/[id]/generation-complete
*
* Monotonically marks an existing stage outline as generation-complete.
* Owner-only. This route deliberately performs a narrow UPDATE so a stale
* load-time repair cannot overwrite newer classroom content.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { markStageGenerationComplete } from '@/lib/persistence/stage-meta';
import { getStageAccessDb, resolveStageAccess } from '@/lib/server/stage-access';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id: stageId } = await params;
try {
const access = await resolveStageAccess(stageId);
// Absent and tombstoned are the same 404 — the caller must not learn
// that an id used to be a real course, and a deleted course has no
// state left worth repairing.
if (!access) {
return NextResponse.json({ error: 'not_found' }, { status: 404, headers: responseHeaders });
}
// Owner only.
if (access.ownerId !== ownerId) {
return NextResponse.json({ error: 'forbidden' }, { status: 403, headers: responseHeaders });
}
const db = await getStageAccessDb();
const touched = await markStageGenerationComplete(db, stageId);
if (!touched) {
return NextResponse.json({ error: 'not_found' }, { status: 404, headers: responseHeaders });
}
console.info('Stage generation marked complete', { stageId, ownerId });
return NextResponse.json({ ok: true }, { status: 200, headers: responseHeaders });
} catch (error) {
console.error('Failed to mark stage generation complete', {
stageId,
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'internal_error' },
{ status: 500, headers: responseHeaders },
);
}
});
}
+38
View File
@@ -0,0 +1,38 @@
/**
* GET /api/stages/[id]/manifest — freshness manifest for one course (the
* reference's `stages/:id/manifest`, ported onto the owner-bound store).
*
* Returns `{rev, scenes: [{id, order, rev}]}` — the per-stage monotonic
* revision and each scene's own revision, produced by DB triggers on
* `document_stages` / `document_scenes` (provisioned by the storage package's
* schema), so every write seam — HTTP routes, agent tools, jobs, manual SQL —
* moves them without application cooperation. The workbench canvas diffs this
* manifest against what it rendered with and re-fetches only the scenes whose
* rev changed.
*
* Permission boundary is the same as every stage route: the owner-bound store
* reads a foreign or missing stage as absent, and both answer the identical
* 404 (the id is not an existence oracle).
*/
import type { NextRequest } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { getOwnerScopedDocumentStore } from '@/lib/server/agent-runtime/owner-scoped-documents';
import { ownerJson, ownerNotFound } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string }> };
export async function GET(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const store = await getOwnerScopedDocumentStore(ownerId);
const manifest = await store.readFreshnessManifest(id);
if (!manifest) return ownerNotFound(responseHeaders);
return ownerJson(manifest, 200, responseHeaders);
});
}
+68
View File
@@ -0,0 +1,68 @@
/**
* POST /api/stages/[id]/publish — make a document-backed course public.
*
* Owner-only; anonymous owners are refused with the reference's
* `login_required` (a published course is a durable public artifact, so it
* needs a real account, not an anonymous cookie partition).
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { setStagePublished } from '@/lib/persistence/stage-meta';
import { getStageAccessDb, resolveStageAccess } from '@/lib/server/stage-access';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id: stageId } = await params;
try {
if (ownerId.startsWith('anon:')) {
return NextResponse.json(
{ error: 'login_required' },
{ status: 401, headers: responseHeaders },
);
}
const access = await resolveStageAccess(stageId);
if (!access) {
return NextResponse.json({ error: 'not_found' }, { status: 404, headers: responseHeaders });
}
if (access.ownerId !== ownerId) {
return NextResponse.json({ error: 'forbidden' }, { status: 403, headers: responseHeaders });
}
if (access.isPublic) {
return NextResponse.json(
{ success: true, publishedAt: access.publishedAt, name: access.name },
{ status: 200, headers: responseHeaders },
);
}
const publishedAt = Date.now();
const db = await getStageAccessDb();
await setStagePublished(db, stageId, true, publishedAt);
console.info('Stage published', { stageId, ownerId });
return NextResponse.json(
{ success: true, publishedAt, name: access.name },
{ status: 200, headers: responseHeaders },
);
} catch (error) {
console.error('Failed to publish stage', {
stageId,
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'internal_error' },
{ status: 500, headers: responseHeaders },
);
}
});
}
+189
View File
@@ -0,0 +1,189 @@
/**
* /api/stages/[id] — read, rename, save, and delete one owned course document.
*
* Ownership is enforced by the store itself, not by a pre-check: every read
* and write goes through the owner-bound document store, so a foreign or
* missing id answers the identical 404 (the no-existence-oracle posture of
* the agent-runtime routes), and a write into a foreign document is refused
* inside the store's transaction (`persistStage` re-checks the owner scope).
*
* - GET returns the whole document (stage + scenes + outline).
* - PATCH renames the course ({ name }), the reference's update path.
* - PUT saves a whole document ({ stage, scenes, outline? }) — the coarse
* "update stage document" write the UI saves through; the server
* bumps `stage.updatedAt` so the freshness signal sees the change.
* - DELETE removes the course and its cascading child rows.
*
* The configured runtime gates the family (see `app/api/stages/route.ts`):
* off, or on without a DATABASE_URL, answers the same plain 404.
*/
import type { NextRequest } from 'next/server';
import { DocumentNotFoundError, DocumentVersionError, type MaicDocument } from '@openmaic/storage';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { apiError } from '@/lib/server/api-response';
import { getOwnerScopedDocumentStore } from '@/lib/server/agent-runtime/owner-scoped-documents';
import { ownerApiError, ownerJson, ownerNotFound } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
import { STAGE_NAME_MAX_LENGTH } from '@/lib/server/agent-runtime/stage-limits';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string }> };
/** A save refused because the payload is not a structurally valid document. */
function isStoreValidationError(error: unknown): error is Error {
return (
error instanceof Error &&
error.message.startsWith('@openmaic/storage:') &&
!(error instanceof DocumentNotFoundError) &&
!(error instanceof DocumentVersionError)
);
}
/** Map a store save failure onto the route's error surface. */
function mapSaveError(error: unknown, headers: Headers) {
if (error instanceof DocumentNotFoundError) return ownerNotFound(headers);
if (error instanceof DocumentVersionError) {
// A document written by a newer client cannot be saved by this one.
return ownerApiError(
'INVALID_REQUEST',
400,
'document was written by a newer client; reload before saving',
headers,
error.message,
);
}
if (isStoreValidationError(error)) {
return ownerApiError('INVALID_REQUEST', 400, 'invalid stage document', headers, error.message);
}
throw error;
}
// GET /api/stages/[id] — the full document.
export async function GET(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const store = await getOwnerScopedDocumentStore(ownerId);
const document = await store.loadDocument(id);
if (!document) return ownerNotFound(responseHeaders);
return ownerJson(document, 200, responseHeaders);
});
}
// PATCH /api/stages/[id] — rename the course (owner-only).
export async function PATCH(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
let body: unknown;
try {
body = await req.json();
} catch {
return apiError('INVALID_REQUEST', 400, 'invalid JSON body');
}
const rawName = (body as { name?: unknown })?.name;
if (typeof rawName !== 'string' || rawName.trim().length === 0) {
return apiError('INVALID_REQUEST', 400, 'name must be a non-empty string');
}
const name = rawName.trim();
if (name.length > STAGE_NAME_MAX_LENGTH) {
return apiError(
'INVALID_REQUEST',
400,
`name exceeds the ${STAGE_NAME_MAX_LENGTH} character limit`,
);
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const store = await getOwnerScopedDocumentStore(ownerId);
const document = await store.loadDocument(id);
if (!document) return ownerNotFound(responseHeaders);
try {
await store.saveDocument({
...document,
stage: { ...document.stage, name, updatedAt: Date.now() },
});
} catch (error) {
return mapSaveError(error, responseHeaders);
}
return ownerJson({ success: true, name }, 200, responseHeaders);
});
}
// PUT /api/stages/[id] — save a whole document.
export async function PUT(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
let body: unknown;
try {
body = await req.json();
} catch {
return apiError('INVALID_REQUEST', 400, 'invalid JSON body');
}
const candidate = body as {
stage?: { id?: unknown; updatedAt?: unknown };
scenes?: unknown;
};
if (
typeof candidate !== 'object' ||
candidate === null ||
typeof candidate.stage !== 'object' ||
candidate.stage === null ||
typeof candidate.stage.id !== 'string' ||
!Array.isArray(candidate.scenes)
) {
return apiError(
'INVALID_REQUEST',
400,
'request body must be a stage document with `stage` and `scenes`',
);
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
if (candidate.stage!.id !== id) {
return ownerApiError(
'INVALID_REQUEST',
400,
'document stage id does not match the requested stage',
responseHeaders,
);
}
const store = await getOwnerScopedDocumentStore(ownerId);
// Save is existence-gated (the reference's update path is too): PUT
// updates a course that exists; it must not resurrect a deleted one or
// mint a course under a client-chosen id. The owner scope is re-checked
// inside the write transaction, so a foreign id still refuses there.
const existing = await store.loadDocument(id);
if (!existing) return ownerNotFound(responseHeaders);
try {
// The server is authoritative for "last modified": bumping updatedAt
// keeps the manifest/freshness signal accurate for this route's writes.
// The full payload is validated inside the store before anything is
// persisted (invalid stage/scene shapes throw and map to 400 below).
await store.saveDocument({
...(body as MaicDocument),
stage: { ...(body as MaicDocument).stage, updatedAt: Date.now() },
});
} catch (error) {
return mapSaveError(error, responseHeaders);
}
return ownerJson({ success: true }, 200, responseHeaders);
});
}
// DELETE /api/stages/[id] — remove the course and its scenes/outline.
export async function DELETE(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const store = await getOwnerScopedDocumentStore(ownerId);
await store.deleteDocument(id);
return ownerJson({ ok: true }, 200, responseHeaders);
});
}
+81
View File
@@ -0,0 +1,81 @@
/**
* GET /api/stages/[id]/scenes?ids=a,b,c — batch scene read (reference
* `stages/:id/scenes`, ported onto the owner-bound document store).
*
* Returns ONLY the requested scenes, in document (`scene_order`) order — the
* narrow re-fetch half of the workbench's manifest sync: the client diffs
* `GET /api/stages/:id/manifest` against what it rendered with, then asks
* this endpoint for exactly the scene ids whose rev changed. One page commit
* therefore moves one scene instead of the whole document.
*
* Ownership and existence share the store's no-existence-oracle posture: the
* owner-bound store reads a foreign or missing stage as absent, and both
* answer the identical 404. A requested id that does not exist (deleted
* between the manifest read and here) is simply absent from the array.
*
* Ids are bounded: a pathological diff could name every scene, so the request
* is capped at MAX_BATCH_SCENE_IDS; over the cap is a 400 rather than a
* silent truncation (truncating would drop scenes the client believes it
* fetched). The client treats 400 as a failed pass and retries on the next
* trigger.
*/
import type { NextRequest } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { apiError } from '@/lib/server/api-response';
import { getOwnerScopedDocumentStore } from '@/lib/server/agent-runtime/owner-scoped-documents';
import { ownerJson, ownerNotFound } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
/** Upper bound on `ids` — see the file header. */
export const MAX_BATCH_SCENE_IDS = 200;
/**
* Scene ids travel the same wire as stage ids, so they face the same driver
* hazard: a `\0` or a lone surrogate in the query parameter would make an id
* comparison throw at the driver. Such an id can never match a stored scene,
* so dropping it is exact (reference rationale).
*/
const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
function isQueryableSceneId(sceneId: string): boolean {
return !sceneId.includes('\u0000') && !LONE_SURROGATE.test(sceneId);
}
type Params = { params: Promise<{ id: string }> };
export async function GET(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
const rawIds = new URL(req.url).searchParams.get('ids');
const requested = (rawIds ?? '')
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0)
// Dedupe: a repeated id is the same scene; counting it twice would also
// inflate the bound check.
.filter((value, index, all) => all.indexOf(value) === index)
.filter(isQueryableSceneId);
if (requested.length === 0) {
return apiError('INVALID_REQUEST', 400, 'empty_scene_ids');
}
if (requested.length > MAX_BATCH_SCENE_IDS) {
return apiError(
'INVALID_REQUEST',
400,
'too_many_scene_ids',
`limit ${MAX_BATCH_SCENE_IDS}, requested ${requested.length}`,
);
}
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const store = await getOwnerScopedDocumentStore(ownerId);
const document = await store.loadDocument(id);
if (!document) return ownerNotFound(responseHeaders);
const wanted = new Set(requested);
const scenes = document.scenes.filter((scene) => wanted.has(scene.id));
return ownerJson({ scenes }, 200, responseHeaders);
});
}
+45
View File
@@ -0,0 +1,45 @@
/**
* GET /api/stages/[id]/status
*
* Returns the public-state metadata for a stage. Used by the Share menu CTA
* to know whether to show "Publish" or "Already published · Unpublish".
*
* No auth required — any caller who has the stage ID can read its public flag.
*
* Convention: snake_case error codes (e.g. `not_found`, `internal_error`).
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { resolveStageAccess } from '@/lib/server/stage-access';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
const { id } = await params;
try {
const access = await resolveStageAccess(id);
// Tombstoned and never-existed must be indistinguishable: this endpoint is
// unauthenticated, so an answer other than plain 404 would let anyone
// confirm that a given id used to be a real course.
if (!access) {
return NextResponse.json({ error: 'not_found' }, { status: 404 });
}
// Field names are the wire contract; they stay `isPublic` / `publishedAt`
// regardless of which layer answered.
return NextResponse.json({ isPublic: access.isPublic, publishedAt: access.publishedAt });
} catch (error) {
console.error('Failed to fetch stage status', {
stageId: id,
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'internal_error' }, { status: 500 });
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* POST /api/stages/[id]/unpublish — make a document-backed course private.
*
* Owner-only; anonymous owners are refused with the reference's
* `login_required` (same rationale as publish).
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { setStagePublished } from '@/lib/persistence/stage-meta';
import { getStageAccessDb, resolveStageAccess } from '@/lib/server/stage-access';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id: stageId } = await params;
try {
if (ownerId.startsWith('anon:')) {
return NextResponse.json(
{ error: 'login_required' },
{ status: 401, headers: responseHeaders },
);
}
const access = await resolveStageAccess(stageId);
if (!access) {
return NextResponse.json({ error: 'not_found' }, { status: 404, headers: responseHeaders });
}
if (access.ownerId !== ownerId) {
return NextResponse.json({ error: 'forbidden' }, { status: 403, headers: responseHeaders });
}
const db = await getStageAccessDb();
await setStagePublished(db, stageId, false, null);
console.info('Stage unpublished', { stageId, ownerId });
return NextResponse.json({ success: true }, { status: 200, headers: responseHeaders });
} catch (error) {
console.error('Failed to unpublish stage', {
stageId,
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'internal_error' },
{ status: 500, headers: responseHeaders },
);
}
});
}
+116
View File
@@ -0,0 +1,116 @@
/**
* /api/stages — the workbench's course-document index and create face.
*
* Every handler is owner-scoped exactly like the agent tools: the owner
* resolves from the anonymous cookie (`withRequestOwnerId`) and is never a
* request parameter, and all reads and writes go through the owner-bound
* document store (`getOwnerScopedDocumentStore`), the same seam the runner
* binds for the stage tools. A stage created here is visible to this browser
* and to nobody else.
*
* The configured runtime gates the whole family: these routes serve the
* workbench, which is agent-runtime territory, so a runtime that is off OR
* enabled without a DATABASE_URL answers the same plain 404 as the agent
* control-plane routes — never a 500 from a store that cannot connect.
*/
import type { NextRequest } from 'next/server';
import { randomBytes } from 'node:crypto';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import type { AppDocumentOutline } from '@/lib/document-store/persistence-types';
import { apiError } from '@/lib/server/api-response';
import { getOwnerScopedDocumentStore } from '@/lib/server/agent-runtime/owner-scoped-documents';
import { ownerJson } from '@/lib/server/agent-runtime/route-response';
import { STAGE_NAME_MAX_LENGTH } from '@/lib/server/agent-runtime/stage-limits';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';
export const runtime = 'nodejs';
/** Mint a fresh, collision-free course id in the same `stage-` family as the agent tools. */
function createStageId(): string {
return `stage-${randomBytes(9).toString('base64url')}`;
}
// GET /api/stages — list every stage document owned by the caller.
export async function GET(req: NextRequest) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const store = await getOwnerScopedDocumentStore(ownerId);
const stages = await store.listDocuments();
return ownerJson({ stages }, 200, responseHeaders);
});
}
// POST /api/stages — create a stage document shell { name, description? }.
//
// Validation happens before owner resolution, like the agent session routes:
// a malformed body must not mint an anonymous cookie partition for a request
// that will not proceed.
export async function POST(req: NextRequest) {
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
let body: unknown;
try {
body = await req.json();
} catch {
return apiError('INVALID_REQUEST', 400, 'invalid JSON body');
}
if (typeof body !== 'object' || body === null) {
return apiError('INVALID_REQUEST', 400, 'request body must be a JSON object');
}
const { name, description } = body as { name?: unknown; description?: unknown };
if (typeof name !== 'string' || name.trim().length === 0) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'name is required');
}
const trimmedName = name.trim();
if (trimmedName.length > STAGE_NAME_MAX_LENGTH) {
return apiError(
'INVALID_REQUEST',
400,
`name exceeds the ${STAGE_NAME_MAX_LENGTH} character limit`,
);
}
if (description !== undefined && typeof description !== 'string') {
return apiError('INVALID_REQUEST', 400, 'description must be a string when present');
}
const trimmedDescription = description?.trim();
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const id = createStageId();
const now = Date.now();
const outline: AppDocumentOutline = {
outlines: [],
requirement: trimmedName,
generationComplete: false,
createdAt: now,
updatedAt: now,
};
const store = await getOwnerScopedDocumentStore(ownerId);
await store.saveDocument({
stage: {
id,
name: trimmedName,
...(trimmedDescription ? { description: trimmedDescription } : {}),
createdAt: now,
updatedAt: now,
},
scenes: [],
outline,
});
return ownerJson(
{
stage: {
id,
name: trimmedName,
...(trimmedDescription ? { description: trimmedDescription } : {}),
createdAt: now,
updatedAt: now,
sceneCount: 0,
},
},
201,
responseHeaders,
);
});
}
+33
View File
@@ -15,6 +15,8 @@ import { createLogger } from '@/lib/logger';
import { MediaStageProvider } from '@/lib/contexts/media-stage-context';
import { generateMediaForOutlines } from '@/lib/media/media-orchestrator';
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
import { fetchStageMeta } from '@/lib/classroom/stage-meta-client';
import { noteStageOwnership } from '@/lib/classroom/stage-ownership-signal';
import {
applyClassroomStageAndScenes,
defaultClassroomLoadDeps,
@@ -71,6 +73,37 @@ export default function ClassroomDetailPage() {
setLoading,
log,
});
// The stage-meta sidecar resolves the viewer-facing ownership facts the
// document seam does not carry — `isOwner` decides read-only vs editable
// (see `stage-meta-client.ts`). Run it strictly AFTER the load applied
// its defaults so its answer wins, and fire it without blocking the
// render that already happened.
if (isEffectCurrent()) {
void fetchStageMeta(classroomId)
.then((result) => {
if (!isEffectCurrent()) return;
if (result.outcome === 'found') {
noteStageOwnership(classroomId, true, {
isOwner: result.meta.isOwner,
});
useStageStore.getState().setViewerAccess({
isOwner: result.meta.isOwner,
});
} else if (result.outcome === 'unavailable') {
// A silent sidecar is not "this is a stranger's course": record
// the outage so nothing treats `isOwner === false` as a visitor
// conclusion. The edit gate stays on the upstream defaults.
noteStageOwnership(classroomId, false, null);
} else {
// 'absent' — no sidecar row for this id. This classroom also
// serves local-only courses, so the upstream editable default
// stays; the server's owner-scoped writes remain the authority.
noteStageOwnership(classroomId, true, null);
}
})
.catch(() => noteStageOwnership(classroomId, false, null));
}
},
[classroomId, loadFromStorage],
);
+32 -256
View File
@@ -34,22 +34,7 @@ import {
storeImages,
} from '@/lib/utils/image-storage';
import { getCurrentModelConfig } from '@/lib/utils/model-config';
import { isAssetPoolServerBacked } from '@/lib/media/asset-pool-config';
import { getPersistenceRequestHeaders } from '@/lib/persistence/bootstrap';
import { resolveSessionDocumentSources } from '@/lib/document/session-sources';
import {
computeConfigFingerprint,
createExtractionDeduplicator,
fetchExtractionWithCache,
resolveExpectedExtractor,
type ExtractionCacheDomain,
} from '@/lib/document/extraction-cache';
import { DEFAULT_INGEST_AWAIT_TIMEOUT_MS } from '@/lib/document/extract-source';
import {
awaitWithFallback,
materializeBundleImages,
} from '@/lib/document/extraction-materialization';
import { SUPPORTED_MEDIA_MIME_TYPES } from '@/lib/document/mime';
import { MAX_VISION_IMAGES } from '@/lib/constants/generation';
import {
MAX_DOCUMENT_BUNDLE_FILES,
@@ -87,8 +72,6 @@ type ParsedDocumentResponseImage = {
description?: string;
width?: number;
height?: number;
/** Pool asset id of the image bytes (server-backed cache-hit rebuilds). */
assetId?: string;
};
function validateDocumentSources(
@@ -333,13 +316,6 @@ function GenerationPreviewContent() {
// Use a local mutable copy so we can update it after document extraction
let currentSession = generationSession;
// A server-backed asset pool can resolve bytes by allocated asset id; a
// browser-backed (self-deploy) pool cannot, so the client keeps the byte
// transport. The probe is read ONCE per run so a mid-run configuration
// change cannot split the bundle across the two forms (RFC #1153 part 0/1,
// and part 2's imageMapping transport decision rides the same probe).
const serverBacked = isAssetPoolServerBacked();
setError(null);
setCurrentStepIndex(0);
@@ -361,10 +337,6 @@ function GenerationPreviewContent() {
log.debug('=== Generation Preview: Extracting course material bundle ===');
validateDocumentSources(documentSources, t);
const sortedDocumentSources = [...documentSources].sort((a, b) => a.order - b.order);
// K3 (in-run dedupe): sources with the same content digest AND the same
// expected extractor identity share ONE extraction, so two same-byte
// files under different names never both pay for the paid extraction.
const deduplicateExtraction = createExtractionDeduplicator();
const parsedParts = await Promise.all(
sortedDocumentSources.map(async (source): Promise<ParsedDocumentPart> => {
const providerId = source.providerId || currentSession.pdfProviderId;
@@ -379,186 +351,37 @@ function GenerationPreviewContent() {
}
).providerConfig;
const providerConfig = currentSession.pdfProviderConfig || legacySourceConfig;
// The caller-supplied endpoint (fingerprinted into the cache key)
// and the derivation domain (L6: `alidocmind` exists in both the
// document and media registries at the same version, so the page —
// which knows which path a source takes — must discriminate them).
const sourceBaseUrl = providerConfig?.baseUrl?.trim() || undefined;
const sourceDomain: ExtractionCacheDomain =
source.mimeType && SUPPORTED_MEDIA_MIME_TYPES.includes(source.mimeType.toLowerCase())
? 'media'
: 'doc';
// The config fingerprint feeds ONLY the in-run dedupe key below. A
// computation failure (Web Crypto unavailable) must never fail the
// extraction: the source falls back to un-deduped extraction, and
// the cache lookup/write paths recompute the fingerprint under
// their own degrade-to-miss guards (RFC #1153 part 1, M1).
let sourceConfigFingerprint: string | undefined;
try {
sourceConfigFingerprint = await computeConfigFingerprint(sourceBaseUrl);
} catch (error) {
log.warn(
'Config fingerprinting failed; skipping in-run dedupe for this source:',
error,
);
const documentBlob = await loadDocumentBlob(source.storageKey);
if (!(documentBlob instanceof Blob) || documentBlob.size === 0) {
throw new Error(t('generation.courseMaterialLoadFailed'));
}
// The extractor the route is expected to run under, resolved
// client-side. It feeds the extraction-cache lookup, which must
// happen BEFORE the extract API is called: the cache key is
// (content digest × domain × extractor identity × config
// fingerprint), and on a hit the rebuilt parse result skips the
// paid extraction entirely.
const expectedExtractor = source.contentDigest
? resolveExpectedExtractor(source.mimeType ?? 'application/pdf', providerId)
: null;
// Cache lookup first; on a miss this runs the extract API (asset-id
// JSON form with a per-source fallback to the legacy byte upload,
// see `fetchExtractionResponse`) and caches the result best-effort.
// The full outcome is kept — a server-backed run needs the
// best-effort cache write's derived image ids, not just the data.
const runExtraction = () =>
fetchExtractionWithCache({
serverBacked,
hasAssetId: Boolean(source.assetId),
logWarning: (message, ...args) => log.warn(message, ...args),
fetchers: {
submitAssetIdForm: async () => {
// Server-backed pool: extract by the asset id allocated at
// upload. The server resolves the original bytes from the
// server asset store, so no bytes cross the wire.
const persistenceHeaders = await getPersistenceRequestHeaders();
return fetch('/api/extract-document', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...persistenceHeaders },
body: JSON.stringify({
assetId: source.assetId,
fileName: source.name,
mimeType: source.mimeType,
providerId: providerId || undefined,
apiKey: providerConfig?.apiKey?.trim() ? providerConfig.apiKey : undefined,
baseUrl: providerConfig?.baseUrl?.trim()
? providerConfig.baseUrl
: undefined,
// AliDocMind uses AK/SK instead of a single apiKey.
accessKeyId: providerConfig?.accessKeyId?.trim()
? providerConfig.accessKeyId
: undefined,
accessKeySecret: providerConfig?.accessKeySecret?.trim()
? providerConfig.accessKeySecret
: undefined,
}),
signal,
});
},
submitByteForm: async () => {
// Browser-backed pool (or a legacy storageKey-only session,
// or a fallback after a failed asset-id form): the server
// cannot resolve a browser-side asset, so upload the bytes
// exactly as before.
const documentBlob = await loadDocumentBlob(source.storageKey);
if (!documentBlob) {
throw new Error(t('generation.courseMaterialLoadFailed'));
}
if (!(documentBlob instanceof Blob) || documentBlob.size === 0) {
log.error('Invalid course material blob:', {
source: source.name,
type: typeof documentBlob,
size: documentBlob instanceof Blob ? documentBlob.size : 'N/A',
});
throw new Error(t('generation.courseMaterialLoadFailed'));
}
const documentFile = new File([documentBlob], source.name || 'document.pdf', {
type: source.mimeType || documentBlob.type || 'application/pdf',
});
const parseFormData = new FormData();
parseFormData.append('file', documentFile);
if (providerId) parseFormData.append('providerId', providerId);
if (providerConfig?.apiKey?.trim()) {
parseFormData.append('apiKey', providerConfig.apiKey);
}
if (providerConfig?.baseUrl?.trim()) {
parseFormData.append('baseUrl', providerConfig.baseUrl);
}
// AliDocMind uses AK/SK instead of a single apiKey.
if (providerConfig?.accessKeyId?.trim()) {
parseFormData.append('accessKeyId', providerConfig.accessKeyId);
}
if (providerConfig?.accessKeySecret?.trim()) {
parseFormData.append('accessKeySecret', providerConfig.accessKeySecret);
}
return fetch('/api/extract-document', {
method: 'POST',
body: parseFormData,
signal,
});
},
},
contentDigest: source.contentDigest,
domain: sourceDomain,
extractorId: expectedExtractor?.extractorId,
extractorVersion: expectedExtractor?.extractorVersion,
baseUrl: sourceBaseUrl,
sourceDocAssetId: source.assetId,
parseFailedMessage: t('generation.courseMaterialParseFailed'),
// Part 2 B: a server-backed deployment names images by their
// allocated pool asset ids, so a cache hit feeds generation by
// id instead of materializing image bytes client-side.
imageMappingMode: serverBacked ? 'asset-id' : 'data-url',
});
// K3 (in-run dedupe): sources whose extraction cache key is
// identical (same content digest + domain + expected extractor +
// config fingerprint) share ONE extraction; two same-byte files
// never both pay, and per-source config differences never share.
// The memo now carries the FULL outcome, so a deduplicated second
// source reaches the shared derivation's image asset ids through
// the winner's cacheWrite (part 2 B).
const extractionOutcome =
source.contentDigest && expectedExtractor && sourceConfigFingerprint
? await deduplicateExtraction.run(
{
contentDigest: source.contentDigest,
domain: sourceDomain,
extractorId: expectedExtractor.extractorId,
extractorVersion: expectedExtractor.extractorVersion,
configFingerprint: sourceConfigFingerprint,
},
runExtraction,
)
: await runExtraction();
const parseData = extractionOutcome.data;
// Part 2 B: on a server-backed deployment the extracted images are
// pool assets (part 1). A cache hit carries their ids on the
// rebuilt result; a fresh extraction's ids come from the
// best-effort cache write — awaited here because the session must
// name the images by id without materializing their bytes. When
// no ids are available (KV unavailable), the source falls back to
// the data-URL transport below — the routes accept both.
//
// N2: the await is BOUNDED by the same 15 s budget as the
// upload-time ingest drain, so a server that accepts the
// connection but never answers degrades to the per-source
// data-URL fallback instead of hanging startGeneration. The
// detached write keeps running and benefits the NEXT run (the
// extraction-cache L5 contract stays true — it is this caller's
// await that is bounded).
let imageAssetIds: Map<string, string> | undefined;
if (serverBacked && !extractionOutcome.cacheHit) {
const derived = await awaitWithFallback(
(extractionOutcome.cacheWrite ?? Promise.resolve([])).catch(() => []),
DEFAULT_INGEST_AWAIT_TIMEOUT_MS,
[],
);
if (derived.length > 0) {
imageAssetIds = new Map(derived.map((image) => [image.id, image.assetId]));
}
const documentFile = new File([documentBlob], source.name || 'document.pdf', {
type: source.mimeType || documentBlob.type || 'application/pdf',
});
const parseFormData = new FormData();
parseFormData.append('file', documentFile);
if (providerId) parseFormData.append('providerId', providerId);
if (providerConfig?.apiKey?.trim())
parseFormData.append('apiKey', providerConfig.apiKey);
if (providerConfig?.baseUrl?.trim())
parseFormData.append('baseUrl', providerConfig.baseUrl);
if (providerConfig?.accessKeyId?.trim()) {
parseFormData.append('accessKeyId', providerConfig.accessKeyId);
}
if (providerConfig?.accessKeySecret?.trim()) {
parseFormData.append('accessKeySecret', providerConfig.accessKeySecret);
}
const parseResponse = await fetch('/api/extract-document', {
method: 'POST',
body: parseFormData,
signal,
});
if (!parseResponse.ok) throw new Error(t('generation.courseMaterialParseFailed'));
const parseResult = await parseResponse.json();
if (!parseResult.success || !parseResult.data) {
throw new Error(t('generation.courseMaterialParseFailed'));
}
const parseData = parseResult.data;
const rawImages = parseData.metadata?.pdfImages;
const images = rawImages
? rawImages.map((img: ParsedDocumentResponseImage) => ({
@@ -568,8 +391,6 @@ function GenerationPreviewContent() {
description: img.description,
width: img.width,
height: img.height,
...(img.assetId ? { assetId: img.assetId } : {}),
...(imageAssetIds?.get(img.id) ? { assetId: imageAssetIds.get(img.id) } : {}),
}))
: ((parseData.images as string[] | undefined) ?? []).map((src, i) => ({
id: `img_${i + 1}`,
@@ -596,22 +417,7 @@ function GenerationPreviewContent() {
);
const bundle = buildDocumentBundle(parsedParts);
// Part 2 B/C + N4: the byte transport is decided PER SOURCE, not for
// the whole bundle. A server-backed source whose images all carry
// allocated asset ids feeds generation by id; a source with any image
// lacking an id (a failed best-effort cache write, a legacy session)
// materializes ITS images into IndexedDB as data URLs — one source's
// failure never silently drops another source's images. The resulting
// `imageMapping` may MIX asset ids and data URLs; the routes and
// `resolveImageIds` are shape-based and accept both.
const { storageIds: perImageStorageIds } = await materializeBundleImages(
serverBacked,
bundle.images,
storeImages,
);
const imageStorageIds = perImageStorageIds.filter(
(storageId): storageId is string => storageId !== undefined,
);
const imageStorageIds = await storeImages(bundle.images);
const pdfImages: PdfImage[] = bundle.images.map((img, i) => ({
id: img.id,
@@ -625,10 +431,7 @@ function GenerationPreviewContent() {
sourceDocumentName: img.sourceDocumentName,
sourceDocumentOrder: img.sourceDocumentOrder,
visionPriority: img.visionPriority,
// Per source: id-fed images carry their pool asset id; materialized
// images carry their IndexedDB storage id (never both).
...(img.assetId && perImageStorageIds[i] === undefined ? { assetId: img.assetId } : {}),
...(perImageStorageIds[i] !== undefined ? { storageId: perImageStorageIds[i] } : {}),
storageId: imageStorageIds[i],
}));
// Update session with extracted document data
@@ -715,35 +518,8 @@ function GenerationPreviewContent() {
}
// Load imageMapping early (needed for both outline and scene generation).
// The transport is decided once per run by the same probe as part 0/1
// (RFC #1153 part 2 B): a server-backed pool names images by their
// allocated asset ids (the extracted images are pool assets — part 1); a
// browser-backed pool materializes base64 data URLs exactly as before.
// Per source (N4) the mapping may MIX allocated asset ids and IndexedDB
// data URLs — a source whose cache write failed materializes its own
// images — and the routes / `resolveImageIds` are shape-based, so both
// value kinds ride the same mapping.
let imageMapping: ImageMapping = {};
const sessionImages = currentSession.pdfImages ?? [];
if (serverBacked) {
const mapped: ImageMapping = {};
for (const img of sessionImages) {
if (img.assetId) mapped[img.id] = img.assetId;
}
const storageIds = sessionImages
.filter((img): img is PdfImage & { storageId: string } => !img.assetId && !!img.storageId)
.map((img) => img.storageId);
if (storageIds.length > 0) {
Object.assign(mapped, await loadImageMapping(storageIds));
} else if (currentSession.imageStorageIds && currentSession.imageStorageIds.length > 0) {
// Legacy session: storage ids live on the session, not on pdfImages.
Object.assign(mapped, await loadImageMapping(currentSession.imageStorageIds));
}
if (Object.keys(mapped).length > 0) {
log.debug('Using per-source imageMapping (server-backed pool, ids + data URLs)');
imageMapping = mapped;
}
} else if (currentSession.imageStorageIds && currentSession.imageStorageIds.length > 0) {
if (currentSession.imageStorageIds && currentSession.imageStorageIds.length > 0) {
log.debug('Loading images from IndexedDB');
imageMapping = await loadImageMapping(currentSession.imageStorageIds);
} else if (
+2
View File
@@ -1,6 +1,8 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
/* Workbench chat (agent workbench surface): scoped .wbchat tokens + prose skin. */
@import '../components/workbench/chat/workbench-chat.css';
/* Streamdown (AI sidebar markdown) styles itself with Tailwind utilities —
Tailwind must scan its dist so those classes get generated. */
+2
View File
@@ -11,6 +11,7 @@ import { Toaster } from '@/components/ui/sonner';
import { ServerProvidersInit } from '@/components/server-providers-init';
import { StorageHealthNotice } from '@/components/storage-health-notice';
import { AccessCodeGuard } from '@/components/access-code-guard';
import { ProSwapWatcher } from '@/components/workbench/ProSwapWatcher';
// The UI font is loaded from @fontsource's stylesheet rather than next/font,
// because only the stylesheet carries the per-subset `unicode-range`
@@ -47,6 +48,7 @@ export default function RootLayout({
<ThemeProvider>
<I18nProvider>
<ServerProvidersInit />
<ProSwapWatcher />
<AccessCodeGuard>{children}</AccessCodeGuard>
<Toaster position="top-center" />
{/* After the Toaster: this one raises a toast on mount when
+77 -229
View File
@@ -40,20 +40,12 @@ import { GenerationToolbar } from '@/components/generation/generation-toolbar';
import { AgentBar } from '@/components/agent/agent-bar';
import { useTheme } from '@/lib/hooks/use-theme';
import { nanoid } from 'nanoid';
import { putAsset, removeAsset } from '@/lib/media/asset-pool';
import { deleteDocumentBlob, storeDocumentBlob } from '@/lib/utils/image-storage';
import { normalizeDocumentMimeType } from '@/lib/document/mime';
import {
courseMaterialFingerprint,
dedupeCourseMaterialFiles,
} from '@/lib/document/course-materials';
import {
awaitPendingIngests,
DEFAULT_INGEST_AWAIT_TIMEOUT_MS,
resolvedAssetIdForIngest,
} from '@/lib/document/extract-source';
import { computeContentDigest } from '@/lib/document/extraction-cache';
import { upsertMaterialLibraryEntry } from '@/lib/materials/library';
import type {
SelectedCourseMaterial,
SessionDocumentSource,
@@ -90,9 +82,19 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
import { SpeechButton } from '@/components/audio/speech-button';
import { useImportClassroom } from '@/lib/import/use-import-classroom';
import { isPptxImportEnabled, shouldShowVocationalTestUi } from '@/lib/config/feature-flags';
import {
isProWorkbenchEnabled,
isPptxImportEnabled,
shouldShowVocationalTestUi,
} from '@/lib/config/feature-flags';
import { useImportPptx } from '@/lib/import/use-import-pptx';
import { InteractiveModeButton } from '@/components/generation/interactive-mode-button';
import { ProBadge } from '@/components/workbench/ProBadge';
import { arrivedByProSwap, startProSwap } from '@/lib/workbench/pro-swap';
import {
readLastWorkspaceSessionId,
workspaceResumeHref,
} from '@/lib/workbench/workspace-session-memory';
const log = createLogger('Home');
@@ -105,6 +107,9 @@ const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled';
// flag until it's wired end-to-end, so the UI doesn't expose a no-op button.
const PPTX_IMPORT_ENABLED = isPptxImportEnabled();
/** The configured runtime probe result, retained across client navigations. */
let workbenchRuntimeCache: boolean | null = null;
interface FormState {
courseMaterials: SelectedCourseMaterial[];
requirement: string;
@@ -125,7 +130,39 @@ function HomePage() {
const { t } = useI18n();
const { theme, setTheme } = useTheme();
const router = useRouter();
// Do not replay the classic hero's entrance after the route handoff already
// carried the lockup and composer into place.
const [swapped] = useState(arrivedByProSwap);
const heroEnter = (from: Record<string, number>) => (swapped ? false : from);
const showVocationalTestUi = shouldShowVocationalTestUi();
const workbenchBuildEnabled = isProWorkbenchEnabled();
const [workbenchRuntimeEnabled, setWorkbenchRuntimeEnabled] = useState(
workbenchRuntimeCache === true,
);
useEffect(() => {
if (!workbenchBuildEnabled || workbenchRuntimeCache !== null) return;
let cancelled = false;
fetch('/api/agent/runtime')
.then((response) => (response.ok ? response.json() : null))
.then((body) => {
workbenchRuntimeCache = body?.enabled === true;
if (!cancelled) setWorkbenchRuntimeEnabled(workbenchRuntimeCache);
})
.catch(() => {
// A failed probe keeps the entry hidden and allows a later visit to retry.
});
return () => {
cancelled = true;
};
}, [workbenchBuildEnabled]);
const workbenchEntryEnabled = workbenchBuildEnabled && workbenchRuntimeEnabled;
const enterWorkbench = () => {
const href = workspaceResumeHref(readLastWorkspaceSessionId());
startProSwap(href, (next) => router.push(next));
};
useEffect(() => {
if (workbenchEntryEnabled) router.prefetch('/workspace');
}, [router, workbenchEntryEnabled]);
const [form, setForm] = useState<FormState>(initialFormState);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] = useState<
@@ -218,21 +255,6 @@ function HomePage() {
const toolbarRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const thumbnailsRef = useRef<Record<string, Slide>>({});
// In-flight asset-pool ingests, keyed by course material id, so removing a
// file whose pool entry is still being allocated can still release it.
const pendingMaterialIngestsRef = useRef(new Map<string, Promise<string>>());
// In-flight content-digest computations, keyed the same way. The digest is
// the stable half of the extraction-cache key, so the session build reads it
// from the settled promise just like the asset id (see
// `resolvedAssetIdForIngest` for the commit-timing rationale).
const pendingMaterialDigestsRef = useRef(new Map<string, Promise<string | undefined>>());
// Course-material ids whose ingest was ABANDONED: removed from the
// selection (the source file handle is gone) or released at prep-timeout
// because no durable holder would ever exist for the id. The material-library
// upsert skips these — minting would record bytes the user deliberately
// discarded (RFC #1153 part 2, N1). Written synchronously by the removal
// path and the prep-timeout drain, read by the upsert settlement below.
const abandonedMaterialIngestIdsRef = useRef(new Set<string>());
const replaceThumbnails = (slides: Record<string, Slide>) => {
const previous = thumbnailsRef.current;
@@ -496,108 +518,11 @@ function HomePage() {
}
};
// Ingest a selected file into the asset pool as soon as it is picked, so the
// material gets its allocated asset id at upload time (RFC #1153 part 0).
// The file still appears in the form immediately; the asset id is patched in
// when the pool entry lands. A failure only loses the pool entry — the
// source keeps working through the legacy blob-stash byte upload — so it is
// logged, not surfaced.
//
// A tab closing mid-ingest can still orphan a pool entry: the browser gives
// no reliable release hook once the page tears down. Accepted — the
// material-library milestone of RFC #1153 brings visibility and management
// for such entries.
const ingestCourseMaterialIntoPool = (addition: SelectedCourseMaterial) => {
const ingest = putAsset(addition.file).then((assetId) => {
setForm((prev) =>
prev.courseMaterials.some((item) => item.id === addition.id)
? {
...prev,
courseMaterials: prev.courseMaterials.map((item) =>
item.id === addition.id ? { ...item, assetId } : item,
),
}
: prev,
);
return assetId;
});
// The pending entry is the durable release handle for this pool entry and
// is intentionally NOT deleted when the ingest settles: deleting it inside
// the ingest's own settlement would race the state patch above (React
// commits it after this promise resolves), orphaning the entry if the user
// removes the file in that window. Only the removal path deletes it (see
// removeCourseMaterial), so an entry is always retrievable until the id
// has a durable holder. Entries for never-removed sources stay until the
// page unmounts — bounded by the files picked in one session.
pendingMaterialIngestsRef.current.set(addition.id, ingest);
void ingest.catch((error) => {
log.error(`Failed to ingest course material "${addition.name}" into the asset pool:`, error);
});
// Content identity: the SHA-256 of the file bytes, computed in parallel
// with the pool ingest (the file is already in memory for putAsset). It is
// the stable half of the extraction-cache key — two uploads of the same
// bytes get different allocated asset ids but the same digest. A digest
// failure only means the source skips the extraction cache (a conservative
// miss); it never blocks the upload or the extraction.
const contentDigest = computeContentDigest(addition.file).catch((error) => {
log.error(`Failed to compute the content digest for "${addition.name}":`, error);
return undefined;
});
pendingMaterialDigestsRef.current.set(addition.id, contentDigest);
void contentDigest.then((digest) => {
if (digest === undefined) return;
setForm((prev) =>
prev.courseMaterials.some((item) => item.id === addition.id)
? {
...prev,
courseMaterials: prev.courseMaterials.map((item) =>
item.id === addition.id ? { ...item, contentDigest: digest } : item,
),
}
: prev,
);
});
// Material library manifest (RFC #1153 part 2): once BOTH the ingest's
// allocated asset id and the content digest have settled, upsert the
// library entry keyed by digest — same bytes re-imported refresh the same
// entry (`addedAt` bumped, `assetId` advanced). The library allocates its
// OWN pool entry from the file bytes and stores that id, so the entry
// stays resolvable after this selection's pool entry is released (N1,
// RFC §5 root model). The upsert must NOT fire for a material whose
// ingest was already abandoned — removed from the selection (the source
// file handle is gone) or released at prep-timeout — so it is gated on
// the same settlement as before AND on the abandoned set. Best-effort by
// contract: a KV failure or a failed library allocation never fails the
// upload.
void Promise.all([ingest, contentDigest]).then(([, digest]) => {
if (!digest) return;
if (abandonedMaterialIngestIdsRef.current.has(addition.id)) return;
void upsertMaterialLibraryEntry({
file: addition.file,
contentDigest: digest,
name: addition.name,
mimeType:
normalizeDocumentMimeType({
mimeType: addition.file.type,
fileName: addition.file.name,
}) || undefined,
size: addition.size,
}).catch((error) => {
log.error(`Failed to record the material library entry for "${addition.name}":`, error);
});
});
};
const addCourseMaterials = (files: File[]) => {
// The set is frozen for the duration of generate-prep: adding is inert
// while `preparingGenerate` is set (the toolbar affordance is disabled
// via the same state), so nothing can slip into the set mid-prep.
if (preparingGenerate) return;
// Compute the additions before touching state: the ingest loop must not
// run inside the setForm updater, which React may invoke more than once —
// each replay would putAsset again and orphan an allocated id.
const dedupedFiles = dedupeCourseMaterialFiles(form.courseMaterials, files);
const startOrder = form.courseMaterials.length + 1;
const additions = dedupedFiles.map((file, index) => ({
@@ -610,10 +535,6 @@ function HomePage() {
order: startOrder + index,
}));
for (const addition of additions) {
ingestCourseMaterialIntoPool(addition);
}
if (additions.length === 0) return;
setForm((prev) => {
// Pure updater: drop any addition the latest state already carries — by
@@ -637,36 +558,6 @@ function HomePage() {
// while `preparingGenerate` is set (the toolbar affordance is disabled
// via the same state), so nothing can slip out of the set mid-prep.
if (preparingGenerate) return;
const removed = form.courseMaterials.find((item) => item.id === id);
// The source file handle is gone: the material-library upsert must not
// fire for this id once its ingest settles (N1). Mark it synchronously so
// the settlement closure — which runs after this handler returns — skips
// minting an entry for bytes the user deliberately discarded.
abandonedMaterialIngestIdsRef.current.add(id);
// Release the pool entry allocated for this file, mirroring the blob-stash
// cleanup: if the ingest is still in flight, release once it lands.
const release = removed?.assetId
? Promise.resolve(removed.assetId)
: (pendingMaterialIngestsRef.current.get(id) ?? Promise.resolve(undefined));
void release
.then((assetId) => {
if (assetId) return removeAsset(assetId);
})
.catch((error) => {
log.error('Failed to release course material asset pool entry:', error);
})
.finally(() => {
// This removal path is the sole consumer of the pending entry: the
// pool entry has been released (or the ingest itself failed, leaving
// nothing to release), so the entry can go. Deleting it here — rather
// than in the ingest's own settlement — keeps it retrievable until
// the id has a durable holder.
pendingMaterialIngestsRef.current.delete(id);
// The content digest has no release to perform; drop its pending entry
// so a removed material cannot leak it into a later session build.
pendingMaterialDigestsRef.current.delete(id);
});
setForm((prev) => ({
...prev,
courseMaterials: prev.courseMaterials
@@ -710,46 +601,9 @@ function HomePage() {
}
: undefined;
// Flip the generating UI state BEFORE the drain so the click visibly does
// something even when an ingest stalls.
// Flip the generating UI state before material bytes are copied locally.
setPreparingGenerate(true);
const unsettledIngestIds = new Set<string>();
try {
// Drain any in-flight ingests before building the session, so a resolved
// asset id lands in the session instead of being dropped when this page
// unmounts. The await is bounded (~15 s): on timeout, the unsettled
// sources proceed with their storageKey and the legacy byte path, and
// each late-resolving id is released since no durable holder will ever
// exist for it. A rejected ingest is fine: that source proceeds with its
// storageKey and the byte path. The drain loops until the pending map is
// stable — despite the freeze guard, an add that slipped in before the
// flag took effect is still drained (belt-and-braces).
const awaitedIngestIds = new Set<string>();
for (;;) {
const unawaited = [...pendingMaterialIngestsRef.current.entries()].filter(
([id]) => !awaitedIngestIds.has(id),
);
if (unawaited.length === 0) break;
for (const [id] of unawaited) awaitedIngestIds.add(id);
const batchUnsettled = await awaitPendingIngests(new Map(unawaited), {
timeoutMs: DEFAULT_INGEST_AWAIT_TIMEOUT_MS,
onUnsettled: (ingestId, ingest) => {
unsettledIngestIds.add(ingestId);
// The ingest was abandoned: no durable holder will ever exist for
// its id, so release it when it lands — and mark it abandoned so
// the material-library upsert skips minting an entry for bytes
// the session deliberately discarded (N1).
abandonedMaterialIngestIdsRef.current.add(ingestId);
void ingest
.then((assetId) => (assetId ? removeAsset(assetId) : undefined))
.catch((error) => {
log.error('Failed to release timed-out course material asset pool entry:', error);
});
},
});
for (const id of batchUnsettled) unsettledIngestIds.add(id);
}
const userProfile = useUserProfileStore.getState();
const requirements: UserRequirements = {
requirement: form.requirement,
@@ -778,19 +632,6 @@ function HomePage() {
for (const [index, item] of frozenMaterials.entries()) {
const storageKey = await storeDocumentBlob(item.file);
storedDocumentKeys.push(storageKey);
// The awaited ingests patched their asset ids into form state via
// setForm, which this closure may not reflect yet; read the settled
// value from the pending map so a just-resolved id still lands in
// the session regardless of React commit timing. A timed-out ingest
// is never awaited again — its source goes byte-path (no assetId).
const settledAssetId = unsettledIngestIds.has(item.id)
? undefined
: await resolvedAssetIdForIngest(pendingMaterialIngestsRef.current, item.id);
// Same for the content digest: read the settled value (undefined
// when the computation failed or never started) so the extraction
// cache key is available in the session even before the form patch
// commits. Digest is local and fast, so no time budget is needed.
const settledContentDigest = await pendingMaterialDigestsRef.current.get(item.id);
documentSources.push({
id: item.id,
name: item.name,
@@ -802,12 +643,6 @@ function HomePage() {
}),
order: index + 1,
storageKey,
// The asset id was allocated at upload; new sessions carry it so
// a server-backed pool can extract by id instead of re-uploading.
assetId: item.assetId ?? settledAssetId,
// Content identity of the bytes: the stable half of the
// extraction-cache key (RFC #1153 part 1).
contentDigest: item.contentDigest ?? settledContentDigest,
providerId: pdfProviderId,
});
}
@@ -990,29 +825,39 @@ function HomePage() {
{/* ═══ Hero section: title + input (centered, wider) ═══ */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={heroEnter({ opacity: 0, y: 20 })}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className={cn('relative z-20 w-full max-w-[800px] flex flex-col items-center mt-[10vh]')}
>
{/* ── Logo ── */}
<motion.img
src="/logo-horizontal.png"
alt="OpenMAIC"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
delay: 0.1,
type: 'spring',
stiffness: 200,
damping: 20,
}}
className="h-12 md:h-16 mb-2 -ml-2 md:-ml-3"
/>
<div className="relative" data-pro-morph="lockup">
<motion.img
src="/logo-horizontal.png"
alt="OpenMAIC"
initial={heroEnter({ opacity: 0, scale: 0.9 })}
animate={{ opacity: 1, scale: 1 }}
transition={{
delay: 0.1,
type: 'spring',
stiffness: 200,
damping: 20,
}}
className="h-12 md:h-16 mb-2 -ml-2 md:-ml-3"
/>
{workbenchEntryEnabled ? (
<div
className="absolute left-full top-0 ml-1.5 mt-[10px] md:ml-2 md:mt-[14px]"
data-pro-morph="badge"
>
<ProBadge active={false} onToggle={enterWorkbench} />
</div>
) : null}
</div>
{/* ── Slogan ── */}
<motion.p
initial={{ opacity: 0 }}
initial={heroEnter({ opacity: 0 })}
animate={{ opacity: 1 }}
transition={{ delay: 0.25 }}
className="text-sm text-muted-foreground/60 mb-8"
@@ -1022,12 +867,15 @@ function HomePage() {
{/* ── Unified input area ── */}
<motion.div
initial={{ opacity: 0, scale: 0.97 }}
initial={heroEnter({ opacity: 0, scale: 0.97 })}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.35 }}
className="w-full"
>
<div className="w-full rounded-2xl border border-border/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl shadow-xl shadow-black/[0.03] dark:shadow-black/20 transition-shadow focus-within:shadow-2xl focus-within:shadow-violet-500/[0.06]">
<div
data-pro-morph="composer"
className="w-full rounded-2xl border border-border/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl shadow-xl shadow-black/[0.03] dark:shadow-black/20 transition-shadow focus-within:shadow-2xl focus-within:shadow-violet-500/[0.06]"
>
{/* ── Greeting + Profile + Agents ── */}
<div className="relative z-20 flex items-start justify-between">
<GreetingBar />
+139
View File
@@ -0,0 +1,139 @@
'use client';
/**
* Compatibility bridge for launch links shipped before the workspace composer
* started creating sessions in place. This route intentionally has no second
* composer: it consumes the legacy intent once, creates the session, and
* replaces itself with the canonical workspace URL.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
createWorkbenchSession,
WorkbenchApiError,
type WorkbenchMaterial,
} from '@/lib/workbench/session-store';
import type { CourseRef } from '@/lib/workbench/course-refs';
import { workspaceHref } from '@/lib/workbench/workspace-panes';
import { useI18n } from '@/lib/hooks/use-i18n';
// Older deployed home composers may still write this key during a rolling deploy.
const LEGACY_LAUNCH_HANDOFF_KEY = 'workbench.launchPrompt';
interface LegacyLaunchIntent {
readonly prompt: string;
readonly skill?: string;
readonly materials?: WorkbenchMaterial[];
readonly courseRefs?: CourseRef[];
}
function parseLegacyHandoff(raw: string): LegacyLaunchIntent {
const trimmed = raw.trim();
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed) as LegacyLaunchIntent & { v?: number };
if (parsed?.v === 1 && typeof parsed.prompt === 'string') return parsed;
} catch {
// Old plain-string handoffs are handled below.
}
}
return { prompt: trimmed };
}
export function WorkbenchLaunchBridge() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useI18n();
const launched = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const [skillOverride, setSkillOverride] = useState<string | null | undefined>(undefined);
const intent = useMemo<LegacyLaunchIntent | null>(() => {
const prompt = searchParams.get('prompt')?.trim();
if (prompt) {
const skill = searchParams.get('skill')?.trim();
return { prompt, ...(skill ? { skill } : {}) };
}
if (typeof window === 'undefined') return null;
try {
const raw = window.sessionStorage.getItem(LEGACY_LAUNCH_HANDOFF_KEY);
return raw?.trim() ? parseLegacyHandoff(raw) : null;
} catch {
return null;
}
}, [searchParams]);
const skill = skillOverride === undefined ? intent?.skill : (skillOverride ?? undefined);
useEffect(() => {
const launchKey = `${attempt}:${skill ?? ''}`;
if (launched.current === launchKey) return;
launched.current = launchKey;
if (!intent?.prompt.trim()) {
router.replace('/workspace');
return;
}
createWorkbenchSession({
prompt: intent.prompt.trim(),
...(skill ? { skill } : {}),
...(intent.materials?.length ? { materials: intent.materials } : {}),
...(intent.courseRefs?.length ? { courseRefs: intent.courseRefs } : {}),
})
.then((session) => {
if (session.courseRefsAccepted === false) {
toast.warning(t('workspace.courseMention.notAccepted'));
}
try {
window.sessionStorage.removeItem(LEGACY_LAUNCH_HANDOFF_KEY);
} catch {
// The session exists; a denied cleanup does not invalidate it.
}
router.replace(workspaceHref({ sessionId: session.id, courseId: null }));
})
.catch((cause: unknown) => {
if (
skill &&
cause instanceof WorkbenchApiError &&
cause.status === 400 &&
/unknown skill/i.test(cause.message)
) {
toast.error(t('workbench.launch.unknownSkill'));
setSkillOverride(null);
setAttempt((current) => current + 1);
return;
}
setError(cause instanceof Error ? cause.message : t('workbench.launch.createFailed'));
});
}, [attempt, intent, router, skill, t]);
if (error) {
return (
<main className="flex min-h-screen w-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
<p className="text-sm text-destructive">{error}</p>
<button
type="button"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
onClick={() => {
setError(null);
setAttempt((current) => current + 1);
}}
>
{t('common.retry')}
</button>
<a className="text-sm font-medium text-primary underline" href="/workspace">
{t('workbench.common.backToWorkspace')}
</a>
</main>
);
}
return (
<main className="flex min-h-screen w-full items-center justify-center bg-background">
<Loader2
className="size-6 animate-spin text-muted-foreground motion-reduce:animate-none"
aria-label={t('workbench.common.loading')}
/>
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Backward-compatible bridge for historical `/workbench/new?prompt=&skill=`
* links and rolling-deployment sessionStorage handoffs. New launches happen
* directly inside `/workspace`; this route owns no composer or product UI.
*/
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { isWorkbenchEntryEnabled } from '@/lib/workbench/entry-gate';
import { WorkbenchLaunchBridge } from './client';
export const dynamic = 'force-dynamic';
export default function WorkbenchNewCompatibilityPage() {
if (!isWorkbenchEntryEnabled()) notFound();
return (
<Suspense fallback={null}>
<WorkbenchLaunchBridge />
</Suspense>
);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* `/workspace` — the Pro workspace home.
*
* Pro mode used to be a `useState` on `app/page.tsx`, which meant the global
* `SiteHeader` could not know about it and stacked a second navigation bar on
* top of the workspace's own sidebar. As a route it is addressable instead:
* `AppChrome` suppresses the header by path prefix, a refresh keeps you here,
* and the workspace can be linked to.
*
* The gate is server-side and checks the pair of workbench flags:
* `NEXT_PUBLIC_PRO_WORKBENCH_ENABLED` (build-time, client-visible) and
* the server-only configured runtime truth. A workspace whose
* every submit 404s is worse than no workspace, so either flag off redirects
* to `/` rather than rendering. `/` hides its Pro badge behind the same pair,
* learned through the `/api/agent/runtime` probe (the client cannot read the
* server flag), so the entry and the destination agree.
*
* `force-dynamic` keeps the flags request-scoped instead of baking them into a
* prerender.
*
* The Suspense boundary covers the route seam that reads the initial deep-link
* snapshot from `useSearchParams`. Once mounted, the workspace owns pane state
* locally and mirrors it with the History API, so ordinary pane changes do not
* ask the server route to render again.
*
*/
import { Suspense } from 'react';
import { redirect } from 'next/navigation';
import { isWorkbenchEntryEnabled } from '@/lib/workbench/entry-gate';
import { WorkspaceEntry } from '@/components/workbench/WorkspaceEntry';
export const dynamic = 'force-dynamic';
export default function WorkspacePage() {
if (!isWorkbenchEntryEnabled()) redirect('/');
return (
<Suspense fallback={null}>
<WorkspaceEntry />
</Suspense>
);
}
+3 -4
View File
@@ -1018,10 +1018,9 @@ interface SpeechRecognitionErrorEvent extends Event {
error: string;
}
// Window.SpeechRecognition / webkitSpeechRecognition are declared globally by
// @assistant-ui/core's speech adapter; we deliberately do NOT re-augment them
// here (an `any` re-declaration conflicts with that typing). The local
// `SpeechRecognition` interface above is the richer instance shape we use.
// Window.SpeechRecognition / webkitSpeechRecognition have minimal constructor
// declarations in types/web-speech.d.ts. The local `SpeechRecognition`
// interface above is the richer instance shape this component consumes.
export type PromptInputSpeechButtonProps = ComponentProps<typeof PromptInputButton> & {
textareaRef?: RefObject<HTMLTextAreaElement | null>;
+48 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback } from 'react';
import { useCallback, type ReactNode } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Play } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -12,6 +12,8 @@ import type { CanvasToolbarProps } from '@/components/canvas/canvas-toolbar';
import type { Scene, StageMode } from '@/lib/types/stage';
import { useI18n } from '@/lib/hooks/use-i18n';
import { ClassroomCompletePageConnected } from '@/components/scene-renderers/classroom-complete';
import { ContainBox } from '@/components/edit/ContainBox';
import { useInWorkbenchPanel } from '@/lib/workbench/panel-context';
interface CanvasAreaProps extends CanvasToolbarProps {
readonly currentScene: Scene | null;
@@ -53,6 +55,7 @@ export function CanvasArea({
onRetryGeneration,
}: CanvasAreaProps) {
const { t } = useI18n();
const inWorkbenchPanel = useInWorkbenchPanel();
const showControls = mode === 'playback' && !whiteboardOpen;
const showPlayHint =
showControls &&
@@ -96,9 +99,11 @@ export function CanvasArea({
: 'bg-gray-50/30 dark:bg-gray-900/30',
)}
>
<div
<StageViewport
workbench={inWorkbenchPanel}
interactive={currentScene?.type === 'interactive'}
className={cn(
'aspect-[16/9] h-full max-h-full max-w-full bg-white dark:bg-gray-800 shadow-2xl rounded-lg overflow-hidden relative transition-all duration-700',
'bg-white dark:bg-gray-800 shadow-2xl rounded-lg overflow-hidden relative transition-all duration-700',
showControls && !isLiveSession && currentScene?.type === 'slide' && 'cursor-pointer',
currentScene?.type === 'interactive'
? 'shadow-blue-200/50 dark:shadow-blue-900/50 ring-1 ring-blue-900/5 dark:ring-blue-500/10'
@@ -242,7 +247,7 @@ export function CanvasArea({
</motion.div>
)}
</AnimatePresence>
</div>
</StageViewport>
</div>
{/* ── Canvas Toolbar — in document flow, only when not merged into roundtable ── */}
@@ -278,3 +283,42 @@ export function CanvasArea({
</div>
);
}
function StageViewport({
workbench,
interactive,
className,
onClick,
children,
}: {
readonly workbench: boolean;
readonly interactive: boolean;
readonly className?: string;
readonly onClick?: (event: React.MouseEvent) => void;
readonly children: ReactNode;
}) {
if (interactive) {
return (
<div className={cn('h-full w-full', className)} onClick={onClick}>
{children}
</div>
);
}
if (!workbench) {
return (
<div
className={cn('aspect-[16/9] h-full max-h-full max-w-full', className)}
onClick={onClick}
>
{children}
</div>
);
}
return (
<ContainBox fit="contain" className={className}>
<div className="relative h-full w-full" onClick={onClick}>
{children}
</div>
</ContainBox>
);
}
+391
View File
@@ -0,0 +1,391 @@
'use client';
/**
* ClassroomSurface — the classroom, wherever it is mounted.
*
* This is the body `/classroom/[id]` has always had: the load pipeline, the
* generation-resume policy and the `Stage` dispatch under `ThemeProvider` /
* `MediaStageProvider`. It moved out of the route file for exactly one reason
* — the Pro workspace's third pane hosts the REAL classroom, not a preview and
* not an iframe, so both surfaces must run the same code rather than two
* copies that drift.
*
* `variant` is only layout/load-context: `page` fills the viewport and treats
* a course that cannot be found as terminal; `pane` fills its column and runs
* a bounded availability probe because a newly linked course may be committed
* shortly afterward. Neither host accepts conversation/session state. A
* classroom's lifecycle is keyed only by its course id; document and manifest
* data then converge in place as writers update them.
*
* The reference (live deployment) additionally runs non-owner visitor
* hydration, a transport-persistence UI fence and a background uploader; all
* three depend on server-side ownership/persistence machinery this workspace
* does not have, so they are dropped and the load follows the ordinary
* single-user path (`app/classroom/[id]/page.tsx`).
*/
import { Stage } from '@/components/stage';
import { ThemeProvider } from '@/lib/hooks/use-theme';
import { useStageStore } from '@/lib/store';
import { useSettingsStore } from '@/lib/store/settings';
import { claimStageSceneLoadToken, isCurrentStageSceneLoadToken } from '@/lib/store/stage';
import { loadImageMapping } from '@/lib/utils/image-storage';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useSceneGenerator } from '@/lib/hooks/use-scene-generator';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
import { useCanvasStore } from '@/lib/store/canvas';
import { createLogger } from '@/lib/logger';
import { MediaStageProvider } from '@/lib/contexts/media-stage-context';
import { generateMediaForOutlines } from '@/lib/media/media-orchestrator';
import { useI18n } from '@/lib/hooks/use-i18n';
import { FileQuestion, Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
import {
applyClassroomStageAndScenes,
defaultClassroomLoadDeps,
runClassroomLoad,
} from '@/lib/classroom/load-classroom';
import {
paneAvailabilityRetryDelay,
shouldResumeClassroomGeneration,
} from '@/lib/classroom/progressive-load-policy';
const log = createLogger('Classroom');
type ClassroomLoadOutcome = 'loaded' | 'unavailable' | 'failed' | 'cancelled';
// stage_link can become visible shortly before its document. Probe only that
// explicit availability gap, with a small bounded backoff; media conversion
// and ordinary failures never enter this schedule.
export function ClassroomSurface({
classroomId,
variant = 'page',
}: {
readonly classroomId: string;
readonly variant?: 'page' | 'pane';
}) {
const { loadFromStorage } = useStageStore();
const loadedClassroomId = useStageStore((s) => s.stage?.id ?? null);
const { t } = useI18n();
// The retry loop below reads the message after async gaps, so it must see
// the CURRENT translation (a locale switch may have happened since mount).
// Written in an effect, not during render.
const notFoundMessageRef = useRef(t('classroom.notFound'));
useEffect(() => {
notFoundMessageRef.current = t('classroom.notFound');
}, [t]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
/**
* The load resolved and no source has this course. A TERMINAL state, kept
* separate from `error`: an error offers a retry, and there is nothing here
* to retry.
*
* The copy it renders is deliberately the SAME whether the course was
* deleted or never existed.
*/
const [notFound, setNotFound] = useState(false);
const generationStartedRef = useRef(false);
const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({
onComplete: () => {
log.info('[Classroom] All scenes generated');
},
});
const loadClassroom = useCallback(
async (isEffectCurrent: () => boolean = () => true): Promise<ClassroomLoadOutcome> => {
const loadToken = claimStageSceneLoadToken();
const isCurrent = () => isEffectCurrent() && isCurrentStageSceneLoadToken(loadToken);
let outcome: ClassroomLoadOutcome = 'loaded';
try {
await runClassroomLoad({
classroomId,
loadToken,
isCurrent,
loadFromStorage,
getCurrentStage: () => useStageStore.getState().stage,
fetchClassroom: defaultClassroomLoadDeps.fetchClassroom,
applyFallbackScenes: (args) =>
defaultClassroomLoadDeps.applyFallbackScenes({
...args,
isCurrent,
applyStageAndScenes: applyClassroomStageAndScenes,
}),
loadRestoredMediaTasks: defaultClassroomLoadDeps.loadRestoredMediaTasks,
applyRestoredMediaTasks: defaultClassroomLoadDeps.applyRestoredMediaTasks,
discardRestoredMediaTasks: defaultClassroomLoadDeps.discardRestoredMediaTasks,
loadLegacyAgentFallbacks: defaultClassroomLoadDeps.loadLegacyAgentFallbacks,
commitMigratedAgentConfigs: defaultClassroomLoadDeps.commitMigratedAgentConfigs,
applyGeneratedAgents: defaultClassroomLoadDeps.applyGeneratedAgents,
getSettings: () => useSettingsStore.getState(),
getAgent: (id) => useAgentRegistry.getState().getAgent(id),
restoreAgentSelection: defaultClassroomLoadDeps.restoreAgentSelection,
setError,
setLoading,
log,
});
if (!isCurrent()) return 'cancelled';
// The load completed without landing this course in the store. The
// reference learns the same fact from a server 404; here the absence
// of a stage after every source answered is the equivalent signal. A
// standalone URL can give a definitive answer; inside the workspace
// the pane treats it as the bounded availability gap instead of
// replacing its lifecycle.
if (useStageStore.getState().stage?.id !== classroomId) {
if (variant === 'page') {
setNotFound(true);
return 'loaded';
}
outcome = 'unavailable';
}
return isCurrent() ? outcome : 'cancelled';
} catch (error) {
log.error('Failed to load classroom:', error);
if (isCurrent()) {
setError(error instanceof Error ? error.message : 'Failed to load classroom');
setLoading(false);
}
return isCurrent() ? 'failed' : 'cancelled';
}
},
[classroomId, loadFromStorage, variant],
);
useEffect(() => {
// Reset loading state on course switch to unmount Stage during transition,
// preventing stale data from syncing back to the new course
/* eslint-disable react-hooks/set-state-in-effect -- Course switch must hide stale Stage before async load */
setLoading(true);
setError(null);
setNotFound(false);
/* eslint-enable react-hooks/set-state-in-effect */
generationStartedRef.current = false;
// Clear previous classroom's media tasks to prevent cross-classroom contamination.
// Placeholder IDs (gen_img_1, gen_vid_1) are NOT globally unique across stages,
// so stale tasks from a previous classroom would shadow the new one's.
const mediaStore = useMediaGenerationStore.getState();
mediaStore.revokeObjectUrls();
useMediaGenerationStore.setState({ tasks: {} });
// Clear whiteboard history to prevent snapshots from a previous course leaking in.
useWhiteboardHistoryStore.getState().clearHistory();
// Reset edit-time canvas selection/scale: the classroom load paths set
// mode:'playback' via raw setState (not setMode), so an unfinished Pro-mode
// session in the previous course wouldn't otherwise clear its canvas state.
useCanvasStore.getState().resetCanvasState();
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let availabilityAttempt = 0;
const loadUntilAvailable = async () => {
if (cancelled) return;
// A previous pane attempt may have observed a transient read failure.
// Clear only its presentation before retrying; do not raise `loading`
// again, so an already mounted classroom never flashes away.
if (variant === 'pane') setError(null);
const outcome = await loadClassroom(() => !cancelled);
if (cancelled || variant !== 'pane' || outcome !== 'unavailable') return;
const delay = paneAvailabilityRetryDelay(availabilityAttempt);
availabilityAttempt += 1;
if (delay !== null) {
retryTimer = setTimeout(loadUntilAvailable, delay);
} else {
setLoading(false);
setError(notFoundMessageRef.current);
}
};
void loadUntilAvailable();
// Cancel ongoing generation when classroomId changes or component unmounts
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
stop();
};
}, [classroomId, loadClassroom, stop, variant]);
// Auto-resume generation for pending outlines (owner only). The reference
// additionally gates on a transport-persistence UI fence and the store's
// `isOwner`; neither exists here (single-user, no server persistence), so
// the fence is a constant false and ownership is expressed by
// `outlineProducer`: a course whose document a server job produced is
// server-owned, not client-authored, and therefore not this browser's to
// regenerate.
useEffect(() => {
if (
!shouldResumeClassroomGeneration({
loading,
error,
transportPersistenceFenced: false,
generationStarted: generationStartedRef.current,
})
) {
return;
}
const state = useStageStore.getState();
// Producer ownership is document data, not conversation status. A
// server-job course never starts a second browser-side generator no matter
// which chat is open (or whether any chat is open).
if (state.outlineProducer === 'server-job') {
generationStartedRef.current = true;
log.info('[Classroom] A server-side job owns this course; the browser will not generate.');
return;
}
const { outlines, scenes, stage, generationComplete } = state;
// Check if there are pending outlines. A finished deck is frozen for
// editing: deleting a slide leaves its outline orphaned, but that must not
// be treated as an interrupted generation and regenerated. Only resume
// when generation has not completed.
const completedOrders = new Set(scenes.map((s) => s.order));
const hasPending = !generationComplete && outlines.some((o) => !completedOrders.has(o.order));
if (hasPending && stage) {
generationStartedRef.current = true;
// Load generation params from sessionStorage (stored by generation-preview before navigating)
const genParamsStr = sessionStorage.getItem('generationParams');
const params = genParamsStr ? JSON.parse(genParamsStr) : {};
// Reconstruct imageMapping for the resumed generation. The mapping may
// MIX allocated asset ids and IndexedDB data URLs — a source whose cache
// write failed materialized its own images — so the resume mapping merges
// both, instead of choosing one transport for the whole set and silently
// dropping the other half.
const pdfImages = (params.pdfImages || []) as Array<
{ id: string; assetId?: string; storageId?: string } & Record<string, unknown>
>;
const finishResume = (imageMapping: Record<string, string>) =>
generateRemaining({
pdfImages: params.pdfImages,
imageMapping,
stageInfo: {
name: stage.name || '',
description: stage.description,
style: stage.style,
},
agents: params.agents,
userProfile: params.userProfile,
languageDirective: params.languageDirective || stage.languageDirective,
});
const imageMapping: Record<string, string> = {};
for (const img of pdfImages) {
if (img.assetId) imageMapping[img.id] = img.assetId;
}
const storageIds = pdfImages
.filter((img) => !img.assetId && img.storageId)
.map((img) => img.storageId as string);
void (async () => {
if (storageIds.length > 0) {
Object.assign(imageMapping, await loadImageMapping(storageIds));
}
finishResume(imageMapping);
})();
} else if (outlines.length > 0 && stage) {
// All scenes are generated, but some media may not have finished.
// Resume media generation for any tasks not yet in IndexedDB.
// generateMediaForOutlines skips already-completed tasks automatically.
generationStartedRef.current = true;
// The deck reached the classroom already fully materialized (e.g. a
// single-slide course, or a deck whose last slide finished in
// generation-preview), so generateRemaining's completion path never
// ran. Record completion now so a later edit/delete is not treated as
// an interrupted generation. No-op if already complete or not all
// outlines have scenes.
useStageStore.getState().markGenerationCompleteIfDone();
// Resume media only for outlines that still have a scene. On a finished
// deck the user may have deleted a slide, leaving an orphaned outline;
// generating its media would waste API calls on a slide that is gone.
const materializedOrders = new Set(scenes.map((s) => s.order));
const materializedOutlines = outlines.filter((o) => materializedOrders.has(o.order));
generateMediaForOutlines(materializedOutlines, stage.id).catch((err) => {
log.warn('[Classroom] Media generation resume error:', err);
});
}
}, [loading, error, generateRemaining]);
return (
<ThemeProvider>
<MediaStageProvider value={classroomId}>
<div
className={
variant === 'pane'
? // A flex CHILD of the pane's row box, so it has to claim both
// axes explicitly: `h-full` alone leaves the width to shrink
// to content, and the classroom chrome (which layers with
// `absolute inset-0`) then has nothing to fill.
'flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden'
: 'h-screen flex flex-col overflow-hidden'
}
>
{loading || (variant === 'pane' && !error && loadedClassroomId !== classroomId) ? (
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p>{t('common.loadingClassroom')}</p>
</div>
</div>
) : notFound ? (
// Checked BEFORE `error`, and it renders no retry: the sources have
// all answered, and running the same lookups again cannot change
// the answer. One message for "deleted" and for "never existed" —
// see the state's declaration.
<div
className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900"
data-testid="classroom-not-found"
>
<div className="flex flex-col items-center gap-3 text-center max-w-md px-6">
<FileQuestion className="h-10 w-10 text-muted-foreground" />
<p className="text-lg font-medium">{t('classroom.notFound')}</p>
<p className="text-sm text-muted-foreground">{t('classroom.notFoundDesc')}</p>
<Link
href="/"
className="mt-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
{t('classroom.backToHome')}
</Link>
</div>
</div>
) : error ? (
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<p className="text-destructive mb-4">
{t('common.errorPrefix')}
{error}
</p>
<button
onClick={() => {
setError(null);
setLoading(true);
void loadClassroom().then((outcome) => {
if (variant === 'pane' && outcome === 'unavailable') {
setLoading(false);
setError(t('classroom.notFound'));
}
});
}}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
{t('common.retry')}
</button>
</div>
</div>
) : (
<Stage classroomId={classroomId} onRetryOutline={retrySingleOutline} />
)}
</div>
</MediaStageProvider>
</ThemeProvider>
);
}
+224 -167
View File
@@ -1,9 +1,17 @@
'use client';
/**
* ActionsBar — Pro-mode "讲解脚本" bottom bar, a horizontal film-editing timeline
* ActionsBar — the "narration script" timeline: a horizontal film-editing strip
* that is also a light editor for the scene's playback `actions`.
*
* This WAS the whole bottom bar. It is now the body of `EditDock`, which owns
* the surface (border, blur, fold) and the global edit bar above it; the
* timeline still renders its own header row — the row's controls and the body
* share one piece of state — and drives the dock's fold through `useEditDock`.
* Nothing about the timeline's own behaviour or geometry changed in the move.
* (The height-drag handle was removed per product decision: only the fold moves
* the dock's height, so the timeline no longer sizes itself.)
*
* The scene's `actions` ARE the timeline: walked left→right, each `speech`
* becomes an editable clip block (one spoken line, numbered) and every non-speech
* cue (spotlight / laser / board) becomes a compact card pinned at its place in
@@ -12,12 +20,13 @@
*
* Editing (persisted via useStageStore.updateScene → actions-edit ops):
* - speech clip text is editable inline (commit on blur);
* - the header "添加动作" pill opens ActionPicker to insert a new action;
* - the header "Add action" pill opens ActionPicker to insert a new action;
* - existing items drag to reorder; each card carries a delete button;
* - clicking an element-bound cue arms canvas pick mode (useCanvasStore.pickTarget),
* so the target is chosen by clicking the element directly on the slide.
* - clicking an element-bound cue arms canvas pick mode (useCanvasStore.pickTarget
* with purpose 'cue'), so the target is chosen by clicking the element directly
* on the slide.
*
* Collapsible; height-resizable from the top edge; reactive to the stage store.
* Reactive to the stage store; collapse and height belong to the dock.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -30,9 +39,11 @@ import {
Flag,
FoldVertical,
GripVertical,
Loader2,
Play,
Plus,
RefreshCw,
Square,
Trash2,
UnfoldVertical,
Volume2,
@@ -71,11 +82,13 @@ import {
setDiscussionTopicById,
setSpeechTextClearAudioById,
} from './actions-edit';
import { useEditDock } from '@/components/edit/EditDock/dock-context';
import { ActionPicker } from './ActionPicker';
import type { PickerType } from './picker-options';
import {
audioExists,
audioObjectUrl,
discardSpeechAudio,
regenerateSpeechAudio,
resolveLegacySpeechAudioId,
resolveSpeechAudioId,
@@ -87,6 +100,16 @@ const EMPTY_ELEMENTS: { id?: string; type: string; content?: string }[] = [];
// on every reset and keeps a constant identity between batch runs).
const NO_IDS: ReadonlySet<string> = new Set();
/**
* Module-level single-flight controller for TTS preview: at most one
* SpeechTtsBar may be loading or playing at any moment, across every speech
* clip in the timeline. A bar registers its own `stop` handle when it starts a
* preview and clears it in `stopPreview` only when it is still the registered
* owner — so a stale stop (a superseded attempt, or an unmounted non-active
* bar) never kills the currently active preview.
*/
let activePreview: { stop: () => void } | null = null;
/**
* Clear the canvas spotlight/laser preview when a cue glyph unmounts while it is
* being hovered — most importantly when the user deletes the cue. React does not
@@ -106,10 +129,6 @@ function useClearCuePreviewOnUnmount() {
*/
const INCOMPLETE_CLIP = 'border-dashed border-amber-400/70';
const MIN_H = 168;
const MAX_H = 520;
const DEFAULT_H = 224;
const LINE_H = 86; // height when collapsed to just the axis line of node icons (fits the chips)
const AXIS_FROM_TOP = 20; // px from track top to the axis center (nodes hang below it)
// Radix Select forbids an empty-string item value, so the discussion's
@@ -165,7 +184,7 @@ function CueTooltip({ tip }: { tip: TooltipState }) {
}
// Native HTML5 drag snapshots the element's square bounding box, so a round
// icon chip drags with white corners ("白边"). Suppress the ghost with a 1×1
// icon chip drags with white corners ("white border"). Suppress the ghost with a 1×1
// transparent image — the violet drop indicator carries the feedback instead.
let blankDragImg: HTMLImageElement | null = null;
function setBlankDragImage(e: React.DragEvent) {
@@ -249,8 +268,11 @@ function MoveButtons({
type TtsStatus = 'none' | 'ready' | 'generating' | 'error';
/** Audio status + 试听 / 重新生成 row, shown when managed TTS is on. */
function SpeechTtsBar({
/** TTS preview lifecycle: idle → loading (awaiting the blob URL) → playing → idle. */
type PreviewPhase = 'idle' | 'loading' | 'playing';
/** Audio status + preview / regenerate row, shown when managed TTS is on. */
export function SpeechTtsBar({
actionId,
audioId,
audioUrl,
@@ -272,16 +294,22 @@ function SpeechTtsBar({
text: string;
refreshKey?: number;
regenerating?: boolean;
onGenerated: (audioId: string) => Promise<void>;
/**
* Notification that regeneration succeeded. Carries the freshly allocated
* audioId so the caller can stamp it on the action: this tree allocates pool
* identities (the blob is stored under the returned id), so the id cannot be
* re-derived by the caller like the reference's deterministic key.
*/
onGenerated: (audioId: string) => void;
}) {
const { t } = useI18n();
const [status, setStatus] = useState<TtsStatus>('none');
// Holds this line in 生成中 across a batch ("全部配音") run and — crucially —
// until its OWN audio re-check resolves, so it can't briefly flash back to
// 未配音 in the window between the batch clearing `regenerating` and the async
// audioExists effect landing. Latched on the rising edge of `regenerating`,
// cleared inside that re-check effect (which the batch always re-triggers via
// `refreshKey`).
// Holds this line in the generating state across a batch ("Voice all") run
// and — crucially — until its OWN audio re-check resolves, so it can't
// briefly flash back to not voiced in the window between the batch clearing
// `regenerating` and the async audioExists effect landing. Latched on the
// rising edge of `regenerating`, cleared inside that re-check effect (which
// the batch always re-triggers via `refreshKey`).
const [batchPending, setBatchPending] = useState(false);
const [prevRegenerating, setPrevRegenerating] = useState(regenerating);
if (regenerating !== prevRegenerating) {
@@ -290,6 +318,14 @@ function SpeechTtsBar({
setPrevRegenerating(regenerating);
if (regenerating) setBatchPending(true);
}
// TTS preview state machine — local UI state for the preview button, kept
// apart from `status`/`effStatus` (audio availability + regeneration), which
// the playback lifecycle must not disturb.
const [previewPhase, setPreviewPhase] = useState<PreviewPhase>('idle');
// Generation token: every `stopPreview` (and every fresh `preview`) bumps it,
// so an in-flight `audioObjectUrl` await can tell it was superseded and drop
// its result — this is what kills the double-click double-Audio race.
const previewTokenRef = useRef(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
const objUrlRef = useRef<string | null>(null);
@@ -302,12 +338,19 @@ function SpeechTtsBar({
}
const stopPreview = useCallback(() => {
// Invalidate every in-flight `preview()` await — a newer click, a takeover
// by another bar, or an unmount all funnel through here.
previewTokenRef.current += 1;
// Only the registered owner clears the module-level handle: a stale stop
// from a superseded or unmounted bar must not kill the current preview.
if (activePreview?.stop === stopPreview) activePreview = null;
audioRef.current?.pause();
audioRef.current = null;
if (objUrlRef.current) {
URL.revokeObjectURL(objUrlRef.current);
objUrlRef.current = null;
}
setPreviewPhase('idle');
}, []);
useEffect(() => {
@@ -335,7 +378,7 @@ function SpeechTtsBar({
// pre-batch check that resolves mid-batch must NOT clear it (adding
// regenerating to the deps also cancels such a check at batch start via
// the cleanup below). Runs even if the read threw, so the row can never
// wedge in 生成中.
// wedge in the generating state.
if (alive && !regenerating) setBatchPending(false);
}
})();
@@ -347,17 +390,42 @@ function SpeechTtsBar({
useEffect(() => () => stopPreview(), [stopPreview]);
const preview = async () => {
stopPreview();
// Global single-flight: stop whatever is loading/playing in ANY bar first.
// This also bumps the token, so this bar's own previous in-flight attempt
// is already invalidated by the time we capture the fresh token below.
activePreview?.stop();
const token = ++previewTokenRef.current;
setPreviewPhase('loading');
activePreview = { stop: stopPreview };
// The legacy URL of an unconverted pair is the narration when no pool or
// Dexie id resolved -- or when the resolved id turns out to have no local
// bytes, which is exactly the dangling-id case the URL survives for.
const src = (readAudioId ? await audioObjectUrl(readAudioId) : null) ?? audioUrl ?? null;
if (!src) return;
if (token !== previewTokenRef.current) {
// Superseded while loading (a newer click, a takeover, a stop): drop the
// result and revoke any blob URL we minted — the winner is in charge.
if (src && src.startsWith('blob:')) URL.revokeObjectURL(src);
return;
}
if (!src) {
stopPreview();
return;
}
objUrlRef.current = src;
const a = new Audio(src);
audioRef.current = a;
a.addEventListener('ended', stopPreview);
void a.play().catch(() => stopPreview());
a.addEventListener('error', stopPreview);
try {
await a.play();
// Stopped while play() was settling (e.g. a takeover in the gap): the
// stop already paused it, nothing more to do.
if (token !== previewTokenRef.current) return;
setPreviewPhase('playing');
} catch {
// Autoplay rejection etc. — treat like any other stop.
stopPreview();
}
};
const regenerate = async () => {
@@ -371,7 +439,7 @@ function SpeechTtsBar({
);
if (id) {
setReadAudioId(id);
await onGenerated(id);
onGenerated(id);
setStatus('ready');
} else {
setStatus('none');
@@ -390,13 +458,22 @@ function SpeechTtsBar({
},
error: { label: t('edit.tts.statusError'), cls: 'text-rose-500' },
};
// A batch "全部配音" run drives this line's loading state from the parent
// A batch "Voice all" run drives this line's loading state from the parent
// (regenerating) — independent of the local single-line status. `batchPending`
// extends 生成中 past the prop clearing, until this line's own audio re-check
// resolves to 已配音 / 未配音, so the batch end shows a clean 生成中 → 已配音
// transition with no intermediate flash.
// extends the generating state past the prop clearing, until this line's own
// audio re-check resolves to voiced / not voiced, so the batch end shows a
// clean generating → voiced transition with no intermediate flash.
const effStatus: TtsStatus = regenerating || batchPending ? 'generating' : status;
const s = STATUS[effStatus];
const previewLabel =
previewPhase === 'loading'
? t('edit.tts.cancelPreview')
: previewPhase === 'playing'
? t('edit.tts.stopPreview')
: t('edit.tts.preview');
// idle → Play; loading → spinner (click cancels the load); playing → Stop.
const PreviewIcon =
previewPhase === 'loading' ? Loader2 : previewPhase === 'playing' ? Square : Play;
return (
<div className="flex items-center gap-1 border-t border-border/60 px-2 py-1">
@@ -405,13 +482,18 @@ function SpeechTtsBar({
<span className="ml-auto" />
<button
type="button"
onClick={preview}
onClick={previewPhase === 'idle' ? () => void preview() : stopPreview}
disabled={effStatus !== 'ready'}
className="grid size-5 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30 disabled:hover:bg-transparent"
aria-label={t('edit.tts.preview')}
title={t('edit.tts.preview')}
className={cn(
'grid size-5 place-items-center rounded-md transition-colors disabled:opacity-30 disabled:hover:bg-transparent',
previewPhase === 'playing'
? 'text-rose-500 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400'
: 'text-muted-foreground/60 hover:bg-muted hover:text-foreground',
)}
aria-label={previewLabel}
title={previewLabel}
>
<Play className="size-3" />
<PreviewIcon className={cn('size-3', previewPhase === 'loading' && 'animate-spin')} />
</button>
<button
type="button"
@@ -465,7 +547,7 @@ function SpeechClip({
ttsRefresh?: number;
regenerating?: boolean;
onCommit: (text: string) => void;
onGenerated: (audioId: string) => Promise<void>;
onGenerated: (audioId: string) => void;
onDelete: () => void;
onMoveLeft: () => void;
onMoveRight: () => void;
@@ -963,7 +1045,7 @@ export function ActionsBar({ sceneId }: { sceneId: string }) {
| undefined
)?.canvas?.elements ?? EMPTY_ELEMENTS;
const language = useStageStore((s) => s.stage?.languageDirective);
// Managed TTS on → speech clips show audio status + 试听 / 重新生成.
// Managed TTS on → speech clips show audio status + preview / regenerate.
const ttsActive = useSettingsStore(
(s) => s.ttsEnabled && s.ttsProviderId !== 'browser-native-tts',
);
@@ -986,14 +1068,17 @@ export function ActionsBar({ sceneId }: { sceneId: string }) {
[selectedAgentIds, agentsRecord],
);
const [lineMode, setLineMode] = useState(false); // collapse to just the axis line of node icons
// Collapsed = the dock's own fold, which this bar's title and its trailing
// toggle both drive. The timeline's collapsed form is its axis of node icons,
// so it reads `collapsed` rather than being hidden by the shell.
const { collapsed: lineMode, toggleCollapsed } = useEditDock();
const [tip, setTip] = useState<TooltipState | null>(null);
const [dragOver, setDragOver] = useState<number | null>(null);
const [focusId, setFocusId] = useState<string | null>(null);
const [regenAll, setRegenAll] = useState(false);
const [pickerAt, setPickerAt] = useState<{ slot: number; rect: DOMRect } | null>(null);
// Ids of speech lines currently being (re)generated by "全部配音", so each
// line's status row shows 生成中 for the duration of the batch.
// Ids of speech lines currently being (re)generated by "Voice all", so each
// line's status row shows the generating state for the duration of the batch.
const [regeneratingIds, setRegeneratingIds] = useState<ReadonlySet<string>>(NO_IDS);
const [ttsRefresh, setTtsRefresh] = useState(0); // bump → speech clips re-check audio status
const reduce = useReducedMotion();
@@ -1056,55 +1141,14 @@ export function ActionsBar({ sceneId }: { sceneId: string }) {
setRegeneratingIds(NO_IDS);
// Always re-check every line's audio at batch end — even when nothing
// synthesized — so each SpeechTtsBar resolves its status (and clears its
// batchPending flag) instead of getting stuck in 生成中.
// batchPending flag) instead of getting stuck in the generating state.
setTtsRefresh((n) => n + 1);
}
}, [regenAll, sceneId, language, commit]);
// Height drag-resize (top edge).
const sectionRef = useRef<HTMLElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const panViewport = (dir: -1 | 1) =>
scrollRef.current?.scrollBy({ left: dir * 280, behavior: 'smooth' });
const [height, setHeight] = useState(DEFAULT_H);
const resizeRef = useRef<{
startY: number;
startH: number;
lastH: number;
pointerId: number;
} | null>(null);
const onResizeStart = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const startH = sectionRef.current?.getBoundingClientRect().height ?? height;
resizeRef.current = { startY: e.clientY, startH, lastH: startH, pointerId: e.pointerId };
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* best effort */
}
document.body.style.cursor = 'row-resize';
},
[height],
);
const onResizeMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const d = resizeRef.current;
if (!d || e.pointerId !== d.pointerId) return;
const next = Math.min(MAX_H, Math.max(MIN_H, d.startH + (d.startY - e.clientY)));
d.lastH = next;
if (sectionRef.current) sectionRef.current.style.height = `${next}px`;
}, []);
const onResizeEnd = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const d = resizeRef.current;
if (!d || e.pointerId !== d.pointerId) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
/* may already be released */
}
setHeight(d.lastH);
resizeRef.current = null;
document.body.style.cursor = '';
}, []);
const newId = () =>
typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `a-${Date.now()}`;
@@ -1152,102 +1196,94 @@ export function ActionsBar({ sceneId }: { sceneId: string }) {
return { action, index, key: (action.id ?? `a-${index}`) as string, speechIndex };
});
return (
<section
ref={sectionRef}
style={{ height: lineMode ? LINE_H : height }}
className="relative flex flex-col border-t border-gray-100 bg-white/80 backdrop-blur-xl dark:border-gray-800 dark:bg-slate-900/80"
>
const header = (
<>
{/* The title is also the fold: it was a click target long before there was
a toggle beside it, so the cursor already knows this spot. */}
<button
type="button"
data-testid="edit-timeline-title"
onClick={toggleCollapsed}
className="flex shrink-0 items-center gap-2.5"
>
<span className="size-1.5 rounded-full bg-primary" />
<span className="text-[12px] font-medium tracking-[0.18em] text-foreground/80">
{t('edit.timeline.title')}
</span>
</button>
{!lineMode && (
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
className="group absolute inset-x-0 top-0 z-10 h-1.5 cursor-row-resize touch-none transition-colors hover:bg-violet-400/30 active:bg-violet-500/50 dark:hover:bg-violet-500/30"
<button
type="button"
onClick={(e) => {
const slot = discussionPresent ? actions.length - 1 : actions.length;
setPickerAt({ slot, rect: e.currentTarget.getBoundingClientRect() });
}}
className="ml-1.5 inline-flex shrink-0 items-center gap-1 rounded-full border border-primary/25 bg-primary/10 px-2.5 py-0.5 text-[11px] font-medium text-primary transition-colors hover:bg-primary/15"
>
<div className="absolute left-1/2 top-[3px] h-0.5 w-9 -translate-x-1/2 rounded-full bg-gray-300 transition-colors group-hover:bg-violet-400 dark:bg-gray-600 dark:group-hover:bg-violet-500" />
</div>
<Plus className="size-3" />
{t('edit.timeline.addAction')}
<ChevronDown className="size-3 opacity-70" />
</button>
)}
<div className="flex h-10 shrink-0 items-center gap-2.5 px-6">
{!lineMode && ttsActive && (
<button
type="button"
onClick={() => setLineMode((v) => !v)}
className="flex items-center gap-2.5"
onClick={regenerateAllAudio}
disabled={regenAll}
title={t('edit.timeline.regenAllTts')}
aria-label={t('edit.timeline.regenAllTts')}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:border-primary/30 hover:text-foreground disabled:opacity-50"
>
<span className="size-1.5 rounded-full bg-primary" />
<span className="text-[12px] font-medium tracking-[0.18em] text-foreground/80">
{t('edit.timeline.title')}
</span>
<RefreshCw className={cn('size-3', regenAll && 'animate-spin')} />
{t('edit.timeline.voiceAll')}
</button>
)}
{!lineMode && (
<span className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground/60">
{t('edit.timeline.counts', { speech: speechCount, cue: cueCount })}
</span>
{/* pan the timeline viewport left/right */}
{!lineMode && (
<div className="flex shrink-0 items-center border-l border-gray-200/70 pl-1 dark:border-gray-700/60">
<button
type="button"
onClick={(e) => {
const slot = discussionPresent ? actions.length - 1 : actions.length;
setPickerAt({ slot, rect: e.currentTarget.getBoundingClientRect() });
}}
className="ml-3 inline-flex items-center gap-1 rounded-full border border-primary/25 bg-primary/10 px-2.5 py-0.5 text-[11px] font-medium text-primary transition-colors hover:bg-primary/15"
onClick={() => panViewport(-1)}
title={t('edit.timeline.panLeft')}
aria-label={t('edit.timeline.panLeft')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<Plus className="size-3" />
{t('edit.timeline.addAction')}
<ChevronDown className="size-3 opacity-70" />
<ChevronsLeft className="size-4" />
</button>
)}
{!lineMode && ttsActive && (
<button
type="button"
onClick={regenerateAllAudio}
disabled={regenAll}
title={t('edit.timeline.regenAllTts')}
aria-label={t('edit.timeline.regenAllTts')}
className="ml-1.5 inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:border-primary/30 hover:text-foreground disabled:opacity-50"
onClick={() => panViewport(1)}
title={t('edit.timeline.panRight')}
aria-label={t('edit.timeline.panRight')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<RefreshCw className={cn('size-3', regenAll && 'animate-spin')} />
{t('edit.timeline.voiceAll')}
<ChevronsRight className="size-4" />
</button>
)}
<span className="ml-auto font-mono text-[11px] tabular-nums text-muted-foreground/60">
{t('edit.timeline.counts', { speech: speechCount, cue: cueCount })}
</span>
{/* pan the timeline viewport left/right */}
{!lineMode && (
<div className="ml-1 flex items-center border-l border-gray-200/70 pl-1 dark:border-gray-700/60">
<button
type="button"
onClick={() => panViewport(-1)}
title={t('edit.timeline.panLeft')}
aria-label={t('edit.timeline.panLeft')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronsLeft className="size-4" />
</button>
<button
type="button"
onClick={() => panViewport(1)}
title={t('edit.timeline.panRight')}
aria-label={t('edit.timeline.panRight')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronsRight className="size-4" />
</button>
</div>
)}
<button
type="button"
onClick={() => setLineMode((v) => !v)}
title={lineMode ? t('edit.timeline.expandTrack') : t('edit.timeline.collapseAxis')}
aria-label={lineMode ? t('edit.timeline.expandTrack') : t('edit.timeline.collapseAxis')}
className="ml-1 grid size-7 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
{lineMode ? <UnfoldVertical className="size-4" /> : <FoldVertical className="size-4" />}
</button>
</div>
</div>
)}
<button
type="button"
onClick={toggleCollapsed}
title={lineMode ? t('edit.timeline.expandTrack') : t('edit.timeline.collapseAxis')}
aria-label={lineMode ? t('edit.timeline.expandTrack') : t('edit.timeline.collapseAxis')}
className="ml-1 grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
{lineMode ? <UnfoldVertical className="size-4" /> : <FoldVertical className="size-4" />}
</button>
</>
);
return (
<>
{/* The timeline's own header row: what it is, what it can insert, how much
it holds, and its fold. Geometry unchanged from the standalone bar. */}
<div className="flex h-10 shrink-0 items-center gap-2 px-6">{header}</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
<div className="relative h-full min-w-max">
{/* the timeline axis (top) — nodes hang below it; hidden when empty
@@ -1287,9 +1323,17 @@ export function ActionsBar({ sceneId }: { sceneId: string }) {
setDragOver(null);
};
const onPick = () =>
useCanvasStore
.getState()
.setPickTarget({ sceneId, actionId: key, cueType: action.type });
useCanvasStore.getState().setPickTarget(
useStageStore.getState().stage?.id
? {
purpose: 'cue',
stageId: useStageStore.getState().stage!.id,
sceneId,
actionId: key,
cueType: action.type,
}
: null,
);
const dot = (
<NodeDot
action={action}
@@ -1338,14 +1382,27 @@ export function ActionsBar({ sceneId }: { sceneId: string }) {
autoFocus={key === focusId}
onFocused={() => setFocusId(null)}
onCommit={(text) => {
// Editing invalidates the document reference but leaves the old
// pool entry for the document-truth sweep introduced in part 3.
// Editing the text invalidates any cached audio
// (the blob is keyed by order+id and the stamped
// audioId, not the text), so drop the stamped
// fields and delete the blob — the line then
// reads as un-voiced until regen.
const prevAudioId = (action as { audioId?: string }).audioId;
commit((cur) => setSpeechTextClearAudioById(cur, key, text));
setTtsRefresh((n) => n + 1);
// Re-check status only AFTER the blob is gone, so
// the status row can't race the async delete and
// briefly still read "voiced".
void discardSpeechAudio(sceneOrder, {
id: key,
audioId: prevAudioId,
}).finally(() => setTtsRefresh((n) => n + 1));
}}
onGenerated={async (assetId) => {
onGenerated={(assetId) => {
commit((cur) => setAudioIdById(cur, key, assetId));
await flushStageSave();
// Stage persistence is debounced; flush so the
// stamped reference is durable once the row
// settles (pool bytes persist first, stamp last).
void flushStageSave().catch(() => undefined);
}}
onDelete={() => {
commit((cur) => removeById(cur, key));
@@ -1418,6 +1475,6 @@ export function ActionsBar({ sceneId }: { sceneId: string }) {
onClose={() => setPickerAt(null)}
/>
)}
</section>
</>
);
}
+2 -2
View File
@@ -109,8 +109,8 @@ export function setSpeechTextById(actions: Action[], id: string, text: string):
* Edit a speech line's text AND drop its stamped audio fields (index-stale-safe).
* The cached audio blob is keyed by sceneOrder+actionId, not the text, so an
* edit must invalidate it or the stale audio would replay for the new wording —
* after this the line reads as un-voiced until regenerated. The invalidation
* marker prevents fallback to a stale legacy derived-id row without deleting it.
* after this the line reads as un-voiced until regenerated. (Deleting the blob
* itself is done separately via `discardSpeechAudio`.)
*/
export function setSpeechTextClearAudioById(actions: Action[], id: string, text: string): Action[] {
const index = actions.findIndex((a) => a.id === id);
+2 -17
View File
@@ -20,6 +20,7 @@ import {
Table2,
type LucideIcon,
} from 'lucide-react';
import { elementRefLabel } from '@/lib/workbench/element-refs';
/** Translator fn (matches useI18n's `t`) — passed in so this module stays hook-free. */
type TFn = (key: string, options?: Record<string, unknown>) => string;
@@ -122,23 +123,7 @@ export function cueLabel(type: string, t: TFn): string {
/** Cue types that target a canvas element (so canvas pick mode applies). */
export const ELEMENT_BOUND = new Set(['spotlight', 'laser', 'play_video']);
const EL_TYPE_KEY: Record<string, string> = {
text: 'edit.element.text',
image: 'edit.element.image',
shape: 'edit.element.shape',
line: 'edit.element.line',
chart: 'edit.element.chart',
table: 'edit.element.table',
latex: 'edit.element.latex',
video: 'edit.element.video',
audio: 'edit.element.audio',
code: 'edit.element.code',
};
/** Human label for a slide element (localized type + a short content snippet). */
export function elementLabel(el: { type: string; content?: string }, t: TFn): string {
const typeLabel = EL_TYPE_KEY[el.type] ? t(EL_TYPE_KEY[el.type]) : el.type;
const raw = (el.content ?? '').replace(/<[^>]+>/g, '').trim();
const snip = raw ? ` · ${raw.slice(0, 16)}${raw.length > 16 ? '…' : ''}` : '';
return `${typeLabel}${snip}`;
return elementRefLabel(el, t);
}
-594
View File
@@ -1,594 +0,0 @@
'use client';
/**
* MAIC Agent — editor AI sidebar (right rail), "Edit with AI" Cursor-style
* surface per the OpenMAIC AgentSidebar design board:
* - user messages are right-aligned solid-violet bubbles (radius 14/14/4/14);
* - assistant output is full-width markdown with design-language tool cards in
* chronological order;
* - the composer is a bordered shell with a violet focus glow, an @-context
* chip for the active scene, horizontally-scrolling quick-prompt chips, and a
* square violet send button.
* Only design aspects with real V0 backing are implemented — the model picker,
* Agent/Ask mode, checkpoints/Restore, reasoning blocks and per-element @-chips
* from the board are intentionally omitted (no runtime support yet).
* Wiring (ExternalStore over the pi AgentEvent SSE stream) lives in
* use-agent-runtime.
*/
import { useCallback, useRef, useState } from 'react';
import {
AssistantRuntimeProvider,
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
useComposerRuntime,
useMessage,
type AssistantRuntime,
} from '@assistant-ui/react';
import {
ArrowUp,
AtSign,
ChevronDown,
History,
PanelRightClose,
PanelRightOpen,
Sparkles,
Square,
SquarePen,
Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { AgentEditSessionRecord } from '@/lib/agent/client/agent-edit-session-types';
import { useI18n } from '@/lib/hooks/use-i18n';
import { SpeechButton } from '@/components/audio/speech-button';
import { MarkdownText } from './markdown-text';
import { ReasoningPart } from './reasoning-part';
import { RegenerateSceneActionsUI } from './regenerate-tool-ui';
import { RegenerateSceneUI } from './regenerate-scene-tool-ui';
import { EditInteractiveHtmlUI } from './edit-interactive-html-tool-ui';
import { EditElementsUI } from './edit-elements-tool-ui';
import { ReadSceneContentUI } from './read-tool-ui';
const MIN_WIDTH = 320;
const MAX_WIDTH = 640;
const DEFAULT_WIDTH = 384;
/** Capability rows shown in the empty state — read-only tips (not clickable),
* each a label + example phrasings. One unified list describing what the agent
* can do across scenes (slide content + narration + interactive-page fixing +
* per-element edits), shown regardless of the active scene type. */
const CAPABILITY_KEYS = [
{ label: 'edit.agent.cap.content.label', examples: 'edit.agent.cap.content.examples' },
{ label: 'edit.agent.cap.narration.label', examples: 'edit.agent.cap.narration.examples' },
{ label: 'edit.agent.cap.elements.label', examples: 'edit.agent.cap.elements.examples' },
{ label: 'edit.agent.cap.fixHtml.label', examples: 'edit.agent.cap.fixHtml.examples' },
];
function UserMessage() {
return (
<MessagePrimitive.Root className="flex justify-end">
{/* Solid brand-violet bubble, right-aligned, with a tail toward the user
(radius 14/14/4/14) — per the design board's .ae-user. */}
<div className="min-w-0 max-w-[88%] rounded-[14px] rounded-br-[4px] bg-primary px-3.5 py-2 text-[13px] leading-relaxed text-white [overflow-wrap:anywhere]">
<MessagePrimitive.Parts />
</div>
</MessagePrimitive.Root>
);
}
function ThinkingIndicator() {
const { t } = useI18n();
// Cursor-style shimmer label — the bright band sweeps across the word while we
// wait for the next API call's first streamed token. Reasoning tokens are never
// rendered raw (think-blocks stripped upstream); thinking surfaces only here.
return (
<span className="ai-thinking-shimmer text-[13px] font-medium">{t('edit.agent.thinking')}</span>
);
}
function AssistantMessage() {
const { t } = useI18n();
// Separate primitive selectors — useMessage is backed by useSyncExternalStore
// (Object.is snapshot compare), so returning a fresh object literal would loop.
const hasContent = useMessage((m) =>
m.content.some(
(p) =>
(p.type === 'text' && p.text.length > 0) ||
p.type === 'tool-call' ||
(p.type === 'reasoning' && p.text.length > 0),
),
);
// Loading shows only while a NEW API call is pending its first token — not while
// tokens are streaming. Walk to the last meaningful part: live text → streaming
// (no loading); a finished tool call (result present) → next turn pending
// (loading); a still-running tool call → its card already spins (no loading);
// nothing yet → loading.
const showLoading = useMessage((m) => {
if (m.status?.type !== 'running') return false;
const parts = m.content as Array<{ type: string; text?: string; result?: unknown }>;
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p.type === 'text' && typeof p.text === 'string' && p.text.length > 0) return false;
if (p.type === 'tool-call') return p.result !== undefined;
// A reasoning part shows its own live "thinking…" label (with duration),
// so the separate shimmer indicator is redundant while reasoning streams.
if (p.type === 'reasoning' && typeof p.text === 'string' && p.text.length > 0) return false;
}
return true;
});
const stopped = useMessage((m) => m.status?.type !== 'running');
return (
<MessagePrimitive.Root className="min-w-0 space-y-2">
{hasContent ? (
<div className="min-w-0 space-y-2 text-[13px] leading-[1.6] text-foreground/90">
<MessagePrimitive.Parts components={{ Text: MarkdownText, Reasoning: ReasoningPart }} />
</div>
) : null}
{showLoading ? (
<ThinkingIndicator />
) : stopped && !hasContent ? (
<span className="text-[12px] text-muted-foreground/60">{t('edit.agent.stopped')}</span>
) : null}
</MessagePrimitive.Root>
);
}
/** Mic button that dictates into the composer. Lives inside ComposerPrimitive.Root
* so it can append the transcription to the composer text via the composer
* runtime. SpeechButton self-gates on ASR availability (disabled when off). */
function VoiceInputButton({ disabled }: { readonly disabled?: boolean }) {
const composer = useComposerRuntime();
return (
<SpeechButton
size="md"
className="size-[30px]"
disabled={disabled}
onTranscription={(text) => {
if (!text) return;
const cur = composer.getState().text ?? '';
const sep = cur && !cur.endsWith(' ') ? ' ' : '';
composer.setText(cur + sep + text);
}}
/>
);
}
interface AgentPanelProps {
readonly scene?: { id: string; title: string; type?: string };
readonly runtime: AssistantRuntime;
readonly clearThread: () => void;
readonly hasMessages: boolean;
readonly canSend: boolean;
readonly sessions: AgentEditSessionRecord[];
readonly activeSessionId: string | undefined;
readonly switchSession: (id: string) => Promise<void>;
readonly deleteSessionAndRefresh: (id: string) => Promise<void>;
readonly refreshSessions: () => Promise<void>;
/**
* When true, renders only the thread body (no aside wrapper, no header, no
* resize handle, no collapse state). Used when an outer container (e.g.
* RightRailTabs) owns the rail chrome.
*/
readonly naked?: boolean;
}
export function AgentPanel({
scene,
runtime,
clearThread,
hasMessages,
canSend,
sessions,
activeSessionId,
switchSession,
deleteSessionAndRefresh,
refreshSessions,
naked,
}: AgentPanelProps) {
const { t } = useI18n();
// Interactive scenes expose a different agent capability (fix the page's bugs)
// than slides (regenerate content/narration), so the empty-state copy and the
// composer placeholder switch by scene type.
// Empty-state copy is unified (no slide/interactive split) — the capability
// list above already covers fixing interactive pages. The composer placeholder
// still adapts to the active scene type.
const isInteractive = scene?.type === 'interactive';
const capabilityKeys = CAPABILITY_KEYS;
const emptyTitleKey = 'edit.agent.emptyTitle';
const emptyLeadKey = 'edit.agent.empty.lead';
const emptyBoundaryKey = 'edit.agent.empty.boundary';
const placeholderKey = isInteractive
? 'edit.agent.interactive.placeholder'
: 'edit.agent.placeholder';
const sceneTypeLabel =
scene?.type && ['slide', 'quiz', 'interactive', 'pbl'].includes(scene.type)
? t(`edit.sceneType.${scene.type}`)
: (scene?.type ?? 'Scene');
const unsupportedMessage = t('edit.unsupportedScene', { type: sceneTypeLabel });
// Drag-to-resize from the left edge (pointer capture, direct DOM write).
const railRef = useRef<HTMLElement>(null);
const [width, setWidth] = useState(DEFAULT_WIDTH);
const dragRef = useRef<{
startX: number;
startW: number;
lastW: number;
pointerId: number;
} | null>(null);
const onResizeStart = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const startW = railRef.current?.getBoundingClientRect().width ?? width;
dragRef.current = { startX: e.clientX, startW, lastW: startW, pointerId: e.pointerId };
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* best effort */
}
document.body.style.cursor = 'col-resize';
},
[width],
);
const onResizeMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
if (!d || e.pointerId !== d.pointerId) return;
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, d.startW + (d.startX - e.clientX)));
d.lastW = next;
if (railRef.current) railRef.current.style.width = `${next}px`;
}, []);
const onResizeEnd = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
if (!d || e.pointerId !== d.pointerId) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
/* may already be released */
}
setWidth(d.lastW);
dragRef.current = null;
document.body.style.cursor = '';
}, []);
const [collapsed, setCollapsed] = useState(false);
// Naked mode: outer container (RightRailTabs) owns the aside wrapper and chrome.
// Render only the thread body, no aside / header / resize / collapse state.
if (naked) {
return (
<AssistantRuntimeProvider runtime={runtime}>
<ReadSceneContentUI />
<RegenerateSceneActionsUI />
<RegenerateSceneUI />
<EditInteractiveHtmlUI />
<EditElementsUI />
<ThreadPrimitive.Root className="relative flex min-h-0 flex-1 flex-col">
<ThreadPrimitive.Viewport className="flex-1 space-y-6 overflow-y-auto px-4 py-5 scroll-smooth">
<ThreadPrimitive.Empty>
<div className="mx-auto mt-12 flex max-w-[268px] flex-col">
<p className="text-center text-sm font-medium text-foreground">
{t(emptyTitleKey)}
</p>
<p className="mt-1.5 text-center text-[12px] leading-relaxed text-muted-foreground">
{t(emptyLeadKey)}
</p>
<div className="mt-5 space-y-3">
{capabilityKeys.map(({ label, examples }) => (
<div key={label} className="flex flex-col gap-0.5">
<span className="text-[12px] font-semibold text-foreground">{t(label)}</span>
<span className="text-[11.5px] leading-relaxed text-[#5b1fa8]/70 dark:text-violet-300/70">
{t(examples)}
</span>
</div>
))}
</div>
<p className="mt-5 text-[11px] leading-relaxed text-muted-foreground/80">
{t(emptyBoundaryKey)}
</p>
<p className="mt-2 inline-flex items-center gap-1 text-[11px] text-muted-foreground/70">
<Sparkles className="size-3 text-[#5b1fa8]/60 dark:text-violet-300/60" />
{t('edit.agent.empty.comingSoon')}
</p>
</div>
</ThreadPrimitive.Empty>
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</ThreadPrimitive.Viewport>
<ThreadPrimitive.ScrollToBottom className="absolute bottom-2 left-1/2 grid size-7 -translate-x-1/2 place-items-center rounded-full border border-border bg-background text-muted-foreground shadow-sm transition-opacity hover:text-foreground disabled:pointer-events-none disabled:opacity-0">
<ChevronDown className="size-4" />
</ThreadPrimitive.ScrollToBottom>
<div className="px-3 pb-3 pt-1">
{!canSend ? (
<p className="mb-2 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-2 text-[11.5px] leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{unsupportedMessage}
</p>
) : null}
<ComposerPrimitive.Root className="rounded-[10px] border border-border bg-card shadow-sm transition-[border-color,box-shadow] focus-within:border-violet-400 focus-within:ring-[3px] focus-within:ring-violet-500/10 dark:focus-within:ring-violet-500/20">
{scene?.title ? (
<div className="px-2 pt-2">
<span className="inline-flex max-w-full items-center gap-1 rounded-md border border-violet-200 bg-violet-50 px-2 py-0.5 text-[11px] font-medium text-[#5b1fa8] dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300">
<AtSign className="size-3 shrink-0 text-violet-500" />
<span className="truncate">{scene.title}</span>
</span>
</div>
) : null}
<ComposerPrimitive.Input
minRows={1}
maxRows={6}
autoFocus={canSend}
disabled={!canSend}
placeholder={t(placeholderKey)}
className="block w-full resize-none bg-transparent px-3 pb-1 pt-2 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-60"
/>
<div className="flex items-center px-2 pb-2 pt-0.5">
<div className="ml-auto flex items-center gap-1">
<VoiceInputButton disabled={!canSend} />
<ThreadPrimitive.If running={false}>
<ComposerPrimitive.Send
disabled={!canSend}
className="grid size-[30px] shrink-0 place-items-center rounded-lg bg-primary text-white transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground/50"
>
<ArrowUp className="size-4" />
</ComposerPrimitive.Send>
</ThreadPrimitive.If>
<ThreadPrimitive.If running>
<button
type="button"
aria-label={t('edit.agent.stop')}
onClick={() => {
try {
runtime.thread.cancelRun();
} catch {
/* no run to cancel */
}
}}
className="grid size-[30px] shrink-0 place-items-center rounded-lg bg-primary text-white transition-colors hover:opacity-90"
>
<Square className="size-3 fill-current" />
</button>
</ThreadPrimitive.If>
</div>
</div>
</ComposerPrimitive.Root>
</div>
</ThreadPrimitive.Root>
</AssistantRuntimeProvider>
);
}
// Collapsed: a slim rail with the brand mark — click anywhere to reopen. The
// runtime is owned above this panel, so the conversation is preserved.
if (collapsed) {
return (
<aside
onClick={() => setCollapsed(false)}
title={t('edit.agent.expand')}
className="group/rail relative flex h-full w-11 shrink-0 cursor-pointer flex-col items-center gap-3 border-l border-gray-100 bg-white/80 pt-3 backdrop-blur-xl transition-colors hover:bg-violet-50/40 dark:border-gray-800 dark:bg-slate-900/80 dark:hover:bg-violet-500/5 shadow-[-2px_0_24px_rgba(0,0,0,0.02)]"
>
<span className="grid size-8 place-items-center rounded-lg text-[#5b1fa8] transition-colors group-hover/rail:bg-violet-100/70 dark:text-violet-300 dark:group-hover/rail:bg-violet-500/15">
<PanelRightOpen className="size-4" />
</span>
<Sparkles className="size-4 text-[#5b1fa8]/80 dark:text-violet-300/80" />
<span className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-[#5b1fa8]/70 [writing-mode:vertical-rl] dark:text-violet-300/70">
{t('edit.agent.title')}
</span>
</aside>
);
}
return (
<aside
ref={railRef}
style={{ width }}
// Mirrors SlideNavRail's surface (white/translucent glass, soft hairline,
// faint side shadow) so the two rails read as one chrome family.
className="relative flex h-full shrink-0 flex-col border-l border-gray-100 bg-white/80 backdrop-blur-xl dark:border-gray-800 dark:bg-slate-900/80 shadow-[-2px_0_24px_rgba(0,0,0,0.02)]"
>
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
className="group absolute left-0 top-0 bottom-0 z-10 w-1.5 cursor-col-resize touch-none transition-colors hover:bg-violet-400/30 active:bg-violet-500/50 dark:hover:bg-violet-500/30"
>
<div className="absolute left-0.5 top-1/2 h-8 w-0.5 -translate-y-1/2 rounded-full bg-gray-300 transition-colors group-hover:bg-violet-400 dark:bg-gray-600 dark:group-hover:bg-violet-500" />
</div>
{/* Header — "Edit with AI" with a violet sparkles mark (design .ae-head). */}
<header className="flex h-10 shrink-0 items-center gap-2 border-b border-gray-100 px-4 pl-5 dark:border-gray-800">
<Sparkles className="size-3.5 text-[#5b1fa8] dark:text-violet-300" />
<span className="text-[13px] font-semibold text-[#5b1fa8] dark:text-violet-300">
{t('edit.agent.title')}
</span>
<Popover onOpenChange={(open) => open && void refreshSessions()}>
<PopoverTrigger asChild>
<button
type="button"
title={t('edit.agent.sessionHistory')}
aria-label={t('edit.agent.sessionHistory')}
className="ml-auto grid size-7 place-items-center rounded-md text-muted-foreground/55 transition-colors hover:bg-muted hover:text-foreground"
>
<History className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-1">
{sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t('edit.agent.sessionEmpty')}
</p>
) : (
<ul className="max-h-80 overflow-y-auto">
{sessions.map((s) => (
<li key={s.id} className="group flex items-center gap-1">
<button
type="button"
onClick={() => void switchSession(s.id)}
className={cn(
'flex-1 truncate rounded-md px-2 py-1.5 text-left text-[13px] transition-colors hover:bg-muted',
s.id === activeSessionId
? 'bg-muted font-medium text-foreground'
: 'text-muted-foreground',
)}
>
{s.title || t('edit.agent.sessionUntitled')}
</button>
<button
type="button"
title={t('edit.agent.sessionDelete')}
aria-label={t('edit.agent.sessionDelete')}
onClick={() => void deleteSessionAndRefresh(s.id)}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground/40 opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</li>
))}
</ul>
)}
</PopoverContent>
</Popover>
{hasMessages ? (
<button
type="button"
onClick={clearThread}
title={t('edit.agent.newConversation')}
aria-label={t('edit.agent.newConversation')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/55 transition-colors hover:bg-muted hover:text-foreground"
>
<SquarePen className="size-4" />
</button>
) : null}
<button
type="button"
onClick={() => setCollapsed(true)}
title={t('edit.agent.collapse')}
aria-label={t('edit.agent.collapse')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/55 transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightClose className="size-4" />
</button>
</header>
<AssistantRuntimeProvider runtime={runtime}>
<ReadSceneContentUI />
<RegenerateSceneActionsUI />
<RegenerateSceneUI />
<EditInteractiveHtmlUI />
<EditElementsUI />
<ThreadPrimitive.Root className="relative flex min-h-0 flex-1 flex-col">
<ThreadPrimitive.Viewport className="flex-1 space-y-6 overflow-y-auto px-4 py-5 scroll-smooth">
<ThreadPrimitive.Empty>
{/* Capability tips — read-only (not clickable). Communicates what
the agent can actually do (content + narration + read), with
example phrasings, instead of clickable recommendation chips. */}
<div className="mx-auto mt-12 flex max-w-[268px] flex-col">
<p className="text-center text-sm font-medium text-foreground">
{t(emptyTitleKey)}
</p>
<p className="mt-1.5 text-center text-[12px] leading-relaxed text-muted-foreground">
{t(emptyLeadKey)}
</p>
<div className="mt-5 space-y-3">
{capabilityKeys.map(({ label, examples }) => (
<div key={label} className="flex flex-col gap-0.5">
<span className="text-[12px] font-semibold text-foreground">{t(label)}</span>
<span className="text-[11.5px] leading-relaxed text-[#5b1fa8]/70 dark:text-violet-300/70">
{t(examples)}
</span>
</div>
))}
</div>
<p className="mt-5 text-[11px] leading-relaxed text-muted-foreground/80">
{t(emptyBoundaryKey)}
</p>
<p className="mt-2 inline-flex items-center gap-1 text-[11px] text-muted-foreground/70">
<Sparkles className="size-3 text-[#5b1fa8]/60 dark:text-violet-300/60" />
{t('edit.agent.empty.comingSoon')}
</p>
</div>
</ThreadPrimitive.Empty>
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</ThreadPrimitive.Viewport>
<ThreadPrimitive.ScrollToBottom className="absolute bottom-2 left-1/2 grid size-7 -translate-x-1/2 place-items-center rounded-full border border-border bg-background text-muted-foreground shadow-sm transition-opacity hover:text-foreground disabled:pointer-events-none disabled:opacity-0">
<ChevronDown className="size-4" />
</ThreadPrimitive.ScrollToBottom>
{/* Composer (design .ae-composer): a bordered input shell with an
@-scene context chip, a voice-input mic, and a square violet send. */}
<div className="px-3 pb-3 pt-1">
{!canSend ? (
<p className="mb-2 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-2 text-[11.5px] leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{unsupportedMessage}
</p>
) : null}
<ComposerPrimitive.Root className="rounded-[10px] border border-border bg-card shadow-sm transition-[border-color,box-shadow] focus-within:border-violet-400 focus-within:ring-[3px] focus-within:ring-violet-500/10 dark:focus-within:ring-violet-500/20">
{scene?.title ? (
<div className="px-2 pt-2">
<span className="inline-flex max-w-full items-center gap-1 rounded-md border border-violet-200 bg-violet-50 px-2 py-0.5 text-[11px] font-medium text-[#5b1fa8] dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300">
<AtSign className="size-3 shrink-0 text-violet-500" />
<span className="truncate">{scene.title}</span>
</span>
</div>
) : null}
{/* minRows/maxRows are react-textarea-autosize's real knobs (its
height measurement breaks when fighting `rows`/max-h classes). */}
<ComposerPrimitive.Input
minRows={1}
maxRows={6}
autoFocus={canSend}
disabled={!canSend}
placeholder={t(placeholderKey)}
className="block w-full resize-none bg-transparent px-3 pb-1 pt-2 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-60"
/>
<div className="flex items-center px-2 pb-2 pt-0.5">
{/* Voice + send cluster on the right; the mic sits immediately
left of the send/stop button. Voice self-gates on ASR. */}
<div className="ml-auto flex items-center gap-1">
<VoiceInputButton disabled={!canSend} />
{/* Send while idle; Stop while a response streams. Stop calls the
thread runtime's cancelRun → our onCancel aborts the fetch. */}
<ThreadPrimitive.If running={false}>
<ComposerPrimitive.Send
disabled={!canSend}
className="grid size-[30px] shrink-0 place-items-center rounded-lg bg-primary text-white transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground/50"
>
<ArrowUp className="size-4" />
</ComposerPrimitive.Send>
</ThreadPrimitive.If>
<ThreadPrimitive.If running>
<button
type="button"
aria-label={t('edit.agent.stop')}
onClick={() => {
try {
runtime.thread.cancelRun();
} catch {
/* no run to cancel */
}
}}
className="grid size-[30px] shrink-0 place-items-center rounded-lg bg-primary text-white transition-colors hover:opacity-90"
>
<Square className="size-3 fill-current" />
</button>
</ThreadPrimitive.If>
</div>
</div>
</ComposerPrimitive.Root>
</div>
</ThreadPrimitive.Root>
</AssistantRuntimeProvider>
</aside>
);
}
@@ -1,146 +0,0 @@
# AgentBar polish — capability tips + voice input + unified tool rendering
- Date: 2026-06-21
- Surface: `components/edit/AgentPanel/*` (the "Edit with AI" editor sidebar)
- Builds on: the editor-agent feature (read_scene_content / regenerate_scene / regenerate_scene_actions)
- Status: design approved, pending spec review → implementation plan
## Background
Three issues with the current AgentBar:
1. **Stale, chip-driven guidance.** A row of clickable quick-prompt chips sits
above the composer (`重新生成讲解旁白 / 让讲解更口语一些 / 加一个生活化类比`),
and the empty state says "让 AI 重新生成与内容匹配的讲解旁白" — both still
frame the agent as a *narration regenerator*, which is stale: the agent now
regenerates the **whole slide** (content + narration) per instruction, reads
the slide, etc. The user wants the chips removed and replaced with clearer,
read-only **capability tips**.
2. **No voice input.** The composer has no dictation affordance.
3. **Inconsistent tool-call rendering.** Only `regenerate_scene` and
`regenerate_scene_actions` have registered tool UIs; `read_scene_content`
renders *nothing*, so a turn that reads the slide shows a blank gap between
the assistant's "let me look at this page" and its reply.
## Goal
Make the AgentBar communicate the agent's real capabilities clearly, support
voice input, and render every tool call consistently.
## Scope decisions (locked)
| Decision | Choice |
|---|---|
| Capability guidance placement | **Empty state only** (vanishes once a conversation starts) |
| Guidance interactivity | **Pure read-only text** (no clickable examples — not "recommendations") |
| Guidance form | **Grouped capability list** — title + lead + 3 labeled rows w/ examples + boundary + "coming soon" closer |
| Voice input | **Reuse `SpeechButton` + `useASRAvailable`**, in the composer footer |
| Tool rendering | **Shared `ToolCard` shell**; `read_scene_content` gets a light card; generic fallback for unregistered tools |
Non-goals: no change to agent capabilities/tools; no per-element @-chips; no
model picker; no streaming-reasoning UI.
## Component changes — `components/edit/AgentPanel/`
### 1. Empty-state capability tips (`AgentPanel.tsx`)
- **Remove** the `QUICK_PROMPT_KEYS` array and the `<ThreadPrimitive.Suggestion>`
chip row above the composer (the whole `scrollbar-hide … overflow-x-auto` div).
- **Replace** the single-line empty hint with a grouped capability list inside
the existing `<ThreadPrimitive.Empty>` block:
- Title: `有什么想改的?` (reuse `edit.agent.emptyTitle`)
- Lead: `告诉我这一页怎么改,我会重做内容并对齐讲解。`
- Three capability rows — each a bold/foreground **label** + one or two muted,
quoted **examples** (read-only, NOT buttons):
- `改内容` — `"精简成 3 个要点"` · `"加个生活化例子"`
- `改讲解` — `"讲得更口语一些"` · `"对齐我刚改的画布"`
- `问这页` — `"这页重点是什么?"`
- Boundary line (muted): `增删 / 排序幻灯片请用左侧导航`
- Closer (muted, with a sparkle): `更多能力陆续加入中,敬请期待 ✨`
- Visual: left-aligned rows within the existing centered ~260px container;
labels `text-foreground`, examples `text-muted-foreground`, small sizes,
consistent with the rail's existing type scale. Quoted examples may use the
brand-violet faintly to read as "things you can say".
### 2. Voice input button (`AgentPanel.tsx` composer footer)
- Import `SpeechButton` (`@/components/audio/speech-button`) and render it in the
composer's bottom action row, **left of** the Send/Stop button.
- Wire `onTranscription={(text) => appendToComposer(text)}`. The composer uses
assistant-ui's `ComposerPrimitive.Input` (not local state), so append via the
composer runtime: `useComposerRuntime().setText(currentText + (currentText && !endsWithSpace ? ' ' : '') + text)`. Confirm the exact assistant-ui API
(`useComposerRuntime` / `getState().text` / `setText`) against the installed
version; fall back to a ref on the underlying `<textarea>` if the runtime API
isn't exposed.
- `SpeechButton` already self-gates via `useASRAvailable()` (disabled when ASR is
off/unusable, except while actively recording). No extra gate needed; it simply
shows as disabled when no ASR is configured. (Availability depends on whether
an ASR provider is configured or browser-native ASR is present.)
- Style the button to match the composer chrome (same size box as Send, muted
until active, brand-violet while recording — `SpeechButton` owns its active
state; pass `className` to fit the footer).
### 3. Unified tool-call rendering
- **Extract** a shared `ToolCard` component (new `tool-card.tsx`) from the
current `regenerate-tool-ui.tsx` / `regenerate-scene-tool-ui.tsx` scaffolding:
the bordered `.ae-tool` shell — leading icon, truncating title, optional
`@scene` pill (reuse the existing `ScenePill`), a right-aligned status badge
(running = violet spinner, done = emerald check, failed = amber alert), an
optional **inline bar-action slot** (rendered on the always-visible header row,
e.g. for the Restore button), and an optional expandable body (children
render-prop). This also resolves the previously-deferred tool-card duplication
finding.
- **`read_scene_content` UI** (new `read-tool-ui.tsx`): register via
`makeAssistantToolUI({ toolName: 'read_scene_content', render })` using the
shared `ToolCard` — title `读取页面内容`, a book/eye glyph, the `@scene` pill,
done = check; **no heavy expandable body** (a read is lightweight). Running →
spinner, error → amber.
- **Refactor** `regenerate-tool-ui.tsx` and `regenerate-scene-tool-ui.tsx` to
render their bodies *inside* the shared `ToolCard` (keep their body content:
action breakdown / element-count). **Move the `regenerate_scene` Restore (还原)
button out of the body and onto the tool bar** via the bar-action slot — it is
currently buried (only reachable after expanding); surface it inline on the
always-visible card row so revert is one tap. The `RestoreButton` component is
unchanged; only its mount point moves to the bar-action slot. After restore,
the bar shows the muted "已还原 / restored" state inline.
- **Generic fallback**: register a fallback tool UI (assistant-ui Tool fallback,
or a catch-all `makeAssistantToolUI` per known tool name) so any unregistered
tool still renders a minimal `ToolCard` titled by a humanized tool name —
future tools never render blank.
- Mount the new `read_scene_content` UI (and fallback) alongside the existing
`<RegenerateSceneActionsUI />` / `<RegenerateSceneUI />` in `AgentPanel.tsx`.
## i18n
- **Remove** keys `edit.agent.quickRegenerate / quickColloquial / quickAnalogy`
from all 8 locales.
- **Update** `edit.agent.emptyHint` → repurpose as the lead, or add
`edit.agent.empty.lead`.
- **Add** (all 8 locales): `edit.agent.cap.content.{label,examples}`,
`edit.agent.cap.narration.{label,examples}`, `edit.agent.cap.ask.{label,examples}`,
`edit.agent.empty.boundary`, `edit.agent.empty.comingSoon`, and
`edit.agent.readCard.title` (`读取页面内容`). The `check:i18n-keys` gate must
pass (keys aligned across all locales).
## Testing
- AgentBar renders the grouped empty state (no chips); examples are plain text,
not buttons.
- `read_scene_content` tool call renders a `ToolCard` (title + done state) — a
unit/render check that the tool UI is registered and the shared shell renders.
- `ToolCard` shared shell: running/done/failed badge states; optional body.
- Voice button: appended transcription lands in the composer text (mock
`SpeechButton`/composer runtime); button absent/disabled when ASR unavailable.
- `check:i18n-keys`, tsc, eslint, prettier green.
## Risks / open points
- **Composer write API**: the one technical unknown is how to push the
transcription into assistant-ui's `ComposerPrimitive.Input`. Verify
`useComposerRuntime().setText` (or equivalent) exists in the installed
assistant-ui version before building; textarea-ref fallback otherwise.
- **Fallback tool UI**: confirm assistant-ui supports a catch-all/fallback tool
renderer in the installed version; if not, register the shared `ToolCard` per
known tool name and accept that brand-new tool names render blank until added
(still better than today for the known set).
@@ -1,97 +0,0 @@
'use client';
/**
* Tool-call UI for `edit_elements` (natural-language per-element edits).
* Minimal non-expandable ToolCard — title + @scene pill + localized status.
*/
import { Move } from 'lucide-react';
import { makeAssistantToolUI } from '@assistant-ui/react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { editElementsOutcome } from '@/lib/agent/client/edit-elements-result';
import { ToolCard, isStoppedResult, type ToolStatus } from './tool-card';
interface EditElementsResult {
content?: { type: string; text?: string }[];
details?: {
sceneId?: string;
intents?: unknown[] | null;
updateCount?: number;
refuseReason?: string;
};
}
function deriveEditElementsFailed(args: {
running: boolean;
stopped: boolean;
isError: boolean;
result?: EditElementsResult | null;
}): boolean {
const { running, stopped, isError, result } = args;
if (running || stopped) return false;
const outcome = editElementsOutcome(result?.details);
if (outcome === 'applied') return false;
return isError || outcome === 'refused';
}
export function EditElementsCard({
running,
stopped,
failed,
sceneId,
}: {
running: boolean;
stopped: boolean;
failed: boolean;
sceneId?: string;
}) {
const { t } = useI18n();
const toolStatus: ToolStatus = running
? 'running'
: stopped
? 'stopped'
: failed
? 'failed'
: 'done';
const baseLabel = running
? t('edit.editElements.editing')
: stopped
? t('edit.agent.stopped')
: failed
? t('edit.editElements.notApplied')
: t('edit.editElements.applied');
return (
<ToolCard
title={t('edit.editElements.title')}
icon={Move}
sceneId={sceneId}
status={toolStatus}
statusLabel={baseLabel}
/>
);
}
export const EditElementsUI = makeAssistantToolUI<
{ sceneId?: string; instruction?: string },
EditElementsResult
>({
toolName: 'edit_elements',
render: ({ args, status, result, isError }) => {
const running = status.type === 'running' || status.type === 'requires-action';
const stopped = !running && isStoppedResult(result);
const failed = deriveEditElementsFailed({
running,
stopped,
isError: !!isError,
result,
});
return (
<EditElementsCard
running={running}
stopped={stopped}
failed={failed}
sceneId={args?.sceneId ?? result?.details?.sceneId}
/>
);
},
});
@@ -1,81 +0,0 @@
'use client';
/**
* Tool-call UI for `edit_interactive_html` (interactive-scene str_replace edits).
* A minimal, NON-expandable `ToolCard` — just the title + @scene pill + status
* badge, plus the "还原 / Restore previous" button on the always-visible row.
* (No expandable body: the edit count / error detail is intentionally omitted.)
*/
import { Wrench } from 'lucide-react';
import { makeAssistantToolUI } from '@assistant-ui/react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { ToolCard, isStoppedResult, type ToolStatus } from './tool-card';
import { RestoreButton } from './restore-button';
import { deriveEditFailed, type EditInteractiveHtmlResult } from './edit-tool-state';
function EditInteractiveHtmlCard({
running,
stopped,
failed,
sceneId,
toolCallId,
}: {
running: boolean;
stopped: boolean;
failed: boolean;
sceneId?: string;
toolCallId: string;
}) {
const { t } = useI18n();
const toolStatus: ToolStatus = running
? 'running'
: stopped
? 'stopped'
: failed
? 'failed'
: 'done';
const statusLabel = running
? t('edit.fixHtml.fixing')
: stopped
? t('edit.agent.stopped')
: failed
? t('edit.fixHtml.notFixed')
: t('edit.fixHtml.fixed');
return (
<ToolCard
title={t('edit.fixHtml.title')}
icon={Wrench}
sceneId={sceneId}
status={toolStatus}
statusLabel={statusLabel}
// No Restore for a stopped/failed run — nothing was applied to revert.
barAction={!failed && !stopped ? <RestoreButton toolCallId={toolCallId} /> : undefined}
/>
);
}
export const EditInteractiveHtmlUI = makeAssistantToolUI<
{ sceneId?: string; edits?: { oldText: string; newText: string }[] },
EditInteractiveHtmlResult
>({
toolName: 'edit_interactive_html',
render: ({ args, status, result, isError, toolCallId }) => {
const running = status.type === 'running' || status.type === 'requires-action';
// The user cancelled the turn before this tool finished → loud stopped state.
const stopped = !running && isStoppedResult(result);
// Bias-to-success failure derivation (see edit-tool-state): only an explicit
// error or a null-html refusal is a failure — a successful apply, or a
// missing/slimmed result, is never "failed".
const failed = deriveEditFailed({ running, stopped, isError: !!isError, result });
return (
<EditInteractiveHtmlCard
running={running}
stopped={stopped}
failed={failed}
sceneId={args?.sceneId ?? result?.details?.sceneId}
toolCallId={toolCallId}
/>
);
},
});
@@ -1,50 +0,0 @@
/**
* Pure status derivation for the `edit_interactive_html` tool card — extracted
* from the tool-UI render so it can be unit-tested without React.
*
* The authoritative success signal is the tool's OWN result: `details.html` is a
* string when the str_replace edits applied (the client then writes it to the
* scene). A successful apply must never render as "failed" — not even when the
* assistant message ends with status `incomplete`, which a reasoning model can
* trigger (it streams reasoning, calls the tool, then the wrap-up turn leaves the
* message `incomplete` even though the edit already landed).
*/
export interface EditInteractiveHtmlResult {
content?: { type: string; text?: string }[];
details?: { sceneId?: string; html?: string | null; editCount?: number };
}
/** True when the edits applied (the tool returned a concrete HTML string). */
export function isEditApplied(result?: EditInteractiveHtmlResult | null): boolean {
return typeof result?.details?.html === 'string';
}
/** True only when the tool explicitly refused / could not apply (html === null). */
export function isEditRefused(result?: EditInteractiveHtmlResult | null): boolean {
const d = result?.details;
return !!d && 'html' in d && d.html === null;
}
/**
* Whether the edit-tool card should show its "failed" (not-fixed) state.
*
* Bias to success: a card only fails on an EXPLICIT failure signal — an
* `isError` result, or a result whose html came back `null` (refusal /
* unappliable edit). A successful apply, a missing/unpropagated result, or an
* `incomplete` message status are NOT failures — pi-agent-core's result
* propagation is lossy and the slim persisted result drops the html payload, so
* treating "no positive signal" as failure wrongly showed ✕ on edits that
* actually applied. `running` and `stopped` are their own states.
*/
export function deriveEditFailed(args: {
running: boolean;
stopped: boolean;
isError: boolean;
result?: EditInteractiveHtmlResult | null;
}): boolean {
const { running, stopped, isError, result } = args;
if (running || stopped) return false;
if (isEditApplied(result)) return false;
return isError || isEditRefused(result);
}
@@ -1,25 +0,0 @@
'use client';
/**
* Assistant text renderer — Streamdown via the official assistant-ui bridge.
*
* Used for its streaming-first RENDERING only: incomplete-markdown repair (no
* mid-stream flicker on unclosed bold/links), memoized block rendering, and
* Shiki code blocks. Deliberately NO library entrance animation and NO caret —
* the tasteful mainstream streaming feel (Claude-style) is a steady, even
* character reveal, which `smooth` (useSmooth interpolation over bursty SSE
* deltas) provides on its own.
*/
import 'streamdown/styles.css';
import { StreamdownTextPrimitive } from '@assistant-ui/react-streamdown';
import { code } from '@streamdown/code';
export function MarkdownText() {
return (
<StreamdownTextPrimitive
className="text-[13.5px] leading-relaxed text-foreground [overflow-wrap:anywhere]"
smooth={{ maxCharIntervalMs: 18, drainMs: 320 }}
plugins={{ code }}
/>
);
}
@@ -1,60 +0,0 @@
'use client';
/**
* Tool-call UI for `read_scene_content`. A read is lightweight, so this renders a
* minimal `ToolCard` — title + @scene pill + status badge, no expandable body.
* Without this, a turn that reads the slide showed a blank gap in the thread;
* now every tool call renders a uniform card.
*/
import { Eye } from 'lucide-react';
import { makeAssistantToolUI } from '@assistant-ui/react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { ToolCard, type ToolStatus } from './tool-card';
interface ReadSceneContentResult {
details?: { sceneId?: string };
}
function ReadCard({
running,
failed,
sceneId,
}: {
running: boolean;
failed: boolean;
sceneId?: string;
}) {
const { t } = useI18n();
const status: ToolStatus = running ? 'running' : failed ? 'failed' : 'done';
const statusLabel = running
? t('edit.readCard.reading')
: failed
? t('edit.readCard.failed')
: t('edit.readCard.done');
return (
<ToolCard
title={t('edit.readCard.title')}
icon={Eye}
sceneId={sceneId}
status={status}
statusLabel={statusLabel}
/>
);
}
export const ReadSceneContentUI = makeAssistantToolUI<{ sceneId?: string }, ReadSceneContentResult>(
{
toolName: 'read_scene_content',
render: ({ args, status, result, isError }) => {
const running = status.type === 'running' || status.type === 'requires-action';
// Bias to success: a read that ran is "done". Only an explicit `isError`
// is a failure — a missing/unpropagated result (which assistant-ui surfaces
// as an `incomplete` part status) is NOT, otherwise a successful read shows
// a spurious ✕ both live and after a refresh restores it without a result.
const failed = !running && !!isError;
const sceneId = args?.sceneId ?? result?.details?.sceneId;
return <ReadCard running={running} failed={failed} sceneId={sceneId} />;
},
},
);
@@ -1,123 +0,0 @@
'use client';
/**
* Reasoning ("thinking") panel for the editor agent. Renders the model's
* reasoning (recovered from inline <think> / reasoning_content and split out by
* extractReasoningMiddleware) as a collapsible block, separate from the answer
* text, with how long the model spent on THIS block.
*
* A multi-step agent reasons several times (read → reason → edit → reason →
* answer), so a message can hold multiple reasoning blocks. Each gets its own
* timer keyed `${messageId}:${ordinal}`: an earlier block freezes once a later
* part follows it; the last block ticks live until something follows or the run
* finalizes. The ordinal is this block's position among the message's reasoning
* parts (matched by text), mirroring how the runtime keys the timers.
*/
import { useEffect, useState } from 'react';
import { useMessage } from '@assistant-ui/react';
import { Brain, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useThinkingTimers, formatThinkDuration } from '@/lib/agent/client/thinking-timers';
export function ReasoningPart({ text }: { text: string }) {
const { t } = useI18n();
const id = useMessage((m) => m.id);
const running = useMessage((m) => m.status?.type === 'running');
// This block's ordinal among the message's reasoning parts. Matched by text,
// but assistant-ui's text smoothing can momentarily desync the `text` prop from
// the message snapshot — a -1 there would flip the timer lookup on and off and
// make the label flicker, so we fall back to the active (last) reasoning block.
const ordinal = useMessage((m) => {
const rs = (m.content as Array<{ type: string; text?: string }>).filter(
(p) => p.type === 'reasoning',
);
const i = rs.findIndex((p) => p.text === text);
return i >= 0 ? i : Math.max(0, rs.length - 1);
});
const timer = useThinkingTimers((s) => s.timers[`${id}:${ordinal}`]);
const [open, setOpen] = useState(false);
// This block was interrupted if the run ended `incomplete` (stopped/cancelled
// or errored) while it was the active block — i.e. it's the LAST reasoning
// block and the final content part (nothing followed it). Earlier blocks that
// finished before the stop keep their normal "已思考" label.
const interrupted = useMessage((m) => {
if (m.status?.type !== 'incomplete') return false;
const c = m.content as readonly { type: string }[];
if (c.length === 0 || c[c.length - 1].type !== 'reasoning') return false;
return c.filter((p) => p.type === 'reasoning').length - 1 === ordinal;
});
const startedAt = timer?.startedAt;
const endedAt = timer?.endedAt;
// Label is decided by run state + whether the block ended — NOT by timer
// presence — so a transient missing timer never flips it to "思考过程" mid-run.
const live = running && endedAt == null;
const ticking = live && startedAt != null;
// The live elapsed is driven by a 1s interval into STATE — never recomputed
// from Date.now() on each render. This component re-renders on every streamed
// reasoning delta (thousands of times); recomputing the elapsed per render made
// the counter jump erratically. Now it advances once per second, independent of
// re-renders, so the number ticks smoothly.
const [tickMs, setTickMs] = useState(0);
useEffect(() => {
if (!ticking || startedAt == null) return;
// Deliberate one-shot init so the counter shows the right elapsed value
// before the first 1s tick; the interval below keeps it updated.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTickMs(Date.now() - startedAt);
const h = setInterval(() => setTickMs(Date.now() - startedAt), 1000);
return () => clearInterval(h);
}, [ticking, startedAt]);
if (!text) return null;
// While ticking: whole seconds from interval state (calm). When frozen: the
// precise final duration.
const dur = ticking
? `${Math.max(0, Math.floor(tickMs / 1000))}s`
: startedAt != null && endedAt != null
? formatThinkDuration(endedAt - startedAt)
: '';
const word = interrupted
? t('edit.agent.stopped')
: endedAt != null
? t('edit.agent.thought')
: live
? t('edit.agent.thinking')
: t('edit.agent.reasoning');
return (
<div
className={cn(
'rounded-lg border',
interrupted
? 'border-neutral-200 bg-neutral-50/60 dark:border-neutral-700/60 dark:bg-neutral-800/30'
: 'border-violet-100/70 bg-violet-50/30 dark:border-violet-500/15 dark:bg-violet-500/[0.04]',
)}
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className={cn(
'flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left text-[12px] font-medium transition-colors',
interrupted
? 'text-muted-foreground/70 hover:text-muted-foreground'
: 'text-[#5b1fa8]/80 hover:text-[#5b1fa8] dark:text-violet-300/80 dark:hover:text-violet-200',
)}
>
<ChevronRight className={cn('size-3 shrink-0 transition-transform', open && 'rotate-90')} />
<Brain className="size-3 shrink-0" />
<span className={cn('shrink-0', ticking && 'ai-thinking-shimmer')}>{word}</span>
{dur ? <span className="shrink-0 tabular-nums opacity-70">{dur}</span> : null}
</button>
{open ? (
<div className="whitespace-pre-wrap break-words border-t border-violet-100/60 px-2.5 py-2 text-[12px] leading-relaxed text-muted-foreground dark:border-violet-500/10">
{text}
</div>
) : null}
</div>
);
}
@@ -1,91 +0,0 @@
'use client';
/**
* Tool-call UI for `regenerate_scene` (whole-slide regeneration). Renders via the
* shared `ToolCard` as a single non-expandable status row (status mark + tooltip
* only — no inline detail body). The "还原到重生成前 / Restore previous" button
* lives on the always-visible card row (ToolCard `barAction`): whole-slide
* regeneration applies directly to the canvas, so revert is one tap.
*/
import { Wrench } from 'lucide-react';
import { makeAssistantToolUI } from '@assistant-ui/react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { ToolCard, isStoppedResult, type ToolStatus } from './tool-card';
import { RestoreButton } from './restore-button';
interface RegenerateSceneResult {
content?: { type: string; text?: string }[];
details?: { sceneId?: string; content?: { elements?: unknown[] } | null; actions?: unknown[] };
}
function RegenerateSceneCard({
running,
stopped,
failed,
sceneId,
toolCallId,
}: {
running: boolean;
stopped: boolean;
failed: boolean;
sceneId?: string;
toolCallId: string;
}) {
const { t } = useI18n();
const toolStatus: ToolStatus = running
? 'running'
: stopped
? 'stopped'
: failed
? 'failed'
: 'done';
const statusLabel = running
? t('edit.regenScene.generating')
: stopped
? t('edit.agent.stopped')
: failed
? t('edit.regenScene.notGenerated')
: t('edit.regenScene.updated');
return (
<ToolCard
title={t('edit.regenScene.title')}
icon={Wrench}
sceneId={sceneId}
status={toolStatus}
statusLabel={statusLabel}
// No Restore for a stopped/failed run — nothing was applied to revert.
barAction={!failed && !stopped ? <RestoreButton toolCallId={toolCallId} /> : undefined}
/>
);
}
export const RegenerateSceneUI = makeAssistantToolUI<
{ sceneId?: string; instruction?: string },
RegenerateSceneResult
>({
toolName: 'regenerate_scene',
render: ({ args, status, result, isError, toolCallId }) => {
const running = status.type === 'running' || status.type === 'requires-action';
// The user cancelled the turn before this tool finished → loud stopped state.
const stopped = !running && isStoppedResult(result);
// pi-agent-core 0.78.0 does NOT propagate a tool result's `isError` into
// `tool_execution_end.isError`, so refusals / generation-failures (which
// return `details.content === null`, i.e. nothing was applied) would render
// as a green "Updated" badge. Derive failure from the result too: if the run
// finished but produced no content, treat it as failed.
const noContentApplied =
!running && !stopped && result != null && result.details?.content == null;
const failed =
!running && !stopped && (isError || status.type === 'incomplete' || noContentApplied);
return (
<RegenerateSceneCard
running={running}
stopped={stopped}
failed={failed}
sceneId={args?.sceneId ?? result?.details?.sceneId}
toolCallId={toolCallId}
/>
);
},
});
@@ -1,85 +0,0 @@
'use client';
/**
* Tool-call UI for `regenerate_scene_actions`. Renders via the shared `ToolCard`
* as a single non-expandable status row (status mark + tooltip only — no inline
* detail body). The "还原 / Restore previous" button lives on the card row.
*/
import { Wrench } from 'lucide-react';
import { makeAssistantToolUI } from '@assistant-ui/react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { ToolCard, isStoppedResult, type ToolStatus } from './tool-card';
import { RestoreButton } from './restore-button';
interface RegenerateResult {
content?: { type: string; text?: string }[];
details?: { sceneId?: string; actions?: { type?: string }[] };
}
function RegenerateActionsCard({
running,
stopped,
failed,
sceneId,
toolCallId,
}: {
running: boolean;
stopped: boolean;
failed: boolean;
sceneId?: string;
toolCallId: string;
}) {
const { t } = useI18n();
const toolStatus: ToolStatus = running
? 'running'
: stopped
? 'stopped'
: failed
? 'failed'
: 'done';
const statusLabel = running
? t('edit.regen.generating')
: stopped
? t('edit.agent.stopped')
: failed
? t('edit.regen.notGenerated')
: t('edit.regen.updated');
return (
<ToolCard
title={t('edit.regen.title')}
icon={Wrench}
sceneId={sceneId}
status={toolStatus}
statusLabel={statusLabel}
// No Restore for a stopped/failed run — nothing was applied to revert.
barAction={!failed && !stopped ? <RestoreButton toolCallId={toolCallId} /> : undefined}
/>
);
}
export const RegenerateSceneActionsUI = makeAssistantToolUI<{ sceneId?: string }, RegenerateResult>(
{
toolName: 'regenerate_scene_actions',
render: ({ args, status, result, isError, toolCallId }) => {
const running = status.type === 'running' || status.type === 'requires-action';
// The user cancelled the turn before this tool finished → loud stopped state.
const stopped = !running && isStoppedResult(result);
// pi-agent-core 0.78.0 doesn't propagate a result's isError into the event,
// so derive failure from the result too: a finished call that produced no
// actions changed nothing — show "not generated", not a green "Updated".
const noActions =
!running && !stopped && result != null && (result.details?.actions?.length ?? 0) === 0;
const failed = !running && !stopped && (isError || status.type === 'incomplete' || noActions);
return (
<RegenerateActionsCard
running={running}
stopped={stopped}
failed={failed}
sceneId={args?.sceneId ?? result?.details?.sceneId}
toolCallId={toolCallId}
/>
);
},
},
);
@@ -1,52 +0,0 @@
'use client';
/**
* Icon-only Restore (undo) control for regenerate tool cards. Whole-slide and
* narration regeneration both apply directly; the runtime snapshots the
* pre-regenerate scene state, so this offers a one-tap revert. Rendered on the
* card's always-visible bar (ToolCard `barAction`). Returns null when there is
* no in-memory snapshot (e.g. a card restored from storage after a refresh).
*/
import { Redo2, Undo2 } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useRegenSnapshots } from '@/lib/agent/client/regen-snapshots';
import { applyScenePatchInSync } from '@/lib/agent/client/apply-slide-content';
export function RestoreButton({ toolCallId }: { toolCallId: string }) {
const { t } = useI18n();
const snap = useRegenSnapshots((s) => s.snapshots[toolCallId]);
if (!snap) return null;
// Undone but with no captured post-edit state (e.g. a card restored from
// storage after a refresh) → terminal undone state, nothing to resume.
if (snap.restored && !snap.redo) {
return (
<span
title={t('edit.regenScene.restored')}
className="grid size-6 place-items-center text-muted-foreground/40"
>
<Undo2 className="size-3.5" />
</span>
);
}
// Toggle: undo while applied, resume (redo) once undone.
const resume = snap.restored;
const Icon = resume ? Redo2 : Undo2;
const label = resume ? t('edit.regenScene.resume') : t('edit.regenScene.restore');
return (
<button
type="button"
title={label}
aria-label={label}
onClick={() =>
useRegenSnapshots
.getState()
.restore(toolCallId, (id, patch) => applyScenePatchInSync(id, patch))
}
className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Icon className="size-3.5" />
</button>
);
}
-114
View File
@@ -1,114 +0,0 @@
'use client';
/**
* Shared tool-call card for the AgentBar, in the AgentSidebar design board's
* `.ae-tool` language: a bordered row with a leading glyph, truncating title, an
* optional `@scene` pill, an optional inline bar-action (always visible on the
* row — e.g. a Restore button), an icon-only status mark (running = violet
* spinner, done = emerald check ✓, failed = amber cross ✗; the text label is a
* hover tooltip). Tool cards are intentionally NOT expandable. Every tool card
* (regenerate / read / future) renders through this
* shell so they stay visually uniform.
*/
import type { ReactNode } from 'react';
import { AtSign, Check, CircleStop, Loader2, X, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { useStageStore } from '@/lib/store/stage';
export type ToolStatus = 'running' | 'done' | 'failed' | 'stopped';
/**
* True when a tool result is the synthetic "stopped" marker the client runtime
* writes for tool calls that never produced a result because the user cancelled
* the turn (see use-agent-runtime). Shared so every tool UI reports a stopped
* card the same way.
*/
export function isStoppedResult(result: unknown): boolean {
return (
typeof result === 'object' &&
result !== null &&
(result as { __stopped?: boolean }).__stopped === true
);
}
/**
* The page (scene) a tool acted on, shown as an `@title` chip — the chat is a
* single global thread (Cursor-style), so each tool card carries its own scene
* reference instead of splitting history per page.
*/
export function ScenePill({ sceneId }: { sceneId?: string }) {
const title = useStageStore((s) =>
sceneId ? (s.scenes.find((x) => x.id === sceneId)?.title ?? null) : null,
);
if (!title) return null;
return (
<span className="inline-flex min-w-0 max-w-[150px] shrink items-center gap-0.5 rounded-md border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10.5px] font-medium text-[#5b1fa8] dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300">
<AtSign className="size-2.5 shrink-0 text-violet-500" />
<span className="truncate">{title}</span>
</span>
);
}
const STATUS_ICON: Record<ToolStatus, LucideIcon> = {
running: Loader2,
done: Check,
failed: X,
stopped: CircleStop,
};
const STATUS_TONE: Record<ToolStatus, string> = {
running: 'text-[#5b1fa8] dark:text-violet-300',
done: 'text-emerald-600 dark:text-emerald-400',
failed: 'text-amber-600 dark:text-amber-400',
// Stopped: a deliberately loud rose stop sign so an interrupted run reads as
// "you stopped this", clearly distinct from a green done or amber failure.
stopped: 'text-rose-600 dark:text-rose-400',
};
export function ToolCard({
title,
icon: Icon,
sceneId,
status,
statusLabel,
barAction,
}: {
title: string;
icon: LucideIcon;
sceneId?: string;
status: ToolStatus;
/** Shown as a hover tooltip on the status mark (the mark itself is icon-only). */
statusLabel: string;
/** Inline action rendered on the always-visible row (e.g. Restore). */
barAction?: ReactNode;
}) {
const running = status === 'running';
const StatusIcon = STATUS_ICON[status];
return (
<div
className={cn(
'overflow-hidden rounded-[9px] border',
running ? 'border-violet-300 dark:border-violet-500/40' : 'border-border',
)}
>
<div className="flex w-full items-center gap-2 bg-muted/50 px-2.5 py-2 text-left">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
{/* Title takes the free space and truncates; the @scene pill rides in the
right cluster next to the status mark, so pills stay right-aligned
across cards regardless of how long each tool name is. */}
<span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-foreground">
{title}
</span>
<span className="ml-auto flex shrink-0 items-center gap-1.5">
{barAction ? <span>{barAction}</span> : null}
<ScenePill sceneId={sceneId} />
<span title={statusLabel} className={cn('inline-flex items-center', STATUS_TONE[status])}>
<StatusIcon className={cn('size-4', running && 'animate-spin')} />
</span>
</span>
</div>
</div>
);
}
+234 -166
View File
@@ -1,15 +1,49 @@
'use client';
import { useState, useRef, useCallback } from 'react';
/**
* AgentRosterPanel — who is in this classroom, and what each of them is like.
*
* Course-level content, not an app setting: the roster lives on the stage
* document (`stage.generatedAgentConfigs`), and every edit here goes straight
* into it through `useAgentRoster`. It is mounted from `RosterDialog`, opened
* from the edit dock's global bar.
*
* One card per member, collapsed to a line and expanded to an editor. The lead
* teacher is first and cannot be removed (the last-teacher guard lives in
* `agent-ops`); AI classmates below it reorder and can leave the class.
*
* Colours: the chrome is theme tokens, so the panel reads correctly in both
* themes. The one exception is each classmate's OWN colour, which is data on the
* agent and therefore stays an inline value.
*/
import {
type ReactNode,
type Ref,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { Camera, ChevronDown, ChevronUp, Redo2, Undo2, UserMinus, UserPlus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '@/lib/utils/cn';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useStageStore } from '@/lib/store/stage';
import type { GeneratedAgentConfig } from '@/lib/types/stage';
import { useAgentRoster } from './useAgentRoster';
import { AvatarPicker } from './AvatarPicker';
const PERSONA_MAX = 2000;
interface RosterDraft {
readonly agentId: string;
readonly patch: Partial<GeneratedAgentConfig>;
}
type RegisterDraft = (key: string, read: () => RosterDraft | null) => () => void;
// ─── Avatar with camera overlay ──────────────────────────────────────────────
interface AvatarWithOverlayProps {
@@ -32,6 +66,7 @@ function AvatarWithOverlay({ agent, size, ringColor, onPickerOpen }: AvatarWithO
onPickerOpen();
}}
>
{/* Avatars are static public paths; the roster needs no image optimizer. */}
<img
src={agent.avatar}
alt={agent.name}
@@ -40,11 +75,8 @@ function AvatarWithOverlay({ agent, size, ringColor, onPickerOpen }: AvatarWithO
style={{ width: size, height: size, boxShadow: `0 0 0 2px ${ringColor}` }}
/>
{hovering && (
<div
className="absolute inset-0 flex items-center justify-center rounded-full"
style={{ background: 'rgba(24,24,27,.45)' }}
>
<Camera className="text-white" style={{ width: 14, height: 14 }} />
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-zinc-900/45">
<Camera className="size-3.5 text-white" />
</div>
)}
</div>
@@ -54,31 +86,64 @@ function AvatarWithOverlay({ agent, size, ringColor, onPickerOpen }: AvatarWithO
// ─── Inline editable name ────────────────────────────────────────────────────
interface EditableNameProps {
readonly agentId: string;
readonly draftKey: string;
readonly value: string;
readonly onCommit: (v: string) => void;
readonly registerDraft: RegisterDraft;
readonly className?: string;
}
function EditableName({ value, onCommit, className }: EditableNameProps) {
function EditableName({
agentId,
draftKey,
value,
onCommit,
registerDraft,
className,
}: EditableNameProps) {
const ref = useRef<HTMLSpanElement>(null);
const draftValueRef = useRef(value);
const focusedRef = useRef(false);
useEffect(() => {
if (!focusedRef.current) draftValueRef.current = value;
}, [value]);
const handleBlur = useCallback(() => {
const text = ref.current?.textContent?.trim() ?? '';
if (text && text !== value) onCommit(text);
else if (ref.current) ref.current.textContent = value;
}, [value, onCommit]);
const readDraft = useCallback((): RosterDraft | null => {
// Immediate Radix closes can still read the live DOM; controlled owner
// closes may already have detached it, in which case the input snapshot is
// the durable owner-side copy.
const name = (ref.current?.textContent ?? draftValueRef.current).trim();
return name && name !== value ? { agentId, patch: { name } } : null;
}, [agentId, value]);
useEffect(() => registerDraft(draftKey, readDraft), [draftKey, readDraft, registerDraft]);
return (
<span
ref={ref}
data-testid="agent-roster-name"
contentEditable
suppressContentEditableWarning
onBlur={handleBlur}
onInput={(event) => {
draftValueRef.current = event.currentTarget.textContent ?? '';
}}
onFocus={() => {
focusedRef.current = true;
}}
onBlur={() => {
focusedRef.current = false;
handleBlur();
}}
onClick={(e) => e.stopPropagation()}
className={cn(
'outline-none cursor-text rounded-[3px]',
'hover:underline hover:decoration-dashed hover:decoration-[#b08ee6]',
'focus:shadow-[0_0_0_2px_rgba(114,46,209,.18)]',
'cursor-text rounded-[3px] outline-none',
'hover:underline hover:decoration-primary/60 hover:decoration-dashed',
'focus:shadow-[0_0_0_2px_var(--ring)]',
className,
)}
style={{ minWidth: 10 }}
@@ -93,11 +158,11 @@ function EditableName({ value, onCommit, className }: EditableNameProps) {
interface PersonaEditorProps {
readonly agentId: string;
readonly value: string;
readonly borderColor: string;
readonly onUpdate: (id: string, persona: string) => void;
readonly registerDraft: RegisterDraft;
}
function PersonaEditor({ agentId, value, borderColor, onUpdate }: PersonaEditorProps) {
function PersonaEditor({ agentId, value, onUpdate, registerDraft }: PersonaEditorProps) {
const { t } = useI18n();
const [prevValue, setPrevValue] = useState(value);
const [draft, setDraft] = useState(value);
@@ -112,41 +177,39 @@ function PersonaEditor({ agentId, value, borderColor, onUpdate }: PersonaEditorP
}
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const v = e.target.value.slice(0, PERSONA_MAX);
setDraft(v);
setDraft(e.target.value.slice(0, PERSONA_MAX));
};
const handleFocus = () => setFocused(true);
const handleBlur = () => {
const handleBlur = useCallback(() => {
setFocused(false);
if (draft !== value) onUpdate(agentId, draft);
};
}, [agentId, draft, onUpdate, value]);
const readDraft = useCallback(
(): RosterDraft | null => (draft !== value ? { agentId, patch: { persona: draft } } : null),
[agentId, draft, value],
);
useEffect(
() => registerDraft(`${agentId}:persona`, readDraft),
[agentId, readDraft, registerDraft],
);
return (
<div className="flex flex-col gap-1.5">
<span style={{ fontSize: 11.5, fontWeight: 600, color: '#52525b', letterSpacing: '.01em' }}>
<span className="text-[11.5px] font-semibold tracking-[0.01em] text-muted-foreground">
{t('edit.roster.personaLabel')}
</span>
<textarea
data-persona={agentId}
value={draft}
onChange={handleChange}
onFocus={handleFocus}
onFocus={() => setFocused(true)}
onBlur={handleBlur}
rows={4}
maxLength={PERSONA_MAX}
placeholder={t('edit.roster.personaPlaceholder')}
className="resize-none rounded-[10px] bg-white px-3 py-2.5 outline-none focus:ring-1"
style={{
border: `1px solid ${borderColor}`,
fontSize: 12.5,
lineHeight: 1.7,
color: '#3f3f46',
// focus ring uses the same tint as border
}}
className="w-full min-w-0 resize-none rounded-[10px] border border-border bg-background px-3 py-2.5 text-[12.5px] leading-relaxed text-foreground outline-none focus:border-primary/40 focus:ring-1 focus:ring-primary/20"
/>
<span style={{ fontSize: 10, color: '#a1a1aa', alignSelf: 'flex-end' }}>
<span className="self-end text-[10px] text-muted-foreground/70">
{draft.length} / {PERSONA_MAX}
</span>
</div>
@@ -160,35 +223,35 @@ interface TeacherCardProps {
readonly open: boolean;
readonly onToggle: () => void;
readonly onUpdate: (id: string, patch: Partial<GeneratedAgentConfig>) => void;
readonly registerDraft: RegisterDraft;
}
function TeacherCard({ agent, open, onToggle, onUpdate }: TeacherCardProps) {
function TeacherCard({ agent, open, onToggle, onUpdate, registerDraft }: TeacherCardProps) {
const { t } = useI18n();
const [showAvatarPicker, setShowAvatarPicker] = useState(false);
const personaPreview = agent.persona?.slice(0, 40) || t('edit.roster.noPersona');
return (
<div
className="mb-3 shrink-0"
style={{
borderRadius: 13,
border: '1px solid #e9d8fb',
background: 'linear-gradient(180deg,#faf6ff,#fff)',
overflow: 'hidden',
}}
data-testid="agent-roster-card"
className="mb-3 w-full min-w-0 shrink-0 overflow-hidden rounded-[13px] border border-primary/25 bg-gradient-to-b from-primary/[0.07] to-transparent"
>
{/* Card head */}
<div
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggle()}
className="flex cursor-pointer items-center gap-3 px-3 py-[11px] select-none"
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
if (event.key === ' ') event.preventDefault();
onToggle();
}}
className="flex cursor-pointer select-none items-center gap-3 px-3 py-[11px]"
>
<AvatarWithOverlay
agent={agent}
size={42}
ringColor="#722ed1"
ringColor="var(--primary)"
onPickerOpen={() => {
if (!open) {
onToggle();
@@ -200,48 +263,34 @@ function TeacherCard({ agent, open, onToggle, onUpdate }: TeacherCardProps) {
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex flex-wrap items-center gap-2">
<EditableName
agentId={agent.id}
draftKey={`${agent.id}:name`}
value={agent.name || t('edit.roster.unnamed')}
onCommit={(name) => onUpdate(agent.id, { name })}
className="text-[13.5px] font-semibold text-[#27272a]"
registerDraft={registerDraft}
className="text-[13.5px] font-semibold text-foreground"
/>
<span
className="inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5"
style={{
background: '#f5f0fd',
border: '1px solid #e9d8fb',
fontSize: 9.5,
fontWeight: 600,
color: '#5b1fa8',
}}
>
<span className="inline-flex items-center gap-0.5 rounded-full border border-primary/25 bg-primary/10 px-1.5 py-0.5 text-[9.5px] font-semibold text-primary">
<span aria-hidden="true">👑</span>
{t('edit.roster.teacherBadge')}
</span>
</div>
<p
className="mt-0.5 truncate"
style={{ fontSize: 11, color: '#a1a1aa' }}
title={agent.persona || ''}
>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground" title={agent.persona}>
{personaPreview}
</p>
</div>
{open ? (
<ChevronUp style={{ width: 17, height: 17, color: '#a1a1aa', flexShrink: 0 }} />
<ChevronUp className="size-[17px] shrink-0 text-muted-foreground" />
) : (
<ChevronDown style={{ width: 17, height: 17, color: '#a1a1aa', flexShrink: 0 }} />
<ChevronDown className="size-[17px] shrink-0 text-muted-foreground" />
)}
</div>
{/* Expanded editor */}
{open && (
<div
className="flex flex-col gap-3 px-3 pb-3"
style={{ borderTop: '1px solid #efe4fb', paddingTop: 12, background: '#fdfaff' }}
>
<div className="flex flex-col gap-3 border-t border-primary/15 bg-primary/[0.03] px-3 pb-3 pt-3">
{showAvatarPicker && (
<div className="pb-1">
<AvatarPicker
@@ -256,8 +305,8 @@ function TeacherCard({ agent, open, onToggle, onUpdate }: TeacherCardProps) {
<PersonaEditor
agentId={agent.id}
value={agent.persona ?? ''}
borderColor="#e9d8fb"
onUpdate={(id, persona) => onUpdate(id, { persona })}
registerDraft={registerDraft}
/>
</div>
)}
@@ -277,6 +326,7 @@ interface ClassmateCardProps {
readonly onRemove: (id: string) => void;
readonly onMoveUp: () => void;
readonly onMoveDown: () => void;
readonly registerDraft: RegisterDraft;
}
function ClassmateCard({
@@ -289,31 +339,35 @@ function ClassmateCard({
onRemove,
onMoveUp,
onMoveDown,
registerDraft,
}: ClassmateCardProps) {
const { t } = useI18n();
const [showAvatarPicker, setShowAvatarPicker] = useState(false);
const ringColor = agent.color || '#a1a1aa';
// The agent's own colour is data, so it stays an inline value; everything
// around it is a theme token.
const ringColor = agent.color || 'var(--muted-foreground)';
const personaPreview = agent.persona?.slice(0, 35) || t('edit.roster.noPersona');
return (
<div
className="mb-[9px] shrink-0"
style={{
borderRadius: 13,
border: open ? `1px solid ${ringColor}66` : '1px solid #f0f0f2',
background: '#fff',
boxShadow: open ? `0 2px 12px ${ringColor}22` : undefined,
overflow: 'hidden',
transition: 'border-color .15s, box-shadow .15s',
}}
data-testid="agent-roster-card"
className={cn(
'group/card mb-[9px] w-full min-w-0 shrink-0 overflow-hidden rounded-[13px] border bg-card transition-colors',
open ? 'border-transparent' : 'border-border',
)}
style={open ? { borderColor: `${ringColor}66`, boxShadow: `0 2px 12px ${ringColor}22` } : {}}
>
{/* Card head */}
<div
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggle()}
className="flex cursor-pointer items-center gap-3 px-3 py-[11px] select-none"
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
if (event.key === ' ') event.preventDefault();
onToggle();
}}
className="flex cursor-pointer select-none items-center gap-3 px-3 py-[11px]"
>
<AvatarWithOverlay
agent={agent}
@@ -331,57 +385,57 @@ function ClassmateCard({
<div className="min-w-0 flex-1">
<EditableName
agentId={agent.id}
draftKey={`${agent.id}:name`}
value={agent.name || t('edit.roster.unnamed')}
onCommit={(name) => onUpdate(agent.id, { name })}
className="block truncate text-[13.5px] font-semibold text-[#27272a]"
registerDraft={registerDraft}
className="block truncate text-[13.5px] font-semibold text-foreground"
/>
<p
className="mt-0.5 truncate"
style={{ fontSize: 11, color: '#a1a1aa' }}
title={agent.persona || ''}
>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground" title={agent.persona}>
{personaPreview}
</p>
</div>
{/* Reorder controls (stop propagation so they don't expand) */}
<div className="flex flex-col gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{/* Reorder controls: quiet at rest so the row is not two chevron stacks,
and brought up on hover / keyboard focus. Kept rendered (not hidden)
so they stay reachable without a pointer. Stop propagation so they
don't expand the card. */}
<div
className="flex shrink-0 flex-col gap-0.5 opacity-45 transition-opacity duration-150 group-hover/card:opacity-100 focus-within:opacity-100 motion-reduce:transition-none"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
aria-label={t('edit.roster.moveUp')}
disabled={isFirst}
onClick={onMoveUp}
className="flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-zinc-100 disabled:pointer-events-none disabled:opacity-25"
className="grid size-5 place-items-center rounded text-muted-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-25"
>
<ChevronUp style={{ width: 12, height: 12, color: '#a1a1aa' }} />
<ChevronUp className="size-3" />
</button>
<button
type="button"
aria-label={t('edit.roster.moveDown')}
disabled={isLast}
onClick={onMoveDown}
className="flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-zinc-100 disabled:pointer-events-none disabled:opacity-25"
className="grid size-5 place-items-center rounded text-muted-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-25"
>
<ChevronDown style={{ width: 12, height: 12, color: '#a1a1aa' }} />
<ChevronDown className="size-3" />
</button>
</div>
{open ? (
<ChevronUp style={{ width: 17, height: 17, color: '#a1a1aa', flexShrink: 0 }} />
<ChevronUp className="size-[17px] shrink-0 text-muted-foreground" />
) : (
<ChevronDown style={{ width: 17, height: 17, color: '#a1a1aa', flexShrink: 0 }} />
<ChevronDown className="size-[17px] shrink-0 text-muted-foreground" />
)}
</div>
{/* Expanded editor */}
{open && (
<div
className="flex flex-col gap-3 px-3 pb-3"
style={{
borderTop: `1px solid ${ringColor}44`,
paddingTop: 12,
background: `${ringColor}08`,
}}
className="flex flex-col gap-3 border-t px-3 pb-3 pt-3"
style={{ borderColor: `${ringColor}44`, background: `${ringColor}08` }}
>
{showAvatarPicker && (
<div className="pb-1">
@@ -397,22 +451,18 @@ function ClassmateCard({
<PersonaEditor
agentId={agent.id}
value={agent.persona ?? ''}
borderColor={`${ringColor}66`}
onUpdate={(id, persona) => onUpdate(id, { persona })}
registerDraft={registerDraft}
/>
{/* Footer: remove */}
<div
className="flex items-center justify-end pt-1"
style={{ borderTop: '1px solid #f0f0f2', marginTop: 4 }}
>
<div className="mt-1 flex items-center justify-end border-t border-border pt-1">
<button
type="button"
data-testid="agent-roster-remove"
onClick={() => onRemove(agent.id)}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 transition-colors hover:bg-rose-50 hover:text-rose-600"
style={{ fontSize: 11.5, color: '#71717a' }}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-500/15 dark:hover:text-rose-400"
>
<UserMinus style={{ width: 13, height: 13 }} />
<UserMinus className="size-3.5" />
{t('edit.roster.removeFromClass')}
</button>
</div>
@@ -424,9 +474,58 @@ function ClassmateCard({
// ─── Main panel ───────────────────────────────────────────────────────────────
export function AgentRosterPanel() {
export interface AgentRosterPanelHandle {
flushDrafts(): void;
}
interface AgentRosterPanelProps {
readonly flushRef?: Ref<AgentRosterPanelHandle>;
/**
* A control the host slots at the end of the sub-head row (the dialog's close
* button). Rendered inline with undo/redo so it shares their baseline and ghost
* icon skin instead of floating over the corner as a boxed glyph.
*/
readonly headerTrailing?: ReactNode;
}
export function AgentRosterPanel({ flushRef, headerTrailing }: AgentRosterPanelProps = {}) {
const { t } = useI18n();
const { roster, selectedId, select, add, update, remove, reorder, history } = useAgentRoster();
const setStageAgents = useStageStore.use.setStageAgents();
const rosterRef = useRef(roster);
useLayoutEffect(() => {
rosterRef.current = roster;
}, [roster]);
const draftReaders = useRef(new Map<string, () => RosterDraft | null>());
const registerDraft = useCallback<RegisterDraft>((key, read) => {
draftReaders.current.set(key, read);
return () => {
if (draftReaders.current.get(key) === read) draftReaders.current.delete(key);
};
}, []);
useImperativeHandle(
flushRef,
() => ({
flushDrafts: () => {
let next = rosterRef.current;
for (const read of draftReaders.current.values()) {
const draft = read();
if (!draft) continue;
next = next.map((agent) =>
agent.id === draft.agentId ? { ...agent, ...draft.patch } : agent,
);
}
if (next !== rosterRef.current) {
// Commit synchronously to the owner store. A state update in an
// unmount cleanup would never reach useAgentRoster's persistence
// effect, which is precisely the close path this handle protects.
rosterRef.current = next;
setStageAgents(next);
}
},
}),
[setStageAgents],
);
const teachers = roster.filter((a) => a.role === 'teacher');
const classmates = roster.filter((a) => a.role !== 'teacher');
@@ -442,40 +541,31 @@ export function AgentRosterPanel() {
select(selectedId === id ? null : id);
};
const handleAdd = () => {
add('student');
// select() called inside add() already
};
// Reorder indices are within the full roster array
const classmateGlobalIndex = (localIdx: number) =>
roster.findIndex((a) => a.id === classmates[localIdx]?.id);
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Sub-head */}
<div
className="flex shrink-0 items-baseline gap-1.5 px-4 pb-1.5"
style={{ paddingTop: 14, paddingBottom: 6 }}
>
<span style={{ fontSize: 13, fontWeight: 600, color: '#3f3f46' }}>
{t('edit.roster.title')}
</span>
<span style={{ fontSize: 11, color: '#a1a1aa', fontFamily: 'monospace' }}>
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col">
{/* Sub-head: title + count on the left; the edit hint, undo/redo and the
host's close button on the right, all sharing one baseline and one
ghost-icon skin. */}
<div className="flex shrink-0 items-center gap-1.5 px-4 pb-1.5 pt-3.5">
<span className="text-[13px] font-semibold text-foreground">{t('edit.roster.title')}</span>
<span className="font-mono text-[11px] text-muted-foreground">
{t('edit.roster.count', { count: roster.length })}
</span>
<span className="flex-1" />
<span style={{ fontSize: 11, color: '#a1a1aa' }}>{t('edit.roster.editHint')}</span>
{/* Undo/redo */}
<span className="text-[11px] text-muted-foreground">{t('edit.roster.editHint')}</span>
<button
type="button"
title={t('edit.undo')}
aria-label={t('edit.undo')}
disabled={!history.canUndo}
onClick={history.undo}
className="ml-1 grid size-5 place-items-center rounded text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 disabled:pointer-events-none disabled:opacity-30"
className="ml-1 grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
>
<Undo2 style={{ width: 12, height: 12 }} />
<Undo2 className="size-3.5" />
</button>
<button
type="button"
@@ -483,15 +573,15 @@ export function AgentRosterPanel() {
aria-label={t('edit.redo')}
disabled={!history.canRedo}
onClick={history.redo}
className="grid size-5 place-items-center rounded text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 disabled:pointer-events-none disabled:opacity-30"
className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
>
<Redo2 style={{ width: 12, height: 12 }} />
<Redo2 className="size-3.5" />
</button>
{headerTrailing}
</div>
{/* Scrollable list */}
<div className="flex flex-1 min-h-0 flex-col overflow-y-auto px-3 pb-4">
{/* Teacher cards */}
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-y-auto px-3 pb-4">
{teachers.map((agent) => (
<TeacherCard
key={agent.id}
@@ -499,28 +589,19 @@ export function AgentRosterPanel() {
open={selectedId === agent.id}
onToggle={() => handleToggle(agent.id)}
onUpdate={handleUpdate}
registerDraft={registerDraft}
/>
))}
{/* Divider */}
{classmates.length > 0 && (
<div className="mb-2 flex items-center gap-2 px-0.5">
<span
style={{
fontSize: 10.5,
fontWeight: 600,
letterSpacing: '.04em',
color: '#a1a1aa',
whiteSpace: 'nowrap',
}}
>
<span className="whitespace-nowrap text-[10.5px] font-semibold tracking-[0.04em] text-muted-foreground">
{t('edit.roster.aiClassmates', { count: classmates.length })}
</span>
<div className="flex-1 border-t" style={{ borderColor: '#f1f1f3' }} />
<div className="flex-1 border-t border-border" />
</div>
)}
{/* Classmate cards */}
{classmates.map((agent, localIdx) => {
const globalIdx = classmateGlobalIndex(localIdx);
return (
@@ -535,32 +616,19 @@ export function AgentRosterPanel() {
onRemove={remove}
onMoveUp={() => reorder(agent.id, globalIdx - 1)}
onMoveDown={() => reorder(agent.id, globalIdx + 1)}
registerDraft={registerDraft}
/>
);
})}
{/* Add button */}
<button
type="button"
onClick={handleAdd}
className="flex w-full items-center justify-center gap-2 rounded-[13px] py-3 transition-colors"
style={{
border: '1.5px dashed #d4d4d8',
fontSize: 12.5,
color: '#71717a',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.borderColor = '#9d63e3';
(e.currentTarget as HTMLButtonElement).style.color = '#5b1fa8';
(e.currentTarget as HTMLButtonElement).style.background = '#faf6ff';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.borderColor = '#d4d4d8';
(e.currentTarget as HTMLButtonElement).style.color = '#71717a';
(e.currentTarget as HTMLButtonElement).style.background = '';
}}
data-testid="agent-roster-add"
// `add` selects the new member itself, so the card opens on its own.
onClick={() => add('student')}
className="flex w-full items-center justify-center gap-2 rounded-[13px] border-[1.5px] border-dashed border-border py-3 text-[12.5px] text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-primary"
>
<UserPlus style={{ width: 15, height: 15 }} />
<UserPlus className="size-4" />
{t('edit.roster.addRole')}
</button>
</div>
+6 -5
View File
@@ -1,6 +1,6 @@
'use client';
import { cn } from '@/lib/utils';
import { cn } from '@/lib/utils/cn';
import { AGENT_DEFAULT_AVATARS } from '@/lib/constants/agent-defaults';
interface AvatarPickerProps {
@@ -21,13 +21,14 @@ export function AvatarPicker({ value, onChange }: AvatarPickerProps) {
aria-label={src}
onClick={() => onChange(src)}
className={cn(
'flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl border-2 transition-all',
'flex size-12 items-center justify-center overflow-hidden rounded-xl border-2 transition-all',
value === src
? 'border-violet-500 shadow-[0_0_0_3px_rgba(139,92,246,0.18)]'
: 'border-transparent hover:border-zinc-300 dark:hover:border-zinc-600',
? 'border-primary shadow-[0_0_0_3px_rgba(114,46,209,0.18)]'
: 'border-transparent hover:border-border',
)}
>
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
{/* Static public path — no image optimizer needed. */}
<img src={src} alt="" className="size-full object-cover" draggable={false} />
</button>
))}
</div>
@@ -0,0 +1,97 @@
'use client';
import { useCallback, useLayoutEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useI18n } from '@/lib/hooks/use-i18n';
import { AgentRosterPanel, type AgentRosterPanelHandle } from './AgentRosterPanel';
interface RosterDialogProps {
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
}
/**
* The classroom roster, as a dialog.
*
* The roster is course-level content, not an app-level setting, so it does not
* live in the global SettingsDialog; and it is not a rail either — the Pro right
* rail it used to occupy is gone. Its entry is the edit dock's global bar, beside
* the other course-level controls.
*
* `AgentRosterPanel` is reused as-is: the panel is self-contained (it reads and
* writes `useStageStore` through `useAgentRoster`), so mounting it here is
* zero-change reuse. Radix renders nothing while closed, which is load-bearing:
* the panel materializes the roster from the stage document AT MOUNT, so every
* open re-reads the current cast rather than editing a stale snapshot.
*
* Sized like the rail it replaced, but as a CAP rather than a fixed width: the
* hard `w-[384px]` it first shipped with could not shrink, so a narrow viewport
* (and a grid track sized to the persona textarea's intrinsic `cols` width) pushed
* the cards' right edge past the clip and off screen. It follows the repo's dialog
* convention now — full width, capped at `sm`, and never wider than the viewport
* less a margin — while every box inside it is allowed to shrink (`min-w-0`),
* which is what stops a textarea from setting the floor.
*
* Height is capped as well, with the panel's own scrollable list handling the
* overflow. The panel already renders `edit.roster.title` as its visible sub-head,
* so the dialog's own title is sr-only — Radix requires one for accessibility, and
* showing it would double the heading.
*/
export function RosterDialog({ open, onOpenChange }: RosterDialogProps) {
const { t } = useI18n();
const panelRef = useRef<AgentRosterPanelHandle>(null);
const capturePanel = useCallback((panel: AgentRosterPanelHandle | null) => {
// React clears callback refs before owner cleanup. Keep the last live
// handle: its draft readers are deliberately owner-owned snapshots and
// remain valid for this one final flush.
if (panel) panelRef.current = panel;
}, []);
useLayoutEffect(() => {
if (!open) return;
// This cleanup runs for both a controlled true -> false transition and an
// owner unmount. The handle commits draft snapshots straight to the stage,
// so it does not depend on the dialog content surviving another render.
return () => panelRef.current?.flushDrafts();
}, [open]);
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
// Radix may remove the focused editor without delivering blur. Commit all
// live drafts directly to the stage before the controlled close unmounts
// the roster hook.
panelRef.current?.flushDrafts();
}
onOpenChange(nextOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
data-testid="roster-dialog"
// The default corner close button is a boxed glyph riding the edge; we
// render our own inside the sub-head instead, aligned with undo/redo.
showCloseButton={false}
className="w-full max-w-[calc(100vw-2rem)] gap-0 overflow-hidden p-0 sm:max-w-[400px]"
>
<DialogTitle className="sr-only">{t('edit.roster.title')}</DialogTitle>
<div className="flex h-[min(70vh,560px)] min-h-0 w-full min-w-0 flex-col">
<AgentRosterPanel
flushRef={capturePanel}
headerTrailing={
<DialogClose
data-testid="roster-dialog-close"
aria-label={t('common.close')}
title={t('common.close')}
className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="size-3.5" />
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
);
}
+25 -5
View File
@@ -1,5 +1,24 @@
'use client';
/**
* useAgentRoster — the roster editor's state, and its one way into the stage.
*
* The roster is materialized from the stage document ONCE, at mount, and every
* edit is applied to that local history (undo / redo included) before being
* committed back through `setStageAgents`. Mount-time materialization is why the
* dialog that hosts this hook must stay unmounted while closed: each open then
* starts from the current cast instead of a snapshot taken hours ago. Within one
* open session the local history is authoritative, so an agent run that rewrites
* the roster in the same seconds loses to the next local commit — the dialog is a
* short-lived, deliberate edit, and reconciling two authors mid-keystroke would
* cost more than it buys.
*
* `setStageAgents` writes the stage document through the shared pending-change
* scheduler (a granular `{kind:'stage'}` change), NOT through the whole-document
* aggregate save — so a roster edit persists normally even while an agent session
* holds the aggregate-save veto for the course on screen.
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { nanoid } from 'nanoid';
import { materializeRoster } from '@/lib/edit/agent-roster';
@@ -38,6 +57,8 @@ function resolvePreset(id: string): GeneratedAgentConfig | undefined {
avatar: cfg.avatar,
color: cfg.color,
priority: cfg.priority,
voiceConfig: cfg.voiceConfig,
voiceDesign: cfg.voiceDesign,
};
}
@@ -66,10 +87,10 @@ export interface AgentRosterController {
// ---------------------------------------------------------------------------
export function useAgentRoster(): AgentRosterController {
const stage = useStageStore.use.stage();
const setStageAgents = useStageStore.use.setStageAgents();
const [histState, setHistState] = useState<AgentRosterHistory>(() => {
const stage = useStageStore.getState().stage;
const roster: AgentRoster = stage
? materializeRoster(stage, resolvePreset, makeId, isGlobalDefault)
: [];
@@ -88,10 +109,9 @@ export function useAgentRoster(): AgentRosterController {
// Commit roster edits to the stage store. setStageAgents updates the stage
// document (persisted through the shared pending-change scheduler) and
// synchronously mirrors the roster into the agent registry + selection.
// Depends only on `histState.present`; `stage` is intentionally excluded:
// setStageAgents replaces `stage`, so depending on it would re-trigger this
// effect in an infinite loop (React #185). setStageAgents already no-ops
// when there is no stage.
// Depends only on `histState.present`: setStageAgents replaces `stage`, so
// depending on the stage would re-trigger this effect in an infinite loop
// (React #185). setStageAgents already no-ops when there is no stage.
useEffect(() => {
if (!isDirtyRef.current) return;
setStageAgents(histState.present);
+172
View File
@@ -0,0 +1,172 @@
'use client';
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { CLASSROOM_ASPECT_RATIO, containBox, fillWidthBox } from '@/lib/edit/contain-box';
import { cn } from '@/lib/utils';
/**
* 16:9 stage box.
* `contain` — largest box that fits (letterbox).
* `fill-width` — as wide as the host; extra height is clipped. Dragging the
* workbench bar then grows the canvas instead of a white strip.
*/
/** CSS properties whose transition end can settle the host's size. */
const LAYOUT_PROPERTIES = new Set([
'width',
'height',
'min-width',
'max-width',
'min-height',
'max-height',
'flex',
'flex-basis',
'flex-grow',
'flex-shrink',
'gap',
'row-gap',
'column-gap',
'grid-template-columns',
'grid-template-rows',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'border-width',
'border-top-width',
'border-right-width',
'border-bottom-width',
'border-left-width',
'inset',
'top',
'right',
'bottom',
'left',
]);
export function ContainBox({
ratio = CLASSROOM_ASPECT_RATIO,
fit = 'contain',
className,
children,
}: {
readonly ratio?: number;
readonly fit?: 'contain' | 'fill-width';
readonly className?: string;
readonly children: ReactNode;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const [box, setBox] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const el = hostRef.current;
if (!el) return;
// Publish only when the box actually changed. The rAF settle re-measures
// and the transition/animation listeners can report a size that already
// rendered, and an unconditional setBox on every ResizeObserver delivery
// would re-render for identical boxes — with the extra re-measure passes
// below that churn would also run for every settle, not just real changes.
const apply = (next: { readonly width: number; readonly height: number }) => {
setBox((current) =>
current.width === next.width && current.height === next.height ? current : next,
);
};
const measure = () => {
apply(
fit === 'fill-width'
? fillWidthBox(el.clientWidth, ratio)
: containBox(el.clientWidth, el.clientHeight, ratio),
);
};
// First-paint measurement. When a course opens, the classroom pane mounts
// while the workbench is still settling (the chat pane re-flows from fill
// to fixed width, the pane's own column lands a frame or two later, the
// scene rail can mount async once its list loads, fonts shift content) —
// so the host box read here can be a stale full-width, or zero while the
// pane is hidden. Any ONE of those can land later than a couple of rAFs,
// which is what left first-open canvases mis-sized until a seam drag
// re-measured. Instead of betting on the right instant, measure every
// frame for a bounded settle window after mount; `apply` publishes only on
// change, so identical reads are free and there is no render churn.
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
// The frame is the host's sizing ancestor; watching it too catches a
// squeeze that lands on the frame one frame before the host's own box
// updates (rail width hydration, grid track redistribution).
if (el.parentElement) ro.observe(el.parentElement);
// Backstop: a layout settlement has been observed in the wild that left
// the host at a stale size with no further RO delivery (first-open with
// the slide rail expanded stayed mis-sized until a seam drag). Re-measure
// on a slow interval for the lifetime of the mount; `apply` publishes only
// on change, so a stable layout costs one cheap clientWidth read per tick.
const backstop = window.setInterval(measure, 400);
let settleRaf = 0;
let settleUntil = 0;
const SETTLE_WINDOW_MS = 1500;
const loop = () => {
measure();
settleRaf = performance.now() < settleUntil ? requestAnimationFrame(loop) : 0;
};
const kick = (ms: number) => {
settleUntil = performance.now() + ms;
if (!settleRaf) settleRaf = requestAnimationFrame(loop);
};
kick(SETTLE_WINDOW_MS);
// The pane can also settle through a width transition or animation that
// the ResizeObserver only tracks frame by frame (the scene rail's 0.3s
// width transition, a pane column animating in). The transition/animation
// END is the authoritative "settled" signal — restart a short settle
// window on it. Listened on the document in capture phase because the
// transitioning element is usually an ancestor or sibling of the host
// (rail, chat pane, pane column) — a listener on the host subtree would
// never see those. `transitionend` is filtered to layout-affecting
// properties; `animationend` fires once by definition.
const onSettled = (event: Event) => {
if (event.type === 'transitionend') {
const { propertyName } = event as TransitionEvent;
if (propertyName && !LAYOUT_PROPERTIES.has(propertyName)) return;
}
kick(400);
};
document.addEventListener('transitionend', onSettled, true);
document.addEventListener('animationend', onSettled, true);
return () => {
window.clearInterval(backstop);
if (settleRaf) cancelAnimationFrame(settleRaf);
settleRaf = 0;
document.removeEventListener('transitionend', onSettled, true);
document.removeEventListener('animationend', onSettled, true);
ro.disconnect();
};
}, [fit, ratio]);
return (
<div
ref={hostRef}
className="flex h-full min-h-0 w-full items-center justify-center overflow-hidden"
>
<div
className={cn('relative', className)}
style={
box.width > 0
? { width: box.width, height: box.height }
: { width: '100%', height: '100%' }
}
>
{children}
</div>
</div>
);
}
+55 -41
View File
@@ -1,17 +1,18 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { EditShell } from '@/components/edit/EditShell';
import { SlideNavRail } from '@/components/edit/SlideNavRail';
import { ActionsBar } from '@/components/edit/ActionsBar/ActionsBar';
import { EditDock } from '@/components/edit/EditDock/EditDock';
import { HeaderControls } from '@/components/stage/header-controls';
import { useAgentRuntime } from '@/lib/agent/client/use-agent-runtime';
import { isMaicEditorEnabled } from '@/lib/config/feature-flags';
import { preloadEditor } from '@/lib/edit/preload-editor';
import { sceneEditorRegistry } from '@/lib/edit/scene-editor-registry';
import { getScenePagerState } from '@/lib/edit/scene-pager';
import { useStageStore } from '@/lib/store/stage';
import { useInWorkbenchPanel } from '@/lib/workbench/panel-context';
import { supportsNarrationTimeline } from './scene-timeline';
import type { Scene } from '@/lib/types/stage';
import { RightRailTabs } from '@/components/edit/RightRailTabs';
interface EditChromeRootProps {
readonly scene: Scene;
@@ -25,14 +26,45 @@ interface EditChromeRootProps {
* 13-line inline JSX with three children.
*
* Owned here: `EditShell` (Frame + CommandBar + canvas + overlays),
* `SlideNavRail` (leftRail slot), the `HeaderControls` trailing
* (settings pill + Pro Switch) that rides in CommandBar's right slot,
* and the tabbed `RightRailTabs` (Edit with AI + 角色 roster).
* `SlideNavRail` (leftRail slot), the standalone-only `HeaderControls`
* trailing that rides in CommandBar's right slot,
* The legacy Edit-with-AI right rail has been retired: agentic edits live
* exclusively in the Pro workspace conversation, and the classroom roster is
* edited from a dialog opened off the edit dock's global bar.
*
* `scene` is required (non-null). The parent gates mounting on
* `mode === 'edit' && currentScene` to satisfy this contract.
*/
export function EditChromeRoot({ scene, isEditable, onToggleEditMode }: EditChromeRootProps) {
// Hosted inside the workbench panel? Then two pieces of chrome are
// meaningless here: the Pro Switch (the panel is Pro-locked; there is
// nowhere to toggle to) and the Edit-with-AI right rail (the workbench
// conversation on the left is its successor — the agent it would talk to
// is the one building this course).
const inWorkbenchPanel = useInWorkbenchPanel();
// Deck paging (‹ n/m ›). Same state source and setter the SlideNavRail
// thumbnails use — `currentSceneId` / `setCurrentSceneId` — so flipping pages
// from the dock and clicking a rail thumbnail can never disagree about which
// page is open.
const scenes = useStageStore.use.scenes();
const currentSceneId = useStageStore.use.currentSceneId();
const setCurrentSceneId = useStageStore.use.setCurrentSceneId();
const pagerState = useMemo(
() => getScenePagerState(scenes, currentSceneId),
[scenes, currentSceneId],
);
const pager = pagerState
? {
...pagerState,
onPrev: () => {
if (pagerState.prevSceneId) setCurrentSceneId(pagerState.prevSceneId);
},
onNext: () => {
if (pagerState.nextSceneId) setCurrentSceneId(pagerState.nextSceneId);
},
}
: undefined;
// Mark the body while edit mode is mounted, so the editor-scoped CSS
// rule in globals.css that pins `body.padding-right` to 0 only fires
// in Pro mode — not on non-editor pages where Radix's
@@ -60,29 +92,18 @@ export function EditChromeRoot({ scene, isEditable, onToggleEditMode }: EditChro
// Whether this scene type has a registered canvas editor surface (slide/quiz).
// Authoring surface is separate from narration timeline availability.
const authoringEnabled = !!sceneEditorRegistry.resolve(scene.type);
// The narration timeline (ActionsBar) is decoupled from the canvas editor surface
// (like agentEnabled below): it applies to registered surfaces (slide/quiz) AND
// view-only canvases that still carry a spoken script (interactive/pbl).
// The narration timeline is decoupled from the canvas editor surface (like
// agentEnabled below): it applies to registered surfaces (slide/quiz) AND
// view-only canvases that still carry a spoken script (interactive/pbl). It is
// also the dock's gate: the timeline is the dock's first tool, so where there
// is no timeline there is no bench to hang other tools off either.
const timelineEnabled = supportsNarrationTimeline(scene.type, authoringEnabled);
// The AI edit panel is decoupled from the canvas surface: it renders wherever
// the agent has an edit capability — slides (regenerate) AND interactive scenes
// (edit_interactive_html), even though the interactive canvas itself stays view-only.
const agentEnabled = authoringEnabled || scene.type === 'interactive';
// Keep the runtime owned by Pro mode chrome, not by the scene-capability gated
// panel. Unsupported scene switches can hide/disable the composer without
// destroying an in-flight run or the messages that still need to settle/save.
const agentRuntime = useAgentRuntime({
scene: agentEnabled ? { id: scene.id, title: scene.title } : undefined,
isSendDisabled: !agentEnabled,
});
const headerControls = (
const headerControls = inWorkbenchPanel ? undefined : (
<HeaderControls
mode="edit"
canEdit={isEditable}
onToggleEditMode={isMaicEditorEnabled() ? onToggleEditMode : undefined}
onToggleEditMode={isMaicEditorEnabled() && !inWorkbenchPanel ? onToggleEditMode : undefined}
/>
);
@@ -90,24 +111,17 @@ export function EditChromeRoot({ scene, isEditable, onToggleEditMode }: EditChro
<EditShell
scene={scene}
leftRail={<SlideNavRail />}
rightRail={
<RightRailTabs
scene={{ id: scene.id, title: scene.title, type: scene.type }}
runtime={agentRuntime.runtime}
clearThread={agentRuntime.clearThread}
hasMessages={agentRuntime.hasMessages}
canSend={agentEnabled}
agentEnabled={agentEnabled}
isRunning={agentRuntime.isRunning}
sessions={agentRuntime.sessions}
activeSessionId={agentRuntime.activeSessionId}
switchSession={agentRuntime.switchSession}
deleteSessionAndRefresh={agentRuntime.deleteSessionAndRefresh}
refreshSessions={agentRuntime.refreshSessions}
/>
bottomRail={
timelineEnabled ? (
<EditDock sceneId={scene.id} sceneType={scene.type} pager={pager} />
) : undefined
}
bottomRail={timelineEnabled ? <ActionsBar sceneId={scene.id} /> : undefined}
commandTrailing={headerControls}
// The pager normally lives in the dock's global edit bar (handed to `EditDock`
// above). Only a scene type that gets no dock at all keeps the floating
// form — otherwise those scenes would lose paging entirely.
pager={timelineEnabled ? undefined : pager}
hideCommandBar={inWorkbenchPanel}
/>
);
}
+84
View File
@@ -0,0 +1,84 @@
'use client';
/**
* The dock's global edit bar — one row, above the timeline, that never changes.
*
* What lands here is what acts on the COURSE rather than on the page's narration:
* which page you are on, who is in the class, and which elements you are handing
* the agent. None of it belongs inside the timeline (a spoken line has nothing to
* say about the cast), and none of it belongs floating over the canvas — a pill
* hovering on the slide covers the very content it is about to replace.
*
* Information structure: paging in the CENTRE, because it is the one control the
* user reaches for constantly and centre is where the eye returns; the two
* course-level entries on the flanks — the roster on the left, the lasso on the
* right — so the row stays symmetric and neither entry can be mistaken for part
* of the timeline below it.
*
* Deliberately not a new visual idiom: the same flat icon buttons, the same type
* scale and the same hairline the timeline header already uses. It stays visible
* (and usable) while the dock is folded, because none of it is about the fold.
*/
import { useState } from 'react';
import { Users } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { CanvasPager, type CanvasPagerProps } from '@/components/edit/EditShell/CanvasPager';
import { RosterDialog } from '@/components/edit/AgentsView/RosterDialog';
import { ElementRefLassoButton } from './ElementRefLassoButton';
/** The bar's own height, in px. The dock adds it to whatever the timeline is. */
export const DOCK_EDIT_BAR_HEIGHT = 36;
export function DockEditBar({
sceneId,
/** Canvas elements to point at — only a slide has any. */
canPickElements,
pager,
}: {
readonly sceneId: string;
readonly canPickElements: boolean;
readonly pager?: CanvasPagerProps;
}) {
const { t } = useI18n();
const [rosterOpen, setRosterOpen] = useState(false);
return (
<>
<div
role="group"
data-testid="edit-dock-bar"
aria-label={t('edit.dock.globalTools')}
// Three tracks rather than a flex row with spacers: the pager must be
// centred on the DOCK, not on whatever is left over after the flanks.
className="grid h-9 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-2 border-b border-gray-100 px-6 dark:border-gray-800"
>
<div className="flex min-w-0 items-center gap-1">
<button
type="button"
data-testid="edit-dock-roster"
onClick={() => setRosterOpen(true)}
title={t('edit.roster.title')}
aria-label={t('edit.roster.title')}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:border-primary/30 hover:text-foreground"
>
<Users className="size-3" />
{t('edit.roster.shortTitle')}
</button>
</div>
<div className="flex items-center justify-center">
{pager && pager.count > 0 ? <CanvasPager {...pager} variant="dock" /> : null}
</div>
<div className="flex min-w-0 items-center justify-end gap-1">
{canPickElements ? <ElementRefLassoButton sceneId={sceneId} /> : null}
</div>
</div>
{/* Radix keeps the dialog in a portal and renders nothing while closed, so
the roster editor is remounted — and therefore re-read from the stage —
on every open. */}
<RosterDialog open={rosterOpen} onOpenChange={setRosterOpen} />
</>
);
}
+113
View File
@@ -0,0 +1,113 @@
'use client';
/**
* EditDock — Pro mode's bottom editing bench.
*
* Two parts, and only two: a global edit bar (`DockEditBar`) that never changes,
* and the narration timeline below it. The dock owns the surface — the top
* border, the blur, the fold — while the timeline owns its own header row and
* body and reads the fold from `useEditDock`. The dock's height is fixed (the
* timeline is folded or not, nothing else changes it): the height-drag handle
* the reference implementation carries above the bar was removed per product
* decision — this bar must not support height dragging.
*
* It briefly had a tab strip and a second tool (an element-reference panel).
* That was wrong twice over: an exclusive panel hid the timeline to do something
* the timeline was not in the way of, and the panel's own list duplicated the
* composer's reference pills. The lasso is a single toggle in the bar now, in the
* Cursor sense — press it, click elements, they appear in the composer.
*
* Deliberately not a new visual idiom. The bar keeps the bench's own hairline,
* type scale and flat icon buttons; the timeline keeps the height, padding and
* geometry it had as a standalone bar.
*/
import { useCallback, useEffect, useState } from 'react';
import { useStageStore } from '@/lib/store/stage';
import { useCanvasStore } from '@/lib/store/canvas';
import { useWorkbenchStore } from '@/lib/workbench/session-store';
import { ActionsBar } from '@/components/edit/ActionsBar/ActionsBar';
import type { CanvasPagerProps } from '@/components/edit/EditShell/CanvasPager';
import type { SceneType } from '@/lib/types/stage';
import { EditDockProvider } from './dock-context';
import { DockEditBar, DOCK_EDIT_BAR_HEIGHT } from './DockEditBar';
/**
* The timeline's height, in px — the body only; the edit bar is added on top of
* it, so folding never takes the bar away. The timeline height is fixed: the
* height-drag handle was removed (owner decision), so only the fold moves it.
*/
const TIMELINE_DEFAULT_HEIGHT = 224;
/** The axis of node icons, with room for the chips that hang off it. */
const TIMELINE_COLLAPSED_HEIGHT = 86;
export function EditDock({
sceneId,
sceneType,
pager,
}: {
readonly sceneId: string;
readonly sceneType: SceneType;
/**
* Deck paging. It lands in the dock's global edit bar rather than floating over
* the canvas: flipping pages acts on the whole course, exactly like the other
* global entries, and a pill hovering over the slide covered the very content
* it was about to replace.
*/
readonly pager?: CanvasPagerProps;
}) {
const sessionId = useWorkbenchStore((state) => state.sessionId);
const currentStageId = useStageStore((state) => state.stage?.id ?? null);
// References address slide canvas elements or DOM elements inside interactive scenes. There is
// deliberately no agent-ownership condition here: see `ElementRefLassoButton`
// for why picking elements is a human authoring gesture rather than something
// a live run has to own.
const canPickElements = sceneType === 'slide' || sceneType === 'interactive';
const [collapsed, setCollapsed] = useState(false);
const toggleCollapsed = useCallback(() => setCollapsed((current) => !current), []);
/**
* The dock is an owner boundary for the lasso, because it can outlive a slide
* renderer during workspace navigation: never leave an old chat's (or an old
* page's) pick mode armed just because there is temporarily no pick layer
* mounted to perform the cleanup.
*
* The criterion is IDENTITY, not ownership: a staged pick belongs to one exact
* (chat, course, page) triple, so switching any of the three — a different
* conversation, a different course, a different slide — disarms whatever the
* previous one left behind. That is the whole invariant; it used to also fire on
* "the agent stopped owning this course", which is how the lasso came to vanish
* the moment a run finished.
*/
useEffect(() => {
const target = useCanvasStore.getState().pickTarget;
if (target?.purpose !== 'element-ref') return;
if (
target.ownerSessionId !== sessionId ||
target.stageId !== currentStageId ||
target.sceneId !== sceneId
) {
useCanvasStore.getState().setPickTarget(null);
}
}, [currentStageId, sceneId, sessionId]);
return (
<section
data-testid="edit-dock"
data-collapsed={collapsed ? 'true' : 'false'}
style={{
height:
(collapsed ? TIMELINE_COLLAPSED_HEIGHT : TIMELINE_DEFAULT_HEIGHT) + DOCK_EDIT_BAR_HEIGHT,
}}
className="relative flex flex-col border-t border-gray-100 bg-white/80 backdrop-blur-xl dark:border-gray-800 dark:bg-slate-900/80"
>
<DockEditBar sceneId={sceneId} canPickElements={canPickElements} pager={pager} />
<div data-testid="edit-dock-timeline" className="flex min-h-0 flex-1 flex-col">
<EditDockProvider value={{ collapsed, toggleCollapsed }}>
<ActionsBar sceneId={sceneId} />
</EditDockProvider>
</div>
</section>
);
}
@@ -0,0 +1,153 @@
'use client';
/**
* The lasso — one button, no panel.
*
* Press it and the canvas enters picking; click an element and it lands in the
* composer's reference row; keep clicking to add more. Press it again (or Esc)
* and picking ends. That is the whole tool: the staged references live in the
* element-refs store, which the composer already renders as removable pills, so a
* second list inside the bench would be the same objects shown twice, one copy of
* which the user cannot send from.
*
* The count rides on the button because the button is where the mode is: it
* answers "did that click register" without asking the eye to travel to the
* conversation. It is read through the OWNER-FENCED selector, so a chat that does
* not own the draft can never show another conversation's tally.
*
* It is NOT gated on the agent owning the course. It used to be, and that was
* wrong in the one case the feature exists for: `agentOwnsPaneCourse` releases
* ownership the moment a run reaches a terminal status, so the lasso vanished
* exactly when the user sat down to edit the deck the agent had just finished
* (and it never appeared at all for a course reached through read-only tools,
* which do not mark a stage as touched). Picking elements is a HUMAN authoring
* gesture — it stages pills in the composer, it writes nothing — and the runner
* does not check that a ref's stage is the session's own: refs carry their own
* `stageId`, and cross-user access is refused by the owner-bound store. So the
* only condition left is the one that makes the gesture meaningful: a slide to
* point at, and a conversation to hand the references to.
*
* "A conversation" means the composer that owns the reference draft, NOT merely a
* session id sitting in the workbench store. Those two come apart: the store is
* only ever attached inside `/workspace`, but it survives a client-side navigation
* out of it, so opening a course from the discover feed (`router.push`
* `/classroom/<id>`) leaves the id behind while `WorkbenchChat` — the only caller
* of `useElementRefsOwnerLifecycle` — unmounts and releases ownership. The button
* used to render on the id alone and refuse to act on the missing owner, which on
* that route is a permanent dead button rather than the one pre-attach frame the
* refusal was written for. So the owner fence IS the render condition: if pressing
* it cannot arm, it is not painted. `armed` keeps it mounted regardless, because
* the promise "press again to leave" (and the Esc handler below) must outlive any
* ownership change that happens while picking.
*/
import { Lasso } from 'lucide-react';
import { useCallback, useEffect } from 'react';
import { cn } from '@/lib/utils/cn';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useCanvasStore } from '@/lib/store/canvas';
import { useElementRefsForSession, useElementRefsStore } from '@/lib/store/element-refs';
import { useStageStore } from '@/lib/store/stage';
import { useWorkbenchStore } from '@/lib/workbench/session-store';
import { MAX_ELEMENT_REFS } from '@/lib/workbench/element-refs';
import { clearCuePreview } from '@/components/edit/ActionsBar/cue-preview';
export function ElementRefLassoButton({ sceneId }: { readonly sceneId: string }) {
const { t } = useI18n();
const sessionId = useWorkbenchStore((s) => s.sessionId);
const refs = useElementRefsForSession(sessionId);
const pickTarget = useCanvasStore.use.pickTarget();
const stageId = useStageStore((s) => s.stage?.id ?? null);
const ownerSessionId = useElementRefsStore((s) => s.ownerSessionId);
// Armed for THIS page of THIS course on behalf of THIS chat. Anything else is
// someone else's pick mode and must not light this button up.
const armed =
pickTarget?.purpose === 'element-ref' &&
pickTarget.sceneId === sceneId &&
pickTarget.stageId === stageId &&
pickTarget.ownerSessionId === sessionId;
// Everything a press needs, in one value: a slide, a chat, and that chat holding
// the draft the pills would land in. Non-null here IS "pressing this works", so
// the press below needs no guard of its own and cannot silently do nothing.
const armTarget =
stageId !== null && sessionId !== null && ownerSessionId === sessionId
? { stageId, sessionId }
: null;
const disarm = useCallback(() => {
const canvas = useCanvasStore.getState();
if (canvas.pickTarget?.purpose === 'element-ref') canvas.setPickTarget(null);
}, []);
/**
* Esc leaves picking. The canvas pick layer binds the same key, and that is
* deliberate rather than redundant: the promise "Esc gets you out" belongs to
* the button that got you in, and must hold even where no pick layer is mounted
* to keep it. Both paths null the same target, so whichever runs first wins and
* the second is a no-op.
*/
useEffect(() => {
if (!armed) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') disarm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [armed, disarm]);
const toggle = () => {
if (armed) {
disarm();
return;
}
if (!armTarget) return;
// Taking the canvas away from a cue pick: its hover preview (spotlight /
// laser) is a live effect painted on the slide, and it must not outlive the
// mode that painted it.
clearCuePreview();
useCanvasStore.getState().setPickTarget({
purpose: 'element-ref',
stageId: armTarget.stageId,
sceneId,
ownerSessionId: armTarget.sessionId,
});
};
// Nothing to press: no slide, no conversation, or a conversation that does not
// hold the reference draft (no composer mounted to render the pills in). Only
// `armed` keeps it on screen through such a change, so that picking always has
// its way out.
if (!armTarget && !armed) return null;
return (
<button
type="button"
data-testid="element-ref-arm"
onClick={toggle}
aria-pressed={armed}
title={t('edit.dock.elementRefHint')}
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition-colors',
armed
? 'border-violet-400 bg-violet-500 text-white hover:bg-violet-600'
: 'border-primary/25 bg-primary/10 text-primary hover:bg-primary/15',
)}
>
<Lasso className="size-3" />
{armed ? t('edit.elementRef.exitPicking') : t('edit.elementRef.startPicking')}
{refs.length > 0 && (
<span
data-testid="element-ref-count"
title={t('edit.elementRef.counts', { count: refs.length, max: MAX_ELEMENT_REFS })}
className={cn(
'grid size-4 place-items-center rounded-full font-mono text-[9px] font-semibold leading-none tabular-nums',
armed ? 'bg-white/25 text-white' : 'bg-primary/20 text-primary',
)}
>
{refs.length}
</span>
)}
</button>
);
}
+44
View File
@@ -0,0 +1,44 @@
'use client';
/**
* Edit dock context — the fold the dock owns and the timeline operates.
*
* The dock owns the surface: the top border, the blur, the drag-resize handle,
* the global edit bar and the height. The narration timeline owns everything
* below that bar — including its own header row, because that row's controls and
* its body share one piece of state (the insert-picker anchor, the TTS batch, the
* focused clip). So the shell cannot render the row; it lends the timeline the
* fold instead, and the timeline puts the toggle at the end of its own row where
* the title has always been able to reach it.
*/
import { createContext, useContext, type ReactNode } from 'react';
export interface EditDockContextValue {
/**
* The dock is folded. The timeline's collapsed form is its axis of node icons
* (still a body), so it reads this rather than being hidden by the shell.
*/
collapsed: boolean;
toggleCollapsed: () => void;
}
const EditDockContext = createContext<EditDockContextValue | null>(null);
export function EditDockProvider({
value,
children,
}: {
value: EditDockContextValue;
children: ReactNode;
}) {
return <EditDockContext.Provider value={value}>{children}</EditDockContext.Provider>;
}
/**
* Read the dock's fold. Returns a neutral value when the timeline is rendered
* outside a dock (an isolated test, a future standalone mount) so it is never
* coupled to the shell being there.
*/
export function useEditDock(): EditDockContextValue {
return useContext(EditDockContext) ?? { collapsed: false, toggleCollapsed: () => undefined };
}
+129
View File
@@ -0,0 +1,129 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils/cn';
export interface CanvasPagerProps {
readonly index: number;
readonly count: number;
readonly canPrev: boolean;
readonly canNext: boolean;
readonly onPrev: () => void;
readonly onNext: () => void;
}
/**
* Where the pager is mounted.
*
* `dock` is the normal form: paging is a global control of the whole deck, so it
* lives in the edit dock's global edit bar alongside the other course-level
* entries rather than floating over the page it is about to leave.
*
* `floating` is the fallback for scene types that get no dock at all (no
* narration timeline ⇒ no bench to hang tools off — see
* `components/edit/scene-timeline.ts`). Without it those scenes would lose
* paging entirely.
*/
export type CanvasPagerVariant = 'dock' | 'floating';
/** Scene navigation — in the dock's global edit bar, or floating when there is no dock. */
export function CanvasPager({
index,
count,
canPrev,
canNext,
onPrev,
onNext,
variant = 'floating',
}: CanvasPagerProps & { readonly variant?: CanvasPagerVariant }) {
const { t } = useI18n();
if (count <= 0) return null;
const controls = (
<>
<PagerButton
label={t('edit.nav.prevPage')}
disabled={!canPrev}
onClick={onPrev}
tone={variant}
>
<ChevronLeft className="size-4" />
</PagerButton>
<span
className={cn(
'select-none px-1 text-center font-mono tabular-nums',
variant === 'dock'
? 'min-w-10 text-[11px] text-muted-foreground/70'
: 'min-w-11 text-[11px] text-zinc-500 dark:text-zinc-400',
)}
>
{index + 1} / {count}
</span>
<PagerButton
label={t('edit.nav.nextPage')}
disabled={!canNext}
onClick={onNext}
tone={variant}
>
<ChevronRight className="size-4" />
</PagerButton>
</>
);
// In the dock the strip supplies the surface (border, blur, height), so the
// pager is flat controls — a second pill inside the bench would read as a
// floating thing that happens to be parked there.
if (variant === 'dock') {
return (
<div data-testid="edit-canvas-pager" className="flex shrink-0 items-center">
{controls}
</div>
);
}
return (
<div
data-testid="edit-canvas-pager"
className="absolute bottom-3 left-1/2 z-30 flex -translate-x-1/2 items-center gap-0.5 rounded-full border border-zinc-200/70 bg-white/70 p-1 shadow-lg shadow-zinc-950/10 backdrop-blur-md dark:border-zinc-700/70 dark:bg-zinc-900/70 dark:shadow-black/30"
>
{controls}
</div>
);
}
function PagerButton({
label,
children,
tone,
...props
}: React.ComponentProps<typeof Button> & {
readonly label: string;
/** Named `tone` rather than `variant` so it cannot collide with Button's own. */
readonly tone: CanvasPagerVariant;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={label}
className={cn(
'size-7 shrink-0 disabled:opacity-35',
tone === 'dock'
? 'rounded-md text-muted-foreground/60 hover:bg-muted hover:text-foreground'
: 'rounded-full text-zinc-600 hover:bg-white/80 hover:text-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-800/80 dark:hover:text-white',
)}
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
+11 -4
View File
@@ -1,13 +1,14 @@
'use client';
import { ArrowLeft, Redo2, Undo2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils';
import type { EditorCommand, SurfaceHistory } from '@/lib/edit/scene-editor-surface';
import { classroomExitLabelKey, exitClassroom } from '@/lib/workbench/classroom-exit';
interface CommandBarProps {
readonly title: string;
@@ -35,13 +36,19 @@ interface CommandBarProps {
export function CommandBar({ title, history, commands, trailing }: CommandBarProps) {
const { t } = useI18n();
const router = useRouter();
const searchParams = useSearchParams();
const exitLabel = t(classroomExitLabelKey(searchParams));
return (
<header className="flex h-20 shrink-0 items-center gap-3 border-b border-zinc-200/60 px-8 dark:border-zinc-800/60">
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* Back-to-home — mirrors playback Header's leftmost button so the
user has the same global-out affordance across modes. */}
<IconButton title={t('generation.backToHome')} onClick={() => router.push('/')}>
{/* Classroom exit mirrors playback Header's leftmost button so the
user has the same global-out affordance across standalone modes. */}
<IconButton
title={exitLabel}
aria-label={exitLabel}
onClick={() => exitClassroom(router, searchParams)}
>
<ArrowLeft className="h-4 w-4" />
</IconButton>
{history && (
+103 -32
View File
@@ -1,17 +1,25 @@
'use client';
import { motion, useMotionValue, useReducedMotion } from 'motion/react';
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import type { SceneEditorSurface, SurfaceState } from '@/lib/edit/scene-editor-surface';
import { sceneEditorRegistry } from '@/lib/edit/scene-editor-registry';
import { NOOP_SURFACE } from '@/lib/edit/noop-surface';
import type { Scene } from '@/lib/types/stage';
import { CHROME_DURATION, CHROME_EASE, CHROME_STAGGER } from '@/lib/edit/transitions';
import { StageGrid } from '@/components/edit/StageGrid';
import { ContainBox } from '@/components/edit/ContainBox';
import type { SceneType } from '@/lib/types/stage';
import { CommandBar } from './CommandBar';
import { FloatingInsertToolbar } from './FloatingInsertToolbar';
import { FloatingToolbar } from './FloatingToolbar';
import { HintRail } from './HintRail';
import { CanvasPager, type CanvasPagerProps } from './CanvasPager';
/** 16:9 classroom surfaces. Quiz is a form and stays full-bleed. */
function usesClassroomAspect(type: SceneType): boolean {
return type === 'slide' || type === 'interactive' || type === 'pbl';
}
interface EditShellProps {
readonly scene: Scene;
@@ -29,11 +37,15 @@ interface EditShellProps {
*/
readonly commandTrailing?: ReactNode;
/**
* Optional right-side panel slot. Used by the MAIC Agent PoC to mount the
* AI sidebar. Like `leftRail`, it is a pure chrome handoff — surface code
* never imports it. Collapses to zero width when absent.
* Page pager for the canvas (‹ n/m › scene flipper), in its FLOATING form.
* Passed down uninterpreted — the chrome stays pure; `EditChromeRoot` computes
* the state from `lib/edit/scene-pager.ts` + the stage store, and normally
* hands it to the edit dock's global edit bar instead. This slot is what remains
* for scene types that get no dock.
*/
readonly rightRail?: ReactNode;
readonly pager?: CanvasPagerProps;
/** Omit the whole top command bar inside the workbench panel. */
readonly hideCommandBar?: boolean;
/** Optional bottom bar (under the canvas) — used for the actions timeline. */
readonly bottomRail?: ReactNode;
}
@@ -55,7 +67,7 @@ const LEFT_RAIL_DELAY = CHROME_STAGGER * 2;
* ├──────────┬───────────────────────────────────┤
* │ leftRail │ Canvas / unsupported-scene │
* │ (opt) │ FloatingToolbar (when selected) │
* │ │ HintRail (AI, reserved) │
* │ │ HintRail (surface hints) │
* └──────────┴───────────────────────────────────┘
*
* Mount choreography: CommandBar drops in from top, leftRail slides in
@@ -75,8 +87,9 @@ export function EditShell({
scene,
leftRail,
commandTrailing,
rightRail,
bottomRail,
pager,
hideCommandBar,
}: EditShellProps) {
const surface = sceneEditorRegistry.resolve(scene.type) ?? NOOP_SURFACE;
// Surface state is published from a child runner (keyed by sceneType so it
@@ -85,10 +98,16 @@ export function EditShell({
// around it stays mounted and consumes state via these props.
const [state, setState] = useState<SurfaceState | null>(null);
// The insert palette disappears on surfaces that do not expose insert
// items (Quiz, GenUI, etc.). Keep its offset in the persistent shell so a
// temporary unmount does not discard the author's chosen position.
// items (Quiz, GenUI, etc.). Keep its offset AND its fold in the persistent
// shell so a temporary unmount does not discard either of the author's
// choices about where the strip sits and whether it is open.
const insertToolbarX = useMotionValue(0);
const insertToolbarY = useMotionValue(0);
const [insertToolbarCollapsed, setInsertToolbarCollapsed] = useState(false);
const toggleInsertToolbarCollapsed = useCallback(
() => setInsertToolbarCollapsed((current) => !current),
[],
);
const SurfaceComponent = surface.SurfaceComponent;
return (
@@ -104,16 +123,24 @@ export function EditShell({
<SurfaceStateRunner key={scene.type} surface={surface} onChange={setState} />
<Frame
title={scene.title}
sceneType={scene.type}
leftRail={leftRail}
history={state?.history}
commands={state?.commands}
trailing={commandTrailing}
rightRail={rightRail}
bottomRail={bottomRail}
pager={pager}
hideCommandBar={hideCommandBar}
>
<SurfaceComponent />
{state?.insertItems && state.insertItems.length > 0 && (
<FloatingInsertToolbar items={state.insertItems} x={insertToolbarX} y={insertToolbarY} />
{state && state.insertItems.length > 0 && (
<FloatingInsertToolbar
items={state.insertItems}
x={insertToolbarX}
y={insertToolbarY}
collapsed={insertToolbarCollapsed}
onToggleCollapsed={toggleInsertToolbarCollapsed}
/>
)}
{state?.hasSelection && <FloatingToolbar actions={state.floatingActions} />}
<HintRail hints={state?.hints} reserveSpace={scene.type === 'quiz'} />
@@ -227,23 +254,27 @@ function surfaceStateEqual(a: SurfaceState, b: SurfaceState | null): boolean {
interface FrameProps {
readonly title: string;
readonly sceneType: SceneType;
readonly leftRail?: ReactNode;
readonly history?: React.ComponentProps<typeof CommandBar>['history'];
readonly commands?: React.ComponentProps<typeof CommandBar>['commands'];
readonly trailing?: ReactNode;
readonly rightRail?: ReactNode;
readonly bottomRail?: ReactNode;
readonly pager?: CanvasPagerProps;
readonly hideCommandBar?: boolean;
readonly children: ReactNode;
}
function Frame({
title,
sceneType,
leftRail,
history,
commands,
trailing,
rightRail,
bottomRail,
pager,
hideCommandBar,
children,
}: FrameProps) {
const prefersReducedMotion = useReducedMotion();
@@ -270,13 +301,20 @@ function Frame({
<StageGrid
className="bg-gradient-to-b from-zinc-100 to-zinc-200 dark:from-zinc-950 dark:to-zinc-900"
topSlot={
<motion.div
initial={cmdInitial}
animate={cmdAnimate}
transition={{ ...stepTransition, delay: prefersReducedMotion ? 0 : COMMANDBAR_DELAY }}
>
<CommandBar title={title} history={history} commands={commands} trailing={trailing} />
</motion.div>
hideCommandBar ? null : (
// `data-maic-edit-chrome`: the workbench's hand-edit signal scope
// (structure ops live here — undo/redo, insert — while the nav rail
// is deliberately out of it: switching pages is navigation, not an
// edit). See lib/workbench/use-workbench-pro-edit.ts.
<motion.div
data-maic-edit-chrome="true"
initial={cmdInitial}
animate={cmdAnimate}
transition={{ ...stepTransition, delay: prefersReducedMotion ? 0 : COMMANDBAR_DELAY }}
>
<CommandBar title={title} history={history} commands={commands} trailing={trailing} />
</motion.div>
)
}
leftSlot={
leftRail ? (
@@ -291,19 +329,52 @@ function Frame({
) : null
}
centerSlot={
// Padded studio frame around the actual scene renderer. Lifted
// up from SlideCanvas so the slide and the non-slide read-only
// renderers share the exact same canvas bounding rect (no
// layout jump when switching scene type). Children render
// inside an inner ring/shadow card that the playback
// CanvasArea visually mirrors.
<div className="relative h-full w-full p-3 sm:p-4">
<div className="relative h-full w-full overflow-hidden rounded-xl bg-white ring-1 ring-zinc-200/80 dark:bg-zinc-900 dark:ring-zinc-800/80 shadow-[0_10px_40px_-12px_rgba(15,23,42,0.18)] dark:shadow-[0_10px_40px_-12px_rgba(0,0,0,0.6)]">
{children}
</div>
// Padded studio frame. Classroom scenes contain-fit the card at
// exactly 16:9, everywhere — a wide workbench panel gets side
// gutters, never a cropped slide. `fill-width` was tried here
// (card as wide as the frame) but it sized the card taller than
// the frame once the pane got wider than 16:9 of its height, and
// `overflow-hidden` cut the slide's bottom off.
//
// The pager anchors to the FRAME's bottom edge, not the card: the
// card keeps its contain-centred fit (untouched), and the pager
// floats in the frame's bottom padding. A card-anchored pager rides
// the slide's lower edge and covers canvas content (e.g. a slide's
// bottom banner) wherever containment puts the card; a frame-anchored
// one drops into the letterbox gap below the card in a tall pane and
// never overlaps the slide.
<div
data-maic-studio-frame="true"
className="relative h-full min-h-0 w-full overflow-hidden p-3 sm:p-4"
>
{/* `data-maic-edit-canvas`: the workbench's hand-edit signal scope
(typing / pointer gestures on the page itself). See
lib/workbench/use-workbench-pro-edit.ts. */}
{usesClassroomAspect(sceneType) ? (
<ContainBox
fit="contain"
className="overflow-hidden rounded-xl bg-white ring-1 ring-zinc-200/80 dark:bg-zinc-900 dark:ring-zinc-800/80 shadow-[0_10px_40px_-12px_rgba(15,23,42,0.18)] dark:shadow-[0_10px_40px_-12px_rgba(0,0,0,0.6)]"
>
<div
data-maic-edit-canvas="true"
data-maic-stage-card="true"
className="relative h-full w-full"
>
{children}
</div>
</ContainBox>
) : (
<div
data-maic-edit-canvas="true"
data-maic-stage-card="true"
className="relative h-full w-full overflow-hidden rounded-xl bg-white ring-1 ring-zinc-200/80 dark:bg-zinc-900 dark:ring-zinc-800/80 shadow-[0_10px_40px_-12px_rgba(15,23,42,0.18)] dark:shadow-[0_10px_40px_-12px_rgba(0,0,0,0.6)]"
>
{children}
</div>
)}
{pager ? <CanvasPager {...pager} /> : null}
</div>
}
rightSlot={rightRail ? <div className="h-full shrink-0">{rightRail}</div> : null}
bottomSlot={bottomRail ?? null}
/>
);
@@ -1,38 +1,99 @@
'use client';
import { motion, useDragControls, type MotionValue } from 'motion/react';
import { GripHorizontal } from 'lucide-react';
import {
AnimatePresence,
motion,
useDragControls,
useReducedMotion,
type MotionValue,
} from 'motion/react';
import { ChevronDown, GripHorizontal } from 'lucide-react';
import { useRef, useState, type KeyboardEvent } from 'react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useCanvasStore } from '@/lib/store/canvas';
import type { InsertPaletteItem } from '@/lib/edit/scene-editor-surface';
import { CHROME_DURATION, CHROME_EASE } from '@/lib/edit/transitions';
import { cn } from '@/lib/utils';
// The palette and the element picker's list are the two canvas overlays that
// position themselves, so they share one host: the studio frame, via
// `CanvasOverlayPortal`. It lives beside the picker because that is where it was
// written; the frame it measures is chrome geometry, not slide-surface state.
import {
CANVAS_OVERLAY_FRAME_SELECTOR,
CANVAS_OVERLAY_Z,
CanvasOverlayPortal,
} from '@/components/edit/surfaces/slide/CanvasOverlayPortal';
import { InsertButton } from './InsertButton';
interface Props {
readonly items: readonly InsertPaletteItem[];
readonly x: MotionValue<number>;
readonly y: MotionValue<number>;
/** Folded to its grip. Owned by the shell so a surface swap keeps it. */
readonly collapsed: boolean;
readonly onToggleCollapsed: () => void;
}
/**
* Persistent insert toolbar — floats inside the center-left edge of the studio
* canvas. Replaces the inline insert slot in CommandBar so the global stage
* controls (back, undo
* Persistent insert toolbar — floats over the studio canvas. Replaces the inline
* insert slot in CommandBar so the global stage controls (back, undo
* /redo, title, settings, Pro, Download) aren't visually mixed with
* content-insertion affordances ("text box / image / shape ..." live
* with the content, not with stage controls).
*
* Labels stay in tooltips so the vertical strip remains compact. A low-profile
* grip lets authors move the strip anywhere inside the studio without shifting
* the centered slide viewport or dedicating permanent layout space to it.
* the centered slide viewport or dedicating permanent layout space to it, and a
* chevron beside the grip folds the strip down to that grip when the slide
* underneath matters more than the tools.
*
* BOUNDS. The strip roams the STUDIO FRAME, not the slide card — the same
* container the element picker's list is clamped to, reached the same way
* (`CanvasOverlayPortal` + `CANVAS_OVERLAY_FRAME_SELECTOR`). Bounding it to the
* card meant the strip could only ever sit on top of slide content: it could not
* be parked in the grey padding beside the slide, and the card's
* `overflow-hidden` clipped it on the way out. The portal's box has the frame's
* geometry, so the drag constraints, the keyboard clamp and every measurement in
* here mean what they always meant — one box larger.
*
* WHILE PICKING. The element picker deliberately covers the whole canvas, so the
* strip rises above it (`paletteOverPicker`) to keep the picker's violet ring
* from painting across it — and goes inert up there: it takes no pointer events,
* so a click in its area falls through to the picker below and still means "pick
* this element". Nothing in the strip is clickable until the pick ends; the
* canvas underneath it is.
*/
export function FloatingInsertToolbar({ items, x, y }: Props) {
export function FloatingInsertToolbar({ items, x, y, collapsed, onToggleCollapsed }: Props) {
const { t } = useI18n();
const prefersReducedMotion = useReducedMotion();
const picking = useCanvasStore.use.pickTarget() !== null;
const constraintsRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const dragControls = useDragControls();
const [keyboardDragging, setKeyboardDragging] = useState(false);
/**
* Move the strip by (dx, dy), clamped so every edge stays inside the drag
* boundary. Motion enforces `dragConstraints` during a gesture only, so both
* of the non-gesture paths — the keyboard move and the re-clamp after the
* fold changes the strip's height — come through here and share one rule.
*/
const moveWithinBounds = (dx: number, dy: number) => {
const bounds = constraintsRef.current?.getBoundingClientRect();
const toolbar = toolbarRef.current?.getBoundingClientRect();
if (!bounds || !toolbar) return;
const clampedDx = Math.max(
bounds.left - toolbar.left,
Math.min(dx, bounds.right - toolbar.right),
);
const clampedDy = Math.max(
bounds.top - toolbar.top,
Math.min(dy, bounds.bottom - toolbar.bottom),
);
x.set(x.get() + clampedDx);
y.set(y.get() + clampedDy);
};
const handleDragKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
@@ -45,70 +106,110 @@ export function FloatingInsertToolbar({ items, x, y }: Props) {
}
if (!keyboardDragging || !event.key.startsWith('Arrow')) return;
const bounds = constraintsRef.current?.getBoundingClientRect();
const toolbar = toolbarRef.current?.getBoundingClientRect();
if (!bounds || !toolbar) return;
event.preventDefault();
const step = event.shiftKey ? 24 : 8;
const dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
const dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0;
const clampedDx = Math.max(
bounds.left - toolbar.left,
Math.min(dx, bounds.right - toolbar.right),
);
const clampedDy = Math.max(
bounds.top - toolbar.top,
Math.min(dy, bounds.bottom - toolbar.bottom),
);
x.set(x.get() + clampedDx);
y.set(y.get() + clampedDy);
moveWithinBounds(dx, dy);
};
if (items.length === 0) return null;
const foldTransition = prefersReducedMotion
? { duration: 0 }
: { duration: CHROME_DURATION, ease: CHROME_EASE };
return (
<div
ref={constraintsRef}
className="pointer-events-none absolute inset-2 z-30 flex items-center justify-start"
<CanvasOverlayPortal
zIndex={picking ? CANVAS_OVERLAY_Z.paletteOverPicker : CANVAS_OVERLAY_Z.palette}
testId="insert-toolbar-layer"
measureSelector={CANVAS_OVERLAY_FRAME_SELECTOR}
>
<motion.div
ref={toolbarRef}
drag
dragListener={false}
dragControls={dragControls}
dragConstraints={constraintsRef}
dragElastic={0.04}
dragMomentum={false}
style={{ x, y }}
whileDrag={{ scale: 1.02 }}
className={cn(
'pointer-events-auto flex flex-col items-center gap-1 p-1',
'bg-white/90 dark:bg-zinc-900/90 backdrop-blur-md',
'ring-1 ring-zinc-200/80 dark:ring-zinc-700/80',
'rounded-xl shadow-md',
)}
<div
ref={constraintsRef}
className="pointer-events-none absolute inset-2 flex items-center justify-start"
>
<button
type="button"
data-testid="insert-toolbar-drag-handle"
aria-label={t('edit.insert.dragToolbarKeyboard')}
aria-pressed={keyboardDragging}
title={t('edit.insert.dragToolbar')}
onPointerDown={(event) => {
setKeyboardDragging(false);
dragControls.start(event);
}}
onKeyDown={handleDragKeyDown}
onBlur={() => setKeyboardDragging(false)}
className="flex h-6 w-9 touch-none cursor-grab items-center justify-center rounded-md text-zinc-300 hover:bg-zinc-100 hover:text-zinc-500 focus-visible:outline-2 focus-visible:outline-violet-500 active:cursor-grabbing dark:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-400"
<motion.div
ref={toolbarRef}
data-testid="insert-toolbar"
data-collapsed={collapsed}
drag
dragListener={false}
dragControls={dragControls}
dragConstraints={constraintsRef}
dragElastic={0.04}
dragMomentum={false}
style={{ x, y }}
whileDrag={{ scale: 1.02 }}
className={cn(
'flex flex-col items-center gap-1 p-1',
picking ? 'pointer-events-none' : 'pointer-events-auto',
'bg-white/90 dark:bg-zinc-900/90 backdrop-blur-md',
'ring-1 ring-zinc-200/80 dark:ring-zinc-700/80',
'rounded-xl shadow-md',
)}
>
<GripHorizontal className="h-3 w-3" strokeWidth={2} />
</button>
{items.map((item) => (
<InsertButton key={item.id} item={item} iconOnly popoverSide="right" />
))}
</motion.div>
</div>
{/* Grip and fold share one low-profile row, so a folded strip IS the
grip — the same pairing the element picker's panel header uses. */}
<div className="flex w-9 items-center">
<button
type="button"
data-testid="insert-toolbar-drag-handle"
aria-label={t('edit.insert.dragToolbarKeyboard')}
aria-pressed={keyboardDragging}
title={t('edit.insert.dragToolbar')}
onPointerDown={(event) => {
setKeyboardDragging(false);
dragControls.start(event);
}}
onKeyDown={handleDragKeyDown}
onBlur={() => setKeyboardDragging(false)}
className="flex h-6 flex-1 touch-none cursor-grab items-center justify-center rounded-md text-zinc-300 hover:bg-zinc-100 hover:text-zinc-500 focus-visible:outline-2 focus-visible:outline-violet-500 active:cursor-grabbing dark:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-400"
>
<GripHorizontal className="h-3 w-3" strokeWidth={2} />
</button>
<button
type="button"
data-testid="insert-toolbar-collapse"
onClick={onToggleCollapsed}
aria-expanded={!collapsed}
aria-label={
collapsed ? t('edit.insert.expandToolbar') : t('edit.insert.collapseToolbar')
}
title={collapsed ? t('edit.insert.expandToolbar') : t('edit.insert.collapseToolbar')}
className="grid h-6 w-4 shrink-0 place-items-center rounded-md text-zinc-300 transition-colors hover:bg-zinc-100 hover:text-zinc-500 focus-visible:outline-2 focus-visible:outline-violet-500 dark:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-400"
>
<ChevronDown
className={cn('h-3 w-3 transition-transform', !collapsed && 'rotate-180')}
strokeWidth={2}
aria-hidden="true"
/>
</button>
</div>
{/* The fold unmounts the buttons rather than hiding them: a folded
strip must not keep three invisible tab stops. Expanding a strip
parked at the frame's bottom edge grows it past that edge, so the
re-clamp runs once the new height is settled — the same pitfall the
element picker's panel re-clamps for. */}
<AnimatePresence initial={false}>
{!collapsed && (
<motion.div
key="insert-items"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={foldTransition}
onAnimationComplete={() => moveWithinBounds(0, 0)}
className="flex w-9 flex-col items-center gap-1 overflow-hidden"
>
{items.map((item) => (
<InsertButton key={item.id} item={item} iconOnly popoverSide="right" />
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</CanvasOverlayPortal>
);
}
+28 -3
View File
@@ -8,6 +8,7 @@ import {
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { useStageStore } from '@/lib/store';
import { PENDING_SCENE_ID } from '@/lib/store/stage';
@@ -73,6 +74,12 @@ interface PlaybackChromeRootProps {
readonly canEnterProMode?: boolean;
/** Pro Switch click handler — parent coordinates teardown + mode flip. */
readonly onEnterProMode?: () => void;
readonly proModeActive?: boolean;
readonly headerBackControl?: ReactNode;
readonly hideHeaderBackControl?: boolean;
readonly hideHeader?: boolean;
readonly hideHeaderGlobalControls?: boolean;
readonly hideHeaderCourseActions?: boolean;
}
/**
@@ -83,7 +90,20 @@ interface PlaybackChromeRootProps {
* the engine wind down cleanly.
*/
export const PlaybackChromeRoot = forwardRef<PlaybackChromeRootHandle, PlaybackChromeRootProps>(
function PlaybackChromeRoot({ onRetryOutline, canEnterProMode, onEnterProMode }, ref) {
function PlaybackChromeRoot(
{
onRetryOutline,
canEnterProMode,
onEnterProMode,
proModeActive,
headerBackControl,
hideHeaderBackControl,
hideHeader,
hideHeaderGlobalControls,
hideHeaderCourseActions,
},
ref,
) {
const { t } = useI18n();
const {
mode,
@@ -1309,7 +1329,7 @@ export const PlaybackChromeRoot = forwardRef<PlaybackChromeRootHandle, PlaybackC
// non-'edit' here since the parent Stage unmounts this component
// when entering Pro mode.
const sceneViewerHeight = (() => {
const headerHeight = isPresenting ? 0 : 80;
const headerHeight = isPresenting || hideHeader ? 0 : 80;
const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0;
return `calc(100% - ${headerHeight + roundtableHeight}px)`;
})();
@@ -1335,15 +1355,20 @@ export const PlaybackChromeRoot = forwardRef<PlaybackChromeRootHandle, PlaybackC
{/* Header — playback only. The Pro Switch fires `onEnterProMode`
(passed by the parent Stage) which awaits our `teardown()`
before the parent flips mode to 'edit'. */}
{!isPresenting && (
{!isPresenting && !hideHeader && (
<Header
currentSceneTitle={
currentScene?.title ||
(isCourseComplete && isPendingScene ? t('stage.courseComplete') : '')
}
mode={mode}
proModeActive={proModeActive}
canEdit={!!canEnterProMode}
onToggleEditMode={onEnterProMode}
backControl={headerBackControl}
hideBackControl={hideHeaderBackControl}
hideGlobalControls={hideHeaderGlobalControls}
hideCourseActions={hideHeaderCourseActions}
/>
)}
-306
View File
@@ -1,306 +0,0 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import {
History,
PanelRightClose,
PanelRightOpen,
SquarePen,
Trash2,
UsersRound,
} from 'lucide-react';
import type { AssistantRuntime } from '@assistant-ui/react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import type { AgentEditSessionRecord } from '@/lib/agent/client/agent-edit-session-types';
import { useI18n } from '@/lib/hooks/use-i18n';
import { AgentPanel } from '@/components/edit/AgentPanel/AgentPanel';
import { AgentRosterPanel } from '@/components/edit/AgentsView/AgentRosterPanel';
import { shouldRenderAgentPanel } from '@/components/edit/agent-panel-visibility';
const MIN_WIDTH = 320;
const MAX_WIDTH = 640;
const DEFAULT_WIDTH = 384;
type RailTab = 'ai' | 'agents';
export interface RightRailTabsProps {
readonly scene?: { id: string; title: string; type?: string };
readonly runtime: AssistantRuntime;
readonly clearThread: () => void;
readonly hasMessages: boolean;
readonly canSend: boolean;
readonly agentEnabled: boolean;
readonly isRunning: boolean;
readonly sessions: AgentEditSessionRecord[];
readonly activeSessionId: string | undefined;
readonly switchSession: (id: string) => Promise<void>;
readonly deleteSessionAndRefresh: (id: string) => Promise<void>;
readonly refreshSessions: () => Promise<void>;
}
/**
* Tabbed right rail: AI editor | classroom roster
*
* Owns the aside wrapper, resize handle, collapse state, and tab state.
* The AgentPanel is rendered in naked (no-wrapper) mode for the AI tab;
* the roster tab renders AgentRosterPanel. Both are kept mounted so state
* is preserved when switching tabs (hidden via CSS).
*
* The AI editor tab is gated by shouldRenderAgentPanel — when the current
* scene type does not support AI editing, the tab is hidden and the active tab
* falls back to roster (which is always available, as agents are stage-level).
*/
export function RightRailTabs({
scene,
runtime,
clearThread,
hasMessages,
canSend,
agentEnabled,
isRunning,
sessions,
activeSessionId,
switchSession,
deleteSessionAndRefresh,
refreshSessions,
}: RightRailTabsProps) {
const { t } = useI18n();
const showAiTab = shouldRenderAgentPanel({ agentEnabled, hasMessages, isRunning });
const [activeTab, setActiveTab] = useState<RailTab>(() => (showAiTab ? 'ai' : 'agents'));
// When the AI tab becomes unavailable (e.g. PBL scene), fall back to agents tab.
// Render-time setState: React re-renders immediately before painting.
if (!showAiTab && activeTab === 'ai') {
setActiveTab('agents');
}
const [collapsed, setCollapsed] = useState(false);
const railRef = useRef<HTMLElement>(null);
const [width, setWidth] = useState(DEFAULT_WIDTH);
const dragRef = useRef<{
startX: number;
startW: number;
lastW: number;
pointerId: number;
} | null>(null);
const onResizeStart = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const startW = railRef.current?.getBoundingClientRect().width ?? width;
dragRef.current = { startX: e.clientX, startW, lastW: startW, pointerId: e.pointerId };
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* best effort */
}
document.body.style.cursor = 'col-resize';
},
[width],
);
const onResizeMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
if (!d || e.pointerId !== d.pointerId) return;
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, d.startW + (d.startX - e.clientX)));
d.lastW = next;
if (railRef.current) railRef.current.style.width = `${next}px`;
}, []);
const onResizeEnd = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
if (!d || e.pointerId !== d.pointerId) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
/* may already be released */
}
setWidth(d.lastW);
dragRef.current = null;
document.body.style.cursor = '';
}, []);
if (collapsed) {
return (
<aside
onClick={() => setCollapsed(false)}
title={t('edit.agent.expand')}
className="group/rail relative flex h-full w-11 shrink-0 cursor-pointer flex-col items-center gap-3 border-l border-gray-100 bg-white/80 pt-3 backdrop-blur-xl transition-colors hover:bg-violet-50/40 dark:border-gray-800 dark:bg-slate-900/80 dark:hover:bg-violet-500/5 shadow-[-2px_0_24px_rgba(0,0,0,0.02)]"
>
<span className="grid size-8 place-items-center rounded-lg text-[#5b1fa8] transition-colors group-hover/rail:bg-violet-100/70 dark:text-violet-300 dark:group-hover/rail:bg-violet-500/15">
<PanelRightOpen className="size-4" />
</span>
</aside>
);
}
const agentPanelProps = {
scene,
runtime,
clearThread,
hasMessages,
canSend,
sessions,
activeSessionId,
switchSession,
deleteSessionAndRefresh,
refreshSessions,
};
return (
<aside
ref={railRef}
style={{ width }}
className="relative flex h-full shrink-0 flex-col border-l border-gray-100 bg-white/80 backdrop-blur-xl dark:border-gray-800 dark:bg-slate-900/80 shadow-[-2px_0_24px_rgba(0,0,0,0.02)]"
>
{/* Resize handle */}
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
className="group absolute left-0 top-0 bottom-0 z-10 w-1.5 cursor-col-resize touch-none transition-colors hover:bg-violet-400/30 active:bg-violet-500/50 dark:hover:bg-violet-500/30"
>
<div className="absolute left-0.5 top-1/2 h-8 w-0.5 -translate-y-1/2 rounded-full bg-gray-300 transition-colors group-hover:bg-violet-400 dark:bg-gray-600 dark:group-hover:bg-violet-500" />
</div>
{/* Tab strip — single header row, no nested header */}
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-gray-100 px-2 dark:border-gray-800">
<div
role="tablist"
className="flex items-center gap-0.5 rounded-lg bg-zinc-100/80 p-0.5 dark:bg-zinc-800"
>
{showAiTab && (
<RailTabButton
label={t('edit.agent.title')}
active={activeTab === 'ai'}
onClick={() => setActiveTab('ai')}
/>
)}
<RailTabButton
label={t('edit.roster.title')}
icon={<UsersRound className="size-[15px]" />}
active={activeTab === 'agents'}
onClick={() => setActiveTab('agents')}
/>
</div>
{/* Spacer + conditional AI-tab actions */}
<div className="flex flex-1 items-center justify-end gap-0.5">
{activeTab === 'ai' && (
<>
<Popover onOpenChange={(open) => open && void refreshSessions()}>
<PopoverTrigger asChild>
<button
type="button"
title={t('edit.agent.sessionHistory')}
aria-label={t('edit.agent.sessionHistory')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/55 transition-colors hover:bg-muted hover:text-foreground"
>
<History className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-1">
{sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t('edit.agent.sessionEmpty')}
</p>
) : (
<ul className="max-h-80 overflow-y-auto">
{sessions.map((s) => (
<li key={s.id} className="group flex items-center gap-1">
<button
type="button"
onClick={() => void switchSession(s.id)}
className={cn(
'flex-1 truncate rounded-md px-2 py-1.5 text-left text-[13px] transition-colors hover:bg-muted',
s.id === activeSessionId
? 'bg-muted font-medium text-foreground'
: 'text-muted-foreground',
)}
>
{s.title || t('edit.agent.sessionUntitled')}
</button>
<button
type="button"
title={t('edit.agent.sessionDelete')}
aria-label={t('edit.agent.sessionDelete')}
onClick={() => void deleteSessionAndRefresh(s.id)}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground/40 opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</li>
))}
</ul>
)}
</PopoverContent>
</Popover>
{hasMessages && (
<button
type="button"
onClick={clearThread}
title={t('edit.agent.newConversation')}
aria-label={t('edit.agent.newConversation')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/55 transition-colors hover:bg-muted hover:text-foreground"
>
<SquarePen className="size-4" />
</button>
)}
</>
)}
{/* Collapse button always visible */}
<button
type="button"
onClick={() => setCollapsed(true)}
title={t('edit.agent.collapse')}
aria-label={t('edit.agent.collapse')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/55 transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightClose className="size-4" />
</button>
</div>
</div>
{/* Tab content — both mounted, non-active hidden via CSS for state preservation */}
<div className={cn('flex flex-1 min-h-0 flex-col', activeTab !== 'ai' && 'hidden')}>
<AgentPanel naked {...agentPanelProps} />
</div>
<div className={cn('flex flex-1 min-h-0 flex-col', activeTab !== 'agents' && 'hidden')}>
<AgentRosterPanel />
</div>
</aside>
);
}
function RailTabButton({
label,
icon,
active,
onClick,
}: {
readonly label: string;
readonly icon?: React.ReactNode;
readonly active: boolean;
readonly onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={cn(
'flex items-center gap-1 rounded-md px-2.5 py-0.5 text-[11.5px] font-semibold transition-all',
active
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-zinc-100'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200',
)}
>
{icon}
{label}
</button>
);
}
+88 -34
View File
@@ -1,11 +1,15 @@
'use client';
import { Plus } from 'lucide-react';
import { ListChecks, Plus, Presentation } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { EditableSceneType } from '@/lib/edit/scene-defaults';
interface InsertionZoneProps {
readonly label: string;
readonly onInsert: () => void;
readonly slideLabel: string;
readonly quizLabel: string;
readonly onInsert: (type: EditableSceneType) => void;
}
/**
@@ -17,48 +21,98 @@ interface InsertionZoneProps {
* on its own z-layer with a solid background + soft drop shadow so it
* clearly floats above any adjacent violet ring.
*/
export function InsertionZone({ label, onInsert }: InsertionZoneProps) {
export function InsertionZone({ label, slideLabel, quizLabel, onInsert }: InsertionZoneProps) {
// `z-20` lifts the whole zone above adjacent `Reorder.Item` siblings.
// Without this, the next-in-DOM-order ThumbItem (which has a `transform`
// via motion's Reorder, creating its own stacking context) paints on
// top, and its violet ring clips through the `+` badge regardless of
// any z-index applied inside the InsertionZone itself.
return (
<div className="group/insert relative isolate z-20 h-2 cursor-pointer overflow-visible">
<Popover>
<div className="group/insert relative isolate z-20 h-2 cursor-pointer overflow-visible">
<PopoverTrigger asChild>
<button
type="button"
aria-label={label}
title={label}
data-testid="slide-nav-insert"
className="absolute inset-0 z-10 outline-none focus-visible:opacity-100"
>
<span className="sr-only">{label}</span>
<span
aria-hidden
className={cn(
// Anchored at the right edge of the gap, vertically centered.
// z-30 lifts it above the adjacent active tile's ring (z-default).
'pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 z-30',
'inline-flex h-5 w-5 items-center justify-center rounded-full',
// Solid background + ring + shadow gives it real visual
// elevation against the neighbouring violet ring zones.
'bg-white text-violet-600 ring-1 ring-violet-200',
'dark:bg-zinc-900 dark:text-violet-300 dark:ring-violet-400/40',
'shadow-md shadow-violet-500/15 dark:shadow-violet-500/20',
// Popup motion: start tiny + transparent, end full size with a
// small overshoot. The custom cubic-bezier is a classic
// "back-ease-out" giving it a quick, springy reveal.
'opacity-0 scale-50',
'group-hover/insert:opacity-100 group-hover/insert:scale-100',
'group-focus-within/insert:opacity-100 group-focus-within/insert:scale-100',
'transition-[opacity,transform] duration-200',
'[transition-timing-function:cubic-bezier(0.34,1.56,0.64,1)]',
)}
>
<Plus className="h-3 w-3" strokeWidth={2.5} />
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="center"
sideOffset={8}
className="w-40 p-1.5"
data-testid="scene-type-chooser"
>
<SceneTypeChoice
type="slide"
label={slideLabel}
Icon={Presentation}
onInsert={onInsert}
/>
<SceneTypeChoice type="quiz" label={quizLabel} Icon={ListChecks} onInsert={onInsert} />
</PopoverContent>
</div>
</Popover>
);
}
function SceneTypeChoice({
type,
label,
Icon,
onInsert,
}: {
readonly type: EditableSceneType;
readonly label: string;
readonly Icon: typeof Presentation;
readonly onInsert: (type: EditableSceneType) => void;
}) {
return (
<PopoverClose asChild>
<button
type="button"
onClick={onInsert}
aria-label={label}
title={label}
data-testid="slide-nav-insert"
className="absolute inset-0 z-10 outline-none focus-visible:opacity-100"
>
<span className="sr-only">{label}</span>
</button>
<span
aria-hidden
onClick={() => onInsert(type)}
data-testid={`scene-type-${type}`}
className={cn(
// Anchored at the right edge of the gap, vertically centered.
// z-30 lifts it above the adjacent active tile's ring (z-default).
'pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 z-30',
'inline-flex h-5 w-5 items-center justify-center rounded-full',
// Solid background + ring + shadow gives it real visual
// elevation against the neighbouring violet ring zones.
'bg-white text-violet-600 ring-1 ring-violet-200',
'dark:bg-zinc-900 dark:text-violet-300 dark:ring-violet-400/40',
'shadow-md shadow-violet-500/15 dark:shadow-violet-500/20',
// Popup motion: start tiny + transparent, end full size with a
// small overshoot. The custom cubic-bezier is a classic
// "back-ease-out" giving it a quick, springy reveal.
'opacity-0 scale-50',
'group-hover/insert:opacity-100 group-hover/insert:scale-100',
'group-focus-within/insert:opacity-100 group-focus-within/insert:scale-100',
'transition-[opacity,transform] duration-200',
'[transition-timing-function:cubic-bezier(0.34,1.56,0.64,1)]',
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm',
'text-zinc-700 outline-none transition-colors hover:bg-violet-50 hover:text-violet-700',
'focus-visible:bg-violet-50 focus-visible:text-violet-700',
'dark:text-zinc-200 dark:hover:bg-violet-500/10 dark:hover:text-violet-300',
'dark:focus-visible:bg-violet-500/10 dark:focus-visible:text-violet-300',
)}
>
<Plus className="h-3 w-3" strokeWidth={2.5} />
</span>
</div>
<Icon className="h-4 w-4 text-violet-500" strokeWidth={1.8} aria-hidden="true" />
<span>{label}</span>
</button>
</PopoverClose>
);
}
+130 -182
View File
@@ -3,31 +3,42 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AnimatePresence, Reorder, motion, useReducedMotion } from 'motion/react';
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { markStagePersistenceDirty, useStageStore } from '@/lib/store/stage';
import { useBrand, useIsDesktop } from '@/lib/brand/brand-context';
import { useStageStore } from '@/lib/store';
import { useSettingsStore } from '@/lib/store/settings';
import { useI18n } from '@/lib/hooks/use-i18n';
import { collectStageAssetRefs } from '@/lib/media/collect-stage-asset-refs';
import { createBlankSlideScene, duplicateSlideScene } from '@/lib/edit/slide-defaults';
import { useDeletedSceneRecycle } from '@/lib/edit/deleted-scene-recycle';
import { duplicateSlideScene } from '@/lib/edit/slide-defaults';
import {
createBlankEditableScene,
insertSceneAtIndex,
type EditableSceneType,
} from '@/lib/edit/scene-defaults';
import { SCENE_CREATION_ENABLED } from '@/lib/edit/scene-creation-enabled';
import { CHROME_DURATION_MS, CHROME_EASE, CHROME_EASE_CSS } from '@/lib/edit/transitions';
import { useInWorkbenchPanel } from '@/lib/workbench/panel-context';
import type { Scene } from '@/lib/types/stage';
import { ThumbItem } from './ThumbItem';
import { InsertionZone } from './InsertionZone';
const RAIL_COLLAPSED_PX = 56;
// Collapsed, the rail is a slim edge handle — just wide enough to hold the
// expand chevron — rather than a narrow column of page numbers. The point of
// collapsing is to give the horizontal space back to the canvas, so it keeps
// almost none; the handle is the only thing that survives, so the rail can be
// brought back.
const RAIL_HANDLE_PX = 16;
const RAIL_MIN_PX = 180;
const RAIL_MAX_PX = 360;
const DELETED_SCENE_UNDO_MS = 5000;
/**
* Pro mode slide-navigation left rail (Studio Editor aesthetic).
*
* Layout: a vertical thumbnail strip with monospaced index captions
* below each tile, inter-thumb "+" insertion zones revealed on hover,
* and a collapse toggle at the rail head. All scene types are
* and a collapse chevron on the rail/slide boundary. All scene types are
* first-class — slides render a live `ThumbnailSlide`, non-slide scenes
* get a type-icon stub but stay clickable, draggable, and right-clickable
* so page-level management is uniform across the deck.
@@ -40,6 +51,9 @@ const DELETED_SCENE_UNDO_MS = 5000;
export function SlideNavRail() {
const { t } = useI18n();
const router = useRouter();
const brand = useBrand();
const isDesktop = useIsDesktop();
const inWorkbenchPanel = useInWorkbenchPanel();
const scenes = useStageStore.use.scenes();
const currentSceneId = useStageStore.use.currentSceneId();
const setCurrentSceneId = useStageStore.use.setCurrentSceneId();
@@ -164,11 +178,6 @@ export function SlideNavRail() {
// more than one scene overall — otherwise the deck would become empty.
const totalScenes = scenes.length;
const currentScene = useMemo(
() => scenes.find((s) => s.id === currentSceneId) ?? null,
[scenes, currentSceneId],
);
const onReorderIds = useCallback(
(newOrder: string[]) => {
const byId = new Map(scenes.map((s) => [s.id, s] as const));
@@ -185,53 +194,22 @@ export function SlideNavRail() {
const handleActivate = useCallback(
(sceneId: string) => {
if (sceneId === currentSceneId) return;
// Switching to a non-slide scene is fine — Stage's auto-exit
// effect drops Pro mode the moment the new scene is uneditable.
// Switching to a non-slide scene is fine — Stage will auto-exit Pro
// mode the moment the new scene is uneditable.
setCurrentSceneId(sceneId);
},
[currentSceneId, setCurrentSceneId],
);
/**
* Insert a fresh blank slide *before* the given scene. The first
* InsertionZone (above the first thumb) calls this with `scenes[0]`
* so it ends up at index 0 — `setScenes([blank, ...scenes])` is
* used directly there since the `insertSceneAfter` API only supports
* insertion after an existing anchor.
*/
const handleInsertBefore = useCallback(
(beforeSceneId: string) => {
if (!stage) return;
const beforeIndex = scenes.findIndex((s) => s.id === beforeSceneId);
if (beforeIndex < 0) return;
const blank = createBlankSlideScene(stage.id, t('edit.nav.untitledSlide'), beforeIndex + 1);
if (beforeIndex === 0) {
// Prepend: setScenes rebalances `order` to match the array index.
setScenes([blank, ...scenes]);
setCurrentSceneId(blank.id);
return;
}
const anchor = scenes[beforeIndex - 1];
insertSceneAfter(anchor.id, blank);
setCurrentSceneId(blank.id);
},
[insertSceneAfter, scenes, setCurrentSceneId, setScenes, stage, t],
);
const handleInsertAt = useCallback(
(afterSceneId: string | null) => {
(insertIndex: number, type: EditableSceneType) => {
if (!stage) return;
const anchor = afterSceneId
? scenes.find((s) => s.id === afterSceneId)
: (currentScene ?? scenes[scenes.length - 1]);
if (!anchor) return;
const anchorIndex = scenes.findIndex((s) => s.id === anchor.id);
const newOrder = anchorIndex + 2;
const blank = createBlankSlideScene(stage.id, t('edit.nav.untitledSlide'), newOrder);
insertSceneAfter(anchor.id, blank);
setCurrentSceneId(blank.id);
const title = type === 'slide' ? t('edit.nav.untitledSlide') : t('edit.sceneType.quiz');
const scene = createBlankEditableScene(type, stage.id, title, insertIndex + 1);
setScenes(insertSceneAtIndex(scenes, scene, insertIndex));
setCurrentSceneId(scene.id);
},
[currentScene, insertSceneAfter, scenes, setCurrentSceneId, stage, t],
[scenes, setCurrentSceneId, setScenes, stage, t],
);
const handleDuplicate = useCallback(
@@ -262,43 +240,29 @@ export function SlideNavRail() {
const handleDelete = useCallback(
(sceneId: string) => {
const source = scenes.find((s) => s.id === sceneId);
if (!source || !stage) return;
if (!source) return;
// Hold deck-empty guard at the rail layer; the store doesn't enforce.
if (source.type === 'slide' && slideCount <= 1) return;
if (totalScenes <= 1) return;
const index = scenes.findIndex((s) => s.id === sceneId);
const sourceRefs = collectStageAssetRefs(
{ stage: { ...stage, videoManifest: undefined }, scenes: [source] },
{ mediaRows: [], audioRows: [] },
).referenced;
const manifestEntries = Object.fromEntries(
Object.entries(stage.videoManifest ?? {}).filter(([ref]) => sourceRefs.has(ref)),
);
useDeletedSceneRecycle.getState().capture(source, index);
deleteScene(sceneId);
toast(t('edit.nav.deleted'), {
description: source.title,
duration: DELETED_SCENE_UNDO_MS,
duration: 5000,
action: {
label: t('edit.nav.undo'),
onClick: () => {
const entry = useDeletedSceneRecycle.getState().consume();
if (!entry) return;
// Stage-scope guard: if the user has navigated to a
// different stage while the toast was up, the deleted scene
// belongs to the previous stage. Drop the undo rather than
// inserting it into the wrong deck.
// different stage while the toast was up, the recycle
// entry belongs to the previous stage and `insertSceneAfter`
// would reject it on stage-id mismatch (silently losing the
// deleted scene). Drop the undo when stages don't match
// rather than blasting the entry into the wrong deck.
const currentStage = useStageStore.getState().stage;
if (!currentStage || currentStage.id !== source.stageId) return;
if (Object.keys(manifestEntries).length > 0) {
useStageStore.setState({
stage: {
...currentStage,
videoManifest: {
...currentStage.videoManifest,
...manifestEntries,
},
},
});
markStagePersistenceDirty([{ kind: 'stage' }]);
}
if (!currentStage || currentStage.id !== entry.stageId) return;
const live = useStageStore.getState().scenes;
// Prepend path — `insertSceneAfter` requires an anchor, but
// restoring index 0 (the previously-first slide) has no
@@ -306,20 +270,22 @@ export function SlideNavRail() {
// and inserting after `live[0]` would land the entry at
// position 1 instead of 0. setScenes-with-rebalance
// preserves the original "first slide" semantics.
if (index === 0 || live.length === 0) {
useStageStore.getState().setScenes([source, ...live]);
useStageStore.getState().setCurrentSceneId(source.id);
if (entry.index === 0 || live.length === 0) {
useStageStore.getState().setScenes([entry.scene, ...live]);
useStageStore.getState().setCurrentSceneId(entry.scene.id);
return;
}
const anchorIndex = Math.min(index - 1, live.length - 1);
const anchorIndex = Math.min(entry.index - 1, live.length - 1);
const anchor = live[anchorIndex];
useStageStore.getState().insertSceneAfter(anchor.id, source);
useStageStore.getState().setCurrentSceneId(source.id);
useStageStore.getState().insertSceneAfter(anchor.id, entry.scene);
useStageStore.getState().setCurrentSceneId(entry.scene.id);
},
},
onDismiss: () => useDeletedSceneRecycle.getState().clear(),
onAutoClose: () => useDeletedSceneRecycle.getState().clear(),
});
},
[deleteScene, scenes, slideCount, stage, totalScenes, t],
[deleteScene, scenes, slideCount, totalScenes, t],
);
const canDeleteAny = totalScenes > 1;
@@ -359,7 +325,7 @@ export function SlideNavRail() {
'shadow-[2px_0_24px_rgba(0,0,0,0.02)]',
)}
style={{
width: collapsed ? RAIL_COLLAPSED_PX : persistedWidth,
width: collapsed ? RAIL_HANDLE_PX : persistedWidth,
transition: widthTransitionCss,
}}
>
@@ -380,63 +346,79 @@ export function SlideNavRail() {
<div className="absolute right-0.5 top-1/2 -translate-y-1/2 w-0.5 h-8 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-violet-400 dark:group-hover:bg-violet-500 transition-colors" />
</div>
)}
{/* Header band — mirrors playback `SceneSidebar`: OpenMAIC logo
on the left (click → home), action cluster on the right.
Height (h-10 + mt-3 mb-1 = ~56px) matches playback so the
chrome top edge stays at the same screen pixel across the
mode swap. */}
<div
className={cn(
'shrink-0 px-3 mt-3 mb-1 h-10',
collapsed ? 'flex flex-col items-center gap-1' : 'flex items-center justify-between',
)}
>
{!collapsed && (
<button
type="button"
onClick={() => router.push('/')}
title={t('generation.backToHome')}
className="flex items-center gap-2 cursor-pointer rounded-lg px-1.5 -mx-1.5 py-1 -my-1 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 active:scale-[0.97] transition-all duration-150"
>
<img src="/logo-horizontal.png" alt="OpenMAIC" className="h-6" />
</button>
)}
<div className={cn('flex items-center gap-1', collapsed && 'flex-col')}>
{/* Insertion lives in the `InsertionZone` strips between (and
before/after) thumbs now — no header `+` button. */}
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? t('edit.nav.expand') : t('edit.nav.collapse')}
title={collapsed ? t('edit.nav.expand') : t('edit.nav.collapse')}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-lg',
'bg-gray-100/80 text-gray-500 ring-1 ring-black/[0.04]',
'dark:bg-gray-800/80 dark:text-gray-400 dark:ring-white/[0.06]',
'hover:bg-gray-200/90 hover:text-gray-700',
'dark:hover:bg-gray-700/90 dark:hover:text-gray-200',
'active:scale-90 transition-all duration-200',
)}
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
{/* Collapse / expand control. Two forms of one toggle (stable testid):
- EXPANDED: a faint chevron on the rail/slide boundary, one register
lighter than a pane seam because this is a boundary inside a pane.
- COLLAPSED: the whole slim handle IS the control — a full-height edge
strip with a centred chevron — so the rail that gave its width back to
the canvas is still easy to find and bring back. */}
{collapsed ? (
<button
type="button"
onClick={() => setCollapsed(false)}
aria-label={t('edit.nav.expand')}
title={t('edit.nav.expand')}
data-testid="slide-nav-rail-collapse"
className={cn(
'absolute inset-0 z-10 flex items-center justify-center',
'text-zinc-400/70 dark:text-zinc-500/70',
'hover:bg-gray-100/80 hover:text-zinc-600 dark:hover:bg-gray-800/80 dark:hover:text-zinc-300',
'focus-visible:outline-none focus-visible:bg-gray-100/80 focus-visible:text-zinc-600 focus-visible:ring-1 focus-visible:ring-violet-400/50 dark:focus-visible:bg-gray-800/80 dark:focus-visible:text-zinc-300',
'active:bg-gray-200/90 active:text-zinc-700 dark:active:bg-gray-700/90 dark:active:text-zinc-200',
'transition-colors duration-150',
)}
>
<ChevronRight className="h-3 w-3" strokeWidth={1.75} aria-hidden="true" />
</button>
) : (
<button
type="button"
onClick={() => setCollapsed(true)}
aria-label={t('edit.nav.collapse')}
title={t('edit.nav.collapse')}
data-testid="slide-nav-rail-collapse"
className={cn(
'absolute right-0 top-1/2 z-10 flex h-8 w-6 -translate-y-1/2 items-center justify-center rounded-l-md',
'text-zinc-400/70 dark:text-zinc-500/70',
'hover:bg-gray-100/80 hover:text-zinc-600 dark:hover:bg-gray-800/80 dark:hover:text-zinc-300',
'focus-visible:outline-none focus-visible:bg-gray-100/80 focus-visible:text-zinc-600 focus-visible:ring-1 focus-visible:ring-violet-400/50 dark:focus-visible:bg-gray-800/80 dark:focus-visible:text-zinc-300',
'active:bg-gray-200/90 active:text-zinc-700 dark:active:bg-gray-700/90 dark:active:text-zinc-200',
'transition-colors duration-150',
)}
>
<ChevronLeft className="h-3 w-3" strokeWidth={1.75} aria-hidden="true" />
</button>
)}
{/* Header band — mirrors playback `SceneSidebar`: OpenMAIC logo on
the left (click → home). Height (h-10 + mt-3 + mb-1 = ~56px)
matches playback so the chrome top edge stays at the same screen
pixel across the mode swap. Inside the workbench panel the band
is dropped entirely — its only other occupant, the collapse
control, now lives on the rail/slide boundary — so the first
thumbnail starts on the same 12px rhythm as the canvas. */}
{!inWorkbenchPanel && !collapsed && (
<div className="shrink-0 px-3 mt-3 mb-1 h-10 flex items-center">
{!collapsed && !isDesktop && (
<button
type="button"
onClick={() => router.push('/')}
title={t('generation.backToHome')}
className="flex items-center gap-2 cursor-pointer rounded-lg px-1.5 -mx-1.5 py-1 -my-1 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 active:scale-[0.97] transition-all duration-150"
>
{/* Desktop client: the Electron title bar already shows the brand icon + name, so the edit rail doesn't repeat it;
returning home is handled by the edit bar's CommandBar back arrow. */}
<img src={brand.logoSrc} alt={brand.productName} className="h-6 w-auto" />
</button>
)}
</div>
</div>
)}
{/* Body — list padding (p-2 space-y-2) matches playback's scene
list so spacing/density read the same. */}
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-hide pt-1">
{collapsed ? (
<CollapsedList
scenes={scenes}
currentSceneId={currentSceneId}
onActivate={handleActivate}
/>
) : (
list so spacing/density read the same. Collapsed, there is no body at
all: the slim handle above is the whole rail. */}
{!collapsed && (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-hide pt-1">
<AnimatePresence initial={false}>
<motion.div
key="expanded-list"
@@ -457,8 +439,10 @@ export function SlideNavRail() {
case the user called out. */}
{SCENE_CREATION_ENABLED && scenes[0] ? (
<InsertionZone
label={t('edit.nav.addSlide')}
onInsert={() => handleInsertBefore(scenes[0].id)}
label={t('edit.nav.addPage')}
slideLabel={t('edit.sceneType.slide')}
quizLabel={t('edit.sceneType.quiz')}
onInsert={(type) => handleInsertAt(0, type)}
/>
) : null}
{scenes.map((scene, index) => (
@@ -474,8 +458,10 @@ export function SlideNavRail() {
/>
{SCENE_CREATION_ENABLED && (
<InsertionZone
label={t('edit.nav.addSlide')}
onInsert={() => handleInsertAt(scene.id)}
label={t('edit.nav.addPage')}
slideLabel={t('edit.sceneType.slide')}
quizLabel={t('edit.sceneType.quiz')}
onInsert={(type) => handleInsertAt(index + 1, type)}
/>
)}
</Fragment>
@@ -483,46 +469,8 @@ export function SlideNavRail() {
</Reorder.Group>
</motion.div>
</AnimatePresence>
)}
</div>
</div>
)}
</aside>
);
}
interface CollapsedListProps {
readonly scenes: readonly Scene[];
readonly currentSceneId: string | null;
readonly onActivate: (sceneId: string) => void;
}
function CollapsedList({ scenes, currentSceneId, onActivate }: CollapsedListProps) {
return (
<ol className="m-0 flex flex-col items-stretch gap-0.5 py-2 px-1.5 list-none">
{scenes.map((scene, index) => {
const active = scene.id === currentSceneId;
const isSlide = scene.type === 'slide';
return (
<li key={scene.id}>
<button
type="button"
onClick={() => onActivate(scene.id)}
title={scene.title || `${index + 1}`}
data-active={active}
data-scene-type={scene.type}
className={cn(
'group/cl flex h-7 w-full items-center justify-center rounded-md',
'font-mono text-[10px] leading-none tabular-nums tracking-wide transition-colors',
active
? 'bg-violet-500 text-white shadow-sm shadow-violet-500/40'
: 'text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-800 dark:hover:text-zinc-200',
!isSlide && !active && 'text-zinc-400/80 dark:text-zinc-500/80',
)}
>
{String(index + 1).padStart(2, '0')}
</button>
</li>
);
})}
</ol>
);
}
-13
View File
@@ -1,13 +0,0 @@
export interface AgentPanelVisibilityState {
readonly agentEnabled: boolean;
readonly hasMessages: boolean;
readonly isRunning: boolean;
}
export function shouldRenderAgentPanel({
agentEnabled,
hasMessages,
isRunning,
}: AgentPanelVisibilityState): boolean {
return agentEnabled || hasMessages || isRunning;
}
+1 -2
View File
@@ -4,8 +4,7 @@ import type { SceneType } from '@/lib/types/stage';
* The narration timeline (ActionsBar) is decoupled from the canvas editor
* surface. It applies wherever a spoken script makes sense: scene types with a
* registered editor surface (slide/quiz), PLUS view-only-canvas scenes that
* still carry narration (interactive/pbl). Mirrors how the AI edit panel
* (agentEnabled) is decoupled from the canvas surface in EditChromeRoot.
* still carry narration (interactive/pbl).
*/
const NARRATION_ONLY_TYPES: ReadonlySet<SceneType> = new Set(['interactive', 'pbl']);
@@ -0,0 +1,251 @@
'use client';
/**
* CanvasOverlayPortal — a floating surface that belongs to the slide's canvas
* area but is not clipped by it.
*
* The overlay this hosts (the insert palette, the element picker's list) must be
* free to sit in the grey padding AROUND the beige slide card without either
* being cut off or escaping to the timeline / pager / thumbnail rail. So its home
* is the STUDIO FRAME — the padded, rounded canvas container that holds the card
* (`data-maic-studio-frame`), the region from just under the pane's title row down
* to just above the edit dock. Not the card (too small — clips, and pins the strip
* against slide content), not the document viewport (too big — the widget could
* wander onto the dock or off screen).
*
* The frame is `overflow-hidden`, so the overlay renders in a portal on
* `document.body` in a `fixed` box placed exactly over the frame. Nothing else
* changes for the children: the box has the frame's geometry, so `absolute`
* positions, drag clamping and `getBoundingClientRect` measurements inside it mean
* what they always meant — a child measured relative to this box lands at the same
* screen point whether the box is the card or the frame. This is the same trick
* `AnchoredBar` uses for the selection bars (a fixed virtual anchor plus a portaled
* surface); it is spelled out here because these two overlays position themselves
* rather than handing the job to Radix.
*
* `measureSelector` names the ancestor whose rect the box takes — the anchor still
* lives inside the card (so it inherits the card's visibility for the folded-pane
* case), but `closest(measureSelector)` walks up to the frame it is measured
* against. The rect is tracked, not read once: the pane resizes, the window
* scrolls, and a canvas zoom is an animated ancestor transform that no
* ResizeObserver can see.
*
* `capturePointer` decides whether the portal's own box swallows pointer events.
* The picker WANTS to (pick mode covers the whole canvas — a click means "this
* element"); the palette must NOT (its box now spans the frame, over the card, and
* a solid capture layer there would make the slide unclickable), so its wrapper is
* `pointer-events-none` and only the strip inside it opts back in.
*
* Z-ORDER. Portaled onto `document.body`, these overlays order against it rather
* than against the card, so the whole relation is written down once, bottom to top,
* as `CANVAS_OVERLAY_Z` below: the palette at rest under the app's dialog/popover
* layer (z-50) so its own popovers open above it, the picker over every canvas
* overlay, and — only while picking — the palette lifted over the picker so the
* pick highlight cannot paint across the strip. The reason for each step is on the
* scale itself.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useCanvasStore } from '@/lib/store/canvas';
/**
* The one place the canvas overlays' stacking is written down — one ordering,
* bottom to top, with the app's own Radix layer (z-50, not ours) as a step in it:
*
* 1. `palette` (30) — where the insert strip rests. BELOW the Radix layer,
* because the strip's buttons are popovers that portal to `document.body` at
* that layer (`InsertButton`): any higher and the strip's own menus would open
* underneath it.
* 2. the app's dialog/popover layer (z-50) — dialogs, popovers, tooltips.
* 3. `picker` (120) — the element picker, above every canvas overlay including the
* resting palette: while picking, a click anywhere on the canvas has to mean
* "this element", not "insert one".
* 4. `paletteOverPicker` (130) — where the insert strip goes WHILE picking, and
* only then, so the picker's violet ring and wash stop painting across the
* strip and washing it out to a disabled-looking grey. The strip is inert up
* here (see `FloatingInsertToolbar` for why, and for what stays clickable):
* it takes no pointer events, so clicks in its area fall through to the picker
* beneath and still mean "pick this element".
*/
export const CANVAS_OVERLAY_Z = {
palette: 30,
picker: 120,
paletteOverPicker: 130,
} as const;
/** The canvas container these overlays live in and are clamped to. */
export const CANVAS_OVERLAY_FRAME_SELECTOR = '[data-maic-studio-frame]';
interface OverlayRect {
readonly left: number;
readonly top: number;
readonly width: number;
readonly height: number;
}
function sameRect(a: OverlayRect | null, b: OverlayRect | null): boolean {
if (a === null || b === null) return a === b;
return a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height;
}
/** Frames the rect must hold steady before the rAF loop parks itself. */
const STABLE_FRAMES_BEFORE_IDLE = 12;
/**
* The live screen rect of the node the overlay is measured against — the closest
* `measureSelector` ancestor of the anchor, or the anchor itself when none is
* given (or none matches). Null while that node has no box at all (an unmounted
* or `display:none` ancestor — a folded pane).
*/
function useAnchorRect(
anchorRef: React.RefObject<HTMLElement | null>,
measureSelector?: string,
): OverlayRect | null {
const [rect, setRect] = useState<OverlayRect | null>(null);
useEffect(() => {
let raf = 0;
let current: OverlayRect | null = null;
let stableFrames = 0;
let zoomActive = useCanvasStore.getState().zoomTarget !== null;
const resolve = (): HTMLElement | null => {
const anchor = anchorRef.current;
if (!anchor) return null;
if (!measureSelector) return anchor;
// The frame is the target; fall back to the anchor if it is somehow absent
// so the overlay degrades to card-local rather than vanishing.
return anchor.closest<HTMLElement>(measureSelector) ?? anchor;
};
const read = (): OverlayRect | null => {
const node = resolve();
if (!node || !node.isConnected) return null;
// `checkVisibility` is the honest hidden test (it walks display/visibility
// on the ancestors); where the runtime lacks it, fall back to the box.
if (typeof node.checkVisibility === 'function' && !node.checkVisibility()) return null;
const r = node.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
};
const measure = () => {
const next = read();
if (!sameRect(current, next)) {
current = next;
setRect(next);
stableFrames = 0;
} else {
stableFrames += 1;
}
if (!zoomActive && stableFrames >= STABLE_FRAMES_BEFORE_IDLE) {
raf = 0;
return;
}
raf = requestAnimationFrame(measure);
};
const arm = () => {
stableFrames = 0;
if (!raf) raf = requestAnimationFrame(measure);
};
// First read is synchronous so the overlay lands with the frame rather than a
// frame later; the loop then follows whatever moves it.
const first = read();
current = first;
setRect(first);
arm();
const observed = resolve();
const observer =
typeof ResizeObserver !== 'undefined' && observed ? new ResizeObserver(arm) : null;
if (observer && observed) observer.observe(observed);
window.addEventListener('scroll', arm, true);
window.addEventListener('resize', arm);
// A canvas zoom is an animated ancestor transform: keep following for its
// whole duration, exactly as `useTrackedRect` does for element anchors.
const unsubscribe = useCanvasStore.subscribe((state, prev) => {
if (state.canvasScale !== prev.canvasScale || state.zoomTarget !== prev.zoomTarget) {
zoomActive = state.zoomTarget !== null;
arm();
}
});
return () => {
if (raf) cancelAnimationFrame(raf);
observer?.disconnect();
unsubscribe();
window.removeEventListener('scroll', arm, true);
window.removeEventListener('resize', arm);
};
}, [anchorRef, measureSelector]);
return rect;
}
export function CanvasOverlayPortal({
zIndex,
testId,
measureSelector,
capturePointer = false,
children,
}: {
/** From `CANVAS_OVERLAY_Z` — the scale is documented in one place. */
readonly zIndex: number;
readonly testId?: string;
/**
* The ancestor whose rect the portal box takes (and therefore what a child's
* `absolute`/drag geometry is clamped to). Usually `CANVAS_OVERLAY_FRAME_SELECTOR`.
* Omitted → the box is the anchor's own rect (the card).
*/
readonly measureSelector?: string;
/**
* Let the portal's own box receive pointer events (the picker covers the
* canvas). Default false: the box is `pointer-events-none` and only interactive
* children opt back in, so it never blocks the slide underneath.
*/
readonly capturePointer?: boolean;
readonly children: ReactNode;
}) {
const anchorRef = useRef<HTMLDivElement>(null);
const rect = useAnchorRect(anchorRef, measureSelector);
const canPortal = typeof document !== 'undefined';
return (
<>
{/* The anchor: measured, never painted, and inside the card so it inherits
the card's own visibility. An empty div paints nothing on its own, so it
deliberately carries NO `invisible` / `opacity-0` — `checkVisibility()`
would then be asked to judge exactly the properties that make this node
inert, instead of the `display:none` ancestor it is here to detect. */}
<div
ref={anchorRef}
aria-hidden="true"
data-canvas-overlay-anchor={testId ?? ''}
className="pointer-events-none absolute inset-0"
/>
{canPortal && rect
? createPortal(
<div
data-testid={testId}
style={{
position: 'fixed',
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
zIndex,
// Never block the slide underneath unless this overlay is meant
// to capture the canvas (the picker). The palette re-enables
// events on its strip alone.
pointerEvents: capturePointer ? undefined : 'none',
}}
>
{children}
</div>,
document.body,
)
: null}
</>
);
}
@@ -1,96 +1,147 @@
'use client';
/**
* ElementPickLayer — canvas-side target picker for the timeline.
* ElementPickLayer — canvas-side element picker, for both of its callers.
*
* When the ActionsBar arms "pick" mode (useCanvasStore.pickTarget, keyed by
* actionId), this layer covers the slide canvas and lets the user bind a cue's
* target either by clicking the element on the slide (hit-tested live) or by
* clicking a row in the floating element panel (draggable + collapsible). Every
* selectable element is outlined; the hovered one gets a solid ring + live
* spotlight/laser preview. Click empty canvas or press Esc to cancel.
* Armed through `useCanvasStore.pickTarget` (see `PickTarget`), it covers the
* slide canvas, outlines every selectable element and hit-tests the pointer
* live. What a click MEANS is the target's `purpose`:
*
* - `cue` (timeline): bind this element to the armed scene action, then leave.
* Hovering previews the real playback effect (spotlight / laser) so the
* author sees what they are choosing.
* - `element-ref` (dock lasso): toggle this element in the message's reference
* list and STAY armed — multi-pick is the point. Already-referenced elements
* wear their pin number, and clicking one un-picks it. No cue preview: the
* elements are being named, not animated.
*
* Empty-canvas click cancels in `cue` mode (there is one thing to choose and
* clicking past it means "never mind"); in `element-ref` mode it does nothing,
* because losing a five-element selection to a stray click is not a cancel the
* user asked for. Esc always leaves.
*
* The canvas itself stays as the author wrote it. Pick mode used to outline EVERY
* selectable element with a faint ring and wash, on the theory that an author
* cannot tell what is clickable — the cost was a slide covered in violet boxes,
* which is a worse answer to "what am I about to pick" than the pointer's own
* hover ring. So: one ring on the element under the pointer, the numbered pins on
* the ones already staged (`ElementRefPinLayer`), and nothing else.
*
* A floating element panel (draggable + collapsible) lists the page's elements
* for the same two actions, for elements too small to hit or hidden behind
* another. Hovering a row rings the element on the canvas — the same single ring,
* driven by the same `hover` state, which is why the panel still works with the
* all-elements outlining gone.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, GripHorizontal, MousePointerClick } from 'lucide-react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, GripHorizontal, MousePointerClick } from 'lucide-react';
import { useCanvasStore } from '@/lib/store/canvas';
import { useElementRefsForSession, useElementRefsStore } from '@/lib/store/element-refs';
import { useStageStore } from '@/lib/store/stage';
import { useWorkbenchStore } from '@/lib/workbench/session-store';
import { useI18n } from '@/lib/hooks/use-i18n';
import { setElementIdById } from '@/components/edit/ActionsBar/actions-edit';
import { cueLabel, elementLabel } from '@/components/edit/ActionsBar/cue-meta';
import { cueLabel } from '@/components/edit/ActionsBar/cue-meta';
import { clearCuePreview, previewCueEffect } from '@/components/edit/ActionsBar/cue-preview';
import { editableElementDomId } from './renderer-element-dom';
import {
MAX_ELEMENT_REFS,
elementRefLabel,
elementRefOrdinal,
makeElementRef,
type SlideElementLike,
} from '@/lib/workbench/element-refs';
import { elementIdAtPoint, measureElementBox, type CanvasBox } from './element-hit-test';
import {
CANVAS_OVERLAY_FRAME_SELECTOR,
CANVAS_OVERLAY_Z,
CanvasOverlayPortal,
} from './CanvasOverlayPortal';
const PANEL_W = 232;
/** Margin kept between the panel and its container on every side. */
const PANEL_MARGIN = 8;
/** Height assumed for the panel while its DOM node is not yet measurable (the header strip). */
const PANEL_FALLBACK_HEIGHT = 40;
interface Box {
left: number;
top: number;
export interface PanelPosition {
x: number;
y: number;
}
export interface PanelSize {
width: number;
height: number;
}
interface ElementLite {
id: string;
type: string;
content?: string;
/**
* Clamp a floating panel's top-left corner inside its container so the WHOLE
* panel stays visible, not just its drag handle: on each axis the position is
* pinned to `[PANEL_MARGIN, container − panel − PANEL_MARGIN]`. When the panel
* is larger than the container (a long list in a short frame) the upper bound
* collapses and the panel pins to the top-left margin instead of hanging past
* the bottom/right edge. The drag path and the re-clamp path both call this, so
* the boundary rule lives in exactly one place.
*/
export function clampPanelPosition(
pos: PanelPosition,
containerSize: PanelSize,
panelSize: PanelSize,
): PanelPosition {
const maxX = Math.max(PANEL_MARGIN, containerSize.width - panelSize.width - PANEL_MARGIN);
const maxY = Math.max(PANEL_MARGIN, containerSize.height - panelSize.height - PANEL_MARGIN);
return {
x: Math.min(Math.max(PANEL_MARGIN, pos.x), maxX),
y: Math.min(Math.max(PANEL_MARGIN, pos.y), maxY),
};
}
const INTERACTION_ELEMENT_ID_ATTRIBUTES = [
'data-element-id',
'data-select-element-id',
'data-context-element-id',
] as const;
function interactionElementIdAt(x: number, y: number): string | null {
for (const node of document.elementsFromPoint(x, y)) {
const target = (node as HTMLElement).closest?.(
INTERACTION_ELEMENT_ID_ATTRIBUTES.map((attribute) => `[${attribute}]`).join(','),
) as HTMLElement | null;
if (!target) continue;
for (const attribute of INTERACTION_ELEMENT_ID_ATTRIBUTES) {
const id = target.getAttribute(attribute);
if (id) return id;
}
}
return null;
}
function rendererPaintNode(elementId: string): HTMLElement | null {
const host = document.getElementById(editableElementDomId(elementId));
if (!host) return null;
return host.querySelector<HTMLElement>(
'.slide-element-hit-target > [class^="base-element-"], .slide-element-hit-target > [class*=" base-element-"]',
);
}
/**
* A canvas element as this layer needs it: everything `elementRefLabel` reads
* (per-type text lives on different fields), plus a definite `id` — an element
* with no id cannot be picked, measured or referenced.
*/
type PickableElement = SlideElementLike & { id: string };
export function ElementPickLayer() {
const { t } = useI18n();
const pickTarget = useCanvasStore.use.pickTarget();
const sessionId = useWorkbenchStore((s) => s.sessionId);
const currentStageId = useStageStore((s) => s.stage?.id ?? null);
const currentSceneId = useStageStore.use.currentSceneId();
// Reactive scene lookup so the panel/binding state tracks store updates.
const scene = useStageStore((s) =>
pickTarget ? (s.scenes.find((x) => x.id === pickTarget.sceneId) ?? null) : null,
pickTarget && s.stage?.id === pickTarget.stageId && s.currentSceneId === pickTarget.sceneId
? (s.scenes.find((x) => x.id === pickTarget.sceneId) ?? null)
: null,
);
const refs = useElementRefsForSession(sessionId);
const rootRef = useRef<HTMLDivElement>(null);
const [hover, setHover] = useState<{ id: string; box: Box } | null>(null);
const [outlines, setOutlines] = useState<Array<{ id: string; box: Box }>>([]);
const panelRef = useRef<HTMLDivElement>(null);
const [hover, setHover] = useState<{ id: string; box: CanvasBox } | null>(null);
const [panel, setPanel] = useState<{ x: number; y: number }>({ x: 0, y: 16 });
const [collapsed, setCollapsed] = useState(false);
const dragRef = useRef<{ px: number; py: number; ox: number; oy: number } | null>(null);
const moveRafRef = useRef<number | null>(null);
const cueType = pickTarget?.cueType;
const elements = useMemo<ElementLite[]>(
const purpose = pickTarget?.purpose;
const cueType = pickTarget?.purpose === 'cue' ? pickTarget.cueType : undefined;
const elements = useMemo<PickableElement[]>(
() =>
(scene?.content as { canvas?: { elements?: ElementLite[] } } | undefined)?.canvas?.elements ??
[],
(
(scene?.content as { canvas?: { elements?: SlideElementLike[] } } | undefined)?.canvas
?.elements ?? []
).filter((element): element is PickableElement => typeof element.id === 'string'),
[scene],
);
const currentBound =
(
scene?.actions?.find((a) => a.id === pickTarget?.actionId) as
| { elementId?: string }
| undefined
)?.elementId ?? '';
pickTarget?.purpose === 'cue'
? ((
scene?.actions?.find((a) => a.id === pickTarget.actionId) as
| { elementId?: string }
| undefined
)?.elementId ?? '')
: '';
const preview = useCallback(
(elementId: string) => {
@@ -105,48 +156,125 @@ export function ElementPickLayer() {
setHover(null);
}, []);
const bind = useCallback(
/**
* A pick. In `cue` mode it binds by `actionId` (index-stale-safe against a
* concurrent reorder/delete) and ends the mode; in `element-ref` mode it
* toggles the element in the staged list and keeps the layer armed.
*/
const pick = useCallback(
(elementId: string) => {
const pt = useCanvasStore.getState().pickTarget;
if (!pt) return;
const sc = useStageStore.getState().scenes.find((s) => s.id === pt.sceneId);
if (pt.purpose === 'element-ref') {
const currentSessionId = useWorkbenchStore.getState().sessionId;
const refsState = useElementRefsStore.getState();
// The pick may target a course other than the session's bound one: refs
// carry their own stageId and the runner resolves against that (the
// open-domain tool surface has no mutable "active stage"), so the only
// fences are the chat that owns the draft and the course on screen.
if (
!currentSessionId ||
pt.ownerSessionId !== currentSessionId ||
refsState.ownerSessionId !== currentSessionId
) {
finish();
return;
}
const stageState = useStageStore.getState();
if (stageState.stage?.id !== pt.stageId) {
finish();
return;
}
const sc = stageState.scenes.find((s) => s.id === pt.sceneId);
const element = (
sc?.content as { canvas?: { elements?: SlideElementLike[] } } | undefined
)?.canvas?.elements?.find((el) => el.id === elementId);
if (!element) return;
const ref = makeElementRef(pt.stageId, pt.sceneId, element, t);
if (ref) refsState.toggle(ref);
return;
}
const stageState = useStageStore.getState();
if (stageState.stage?.id !== pt.stageId) {
finish();
return;
}
const sc = stageState.scenes.find((s) => s.id === pt.sceneId);
if (sc) {
// Bind by actionId — index-stale-safe against concurrent reorder/delete.
useStageStore.getState().updateScene(pt.sceneId, {
actions: setElementIdById(sc.actions ?? [], pt.actionId, elementId),
});
}
finish();
},
[finish],
[finish, t],
);
// Local (canvas-relative) box for a viewport rect.
const toLocal = useCallback((r: DOMRect): Box | null => {
const cr = rootRef.current?.getBoundingClientRect();
if (!cr) return null;
return { left: r.left - cr.left, top: r.top - cr.top, width: r.width, height: r.height };
// The target is UI state owned by one exact course page. Clear every visual
// side effect as soon as navigation makes either half of that identity stale;
// merely rendering null would leave shortcuts disabled and pins suppressed.
// For `element-ref`, identity is (chat, DISPLAYED course, page) — never the
// session's bound stage: the open-domain tool surface has no mutable "active
// stage" pointer, element refs carry their own stageId, and the runner
// resolves them against the course they name. So a pick may point at a course
// other than the one the chat session is bound to.
useLayoutEffect(() => {
if (
!pickTarget ||
(pickTarget.stageId === currentStageId &&
pickTarget.sceneId === currentSceneId &&
(pickTarget.purpose !== 'element-ref' || pickTarget.ownerSessionId === sessionId))
) {
return;
}
if (moveRafRef.current != null) {
cancelAnimationFrame(moveRafRef.current);
moveRafRef.current = null;
}
clearCuePreview();
useCanvasStore.getState().setPickTarget(null);
setHover(null);
}, [currentSceneId, currentStageId, pickTarget, sessionId]);
/**
* Re-measure the hovered element's ring. The ring is a box in canvas
* coordinates, so anything that moves the canvas under a still pointer (a
* window resize, a scroll in an ancestor) leaves it pointing at where the
* element WAS. Nothing else is measured up front any more: with the
* all-elements outlining gone there is exactly one box on screen.
*/
const remeasureHover = useCallback(() => {
setHover((current) => {
if (!current) return current;
const box = measureElementBox(current.id, rootRef.current);
return box ? { id: current.id, box } : null;
});
}, []);
const measureOutlines = useCallback(() => {
const boxes: Array<{ id: string; box: Box }> = [];
for (const el of elements) {
const paint = rendererPaintNode(el.id);
if (!paint) continue;
const b = toLocal(paint.getBoundingClientRect());
if (b) boxes.push({ id: el.id, box: b });
}
setOutlines(boxes);
}, [elements, toLocal]);
/**
* Pull the panel back inside the container. The drag clamps against the
* panel's CURRENT size, so a panel whose height changes under it (expanding a
* collapsed panel that was dragged to the bottom, a longer element list, a
* resized container) can end up overhanging the frame; this re-applies the
* same pure clamp the drag uses, so the two paths cannot drift apart.
*/
const reclampPanel = useCallback(() => {
const cr = rootRef.current?.getBoundingClientRect();
if (!cr) return;
const panelH = panelRef.current?.offsetHeight ?? PANEL_FALLBACK_HEIGHT;
setPanel((prev) => clampPanelPosition(prev, cr, { width: PANEL_W, height: panelH }));
}, []);
// On entering pick mode: outline every selectable element, dock panel top-right.
// On entering pick mode: dock the element panel top-right.
useEffect(() => {
if (!pickTarget) return;
measureOutlines();
const cr = rootRef.current?.getBoundingClientRect();
if (cr) setPanel({ x: Math.max(8, cr.width - PANEL_W - 16), y: 16 });
setCollapsed(false);
const onResize = () => measureOutlines();
const onResize = () => {
remeasureHover();
reclampPanel();
};
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onResize, true);
return () => {
@@ -154,7 +282,46 @@ export function ElementPickLayer() {
window.removeEventListener('scroll', onResize, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pickTarget?.sceneId, pickTarget?.actionId, elements.length]);
}, [pickTarget?.sceneId, purpose, elements.length, reclampPanel]);
/**
* The portaled container (`rootRef`) only exists once `CanvasOverlayPortal`
* has measured the frame rect — a render AFTER this layer first arms — so a
* ref callback is the mount signal: `containerReady` flips true exactly when
* the node the panel clamps to is in the DOM.
*/
const [containerReady, setContainerReady] = useState(false);
const attachContainerRef = useCallback((node: HTMLDivElement | null) => {
rootRef.current = node;
if (node) setContainerReady(true);
}, []);
// Re-clamp on in-frame container resizes: dragging the edit dock's handle up
// shrinks the studio frame from below, and the workspace divider drags
// re-shape it — neither fires a window resize, but the portaled box tracks
// them via `CanvasOverlayPortal`'s rAF rect loop, so `rootRef` (which fills
// the box) resizes in place. The observer fires for exactly those changes
// and pulls the panel back inside (the real-browser repro: a taller timeline
// pushed the panel over the dock). `reclampPanel` is idempotent, so the
// window-resize path keeping it too is harmless.
useEffect(() => {
if (!pickTarget || !containerReady) return;
const observed = rootRef.current;
if (!observed || typeof ResizeObserver === 'undefined') return;
const frameObserver = new ResizeObserver(() => reclampPanel());
frameObserver.observe(observed);
return () => frameObserver.disconnect();
}, [pickTarget, containerReady, reclampPanel]);
// Re-clamp when the panel's own size changes under a position that was
// clamped against the old one: a collapsed panel dragged to the bottom grows
// on expand, and a longer element list does the same. Container resizes are
// covered by the entry effect above (window resize listener + the
// ResizeObserver on the container).
useEffect(() => {
if (!pickTarget) return;
reclampPanel();
}, [collapsed, elements.length, pickTarget, reclampPanel]);
useEffect(() => {
if (!pickTarget) return;
@@ -183,8 +350,23 @@ export function ElementPickLayer() {
);
if (!pickTarget) return null;
// The owner fence is the only render condition: cross-course picks (displayed
// course ≠ session-bound course) are valid, refs self-describe their stage.
if (pickTarget.purpose === 'element-ref' && pickTarget.ownerSessionId !== sessionId) return null;
if (!scene) return null;
const typeLabel = cueLabel(pickTarget.cueType, t);
const isRefMode = pickTarget.purpose === 'element-ref';
const sceneId = pickTarget.sceneId;
const atCap = isRefMode && refs.length >= MAX_ELEMENT_REFS;
const banner = isRefMode
? {
lead: t('edit.pick.refLead', { count: refs.length, max: MAX_ELEMENT_REFS }),
hint: atCap ? t('edit.pick.refCapHint') : t('edit.pick.refHint'),
}
: {
lead: t('edit.pick.pickFor', { label: cueLabel(pickTarget.cueType, t) }),
hint: t('edit.pick.pickHint'),
};
// Hit-test on mousemove, coalesced to one rAF per frame.
const onCanvasMove = (e: React.MouseEvent) => {
@@ -192,7 +374,7 @@ export function ElementPickLayer() {
if (moveRafRef.current != null) return;
moveRafRef.current = requestAnimationFrame(() => {
moveRafRef.current = null;
const id = interactionElementIdAt(clientX, clientY);
const id = elementIdAtPoint(clientX, clientY);
if (!id) {
if (hover) {
setHover(null);
@@ -201,8 +383,7 @@ export function ElementPickLayer() {
return;
}
if (id !== hover?.id) {
const paint = rendererPaintNode(id);
const box = paint ? toLocal(paint.getBoundingClientRect()) : null;
const box = measureElementBox(id, rootRef.current);
setHover(box ? { id, box } : null);
preview(id);
}
@@ -210,13 +391,14 @@ export function ElementPickLayer() {
};
const onCanvasClick = () => {
if (hover) bind(hover.id);
else finish();
if (hover) pick(hover.id);
// Clicking past every element: a cue pick is a single choice, so this reads
// as "never mind". A multi-pick selection must not evaporate the same way.
else if (!isRefMode) finish();
};
const highlightById = (id: string) => {
const paint = rendererPaintNode(id);
const box = paint ? toLocal(paint.getBoundingClientRect()) : null;
const box = measureElementBox(id, rootRef.current);
setHover(box ? { id, box } : { id, box: { left: 0, top: 0, width: 0, height: 0 } });
preview(id);
};
@@ -232,127 +414,150 @@ export function ElementPickLayer() {
const onPanelMove = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const next = { x: d.ox + (e.clientX - d.px), y: d.oy + (e.clientY - d.py) };
const cr = rootRef.current?.getBoundingClientRect();
const maxX = cr ? cr.width - PANEL_W - 8 : 9999;
const maxY = cr ? cr.height - 40 : 9999;
setPanel({
x: Math.min(Math.max(8, d.ox + (e.clientX - d.px)), Math.max(8, maxX)),
y: Math.min(Math.max(8, d.oy + (e.clientY - d.py)), Math.max(8, maxY)),
});
if (!cr) {
setPanel(next);
return;
}
// Clamp against the panel's ACTUAL height — the old `height - 40` only kept
// the handle's top inside the frame, letting a tall list hang below it.
const panelH = panelRef.current?.offsetHeight ?? PANEL_FALLBACK_HEIGHT;
setPanel(clampPanelPosition(next, cr, { width: PANEL_W, height: panelH }));
};
const onPanelUp = () => {
dragRef.current = null;
};
return (
<div ref={rootRef} className="absolute inset-0 z-[120]">
{/* click-catcher (sibling of the panel, so panel clicks never reach it) */}
<div
className="absolute inset-0 cursor-crosshair"
onMouseMove={onCanvasMove}
onClick={onCanvasClick}
/>
{/* every selectable element gets a faint outline → "this is clickable" */}
{outlines.map((o) => (
// Portaled onto the STUDIO FRAME (the padded canvas container), so the panel
// is never clipped by the card's `overflow-hidden` and can roam the grey
// padding around the card without escaping to the dock. The box IS the
// frame's, and everything in here measures relative to `rootRef` (which fills
// it), so element rings and hit-tests land at the same screen point as before.
// `capturePointer`: pick mode deliberately covers the whole canvas — a click
// anywhere means "this element", or "never mind" on the empty padding.
<CanvasOverlayPortal
zIndex={CANVAS_OVERLAY_Z.picker}
testId="element-pick-layer"
measureSelector={CANVAS_OVERLAY_FRAME_SELECTOR}
capturePointer
>
<div ref={attachContainerRef} className="absolute inset-0">
{/* click-catcher (sibling of the panel, so panel clicks never reach it) */}
<div
key={o.id}
className="pointer-events-none absolute rounded-[3px] ring-1 ring-violet-400/40 bg-violet-400/[0.04]"
style={{ left: o.box.left, top: o.box.top, width: o.box.width, height: o.box.height }}
className="absolute inset-0 cursor-crosshair"
onMouseMove={onCanvasMove}
onClick={onCanvasClick}
/>
))}
{/* hovered element — solid ring */}
{hover && hover.box.width > 0 && (
<div
className="pointer-events-none absolute rounded-md ring-2 ring-violet-500 bg-violet-500/[0.06]"
style={{
left: hover.box.left - 2,
top: hover.box.top - 2,
width: hover.box.width + 4,
height: hover.box.height + 4,
}}
/>
)}
{/* hovered element — the one ring on the canvas */}
{hover && hover.box.width > 0 && (
<div
className="pointer-events-none absolute rounded-md bg-violet-500/[0.06] ring-2 ring-violet-500"
style={{
left: hover.box.left - 2,
top: hover.box.top - 2,
width: hover.box.width + 4,
height: hover.box.height + 4,
}}
/>
)}
{/* instruction banner */}
<div className="pointer-events-none absolute left-1/2 top-3 -translate-x-1/2 rounded-full border border-violet-300/60 bg-popover/95 px-3.5 py-1.5 text-[12px] font-medium text-foreground shadow-lg shadow-black/10 backdrop-blur">
<span className="text-violet-600 dark:text-violet-400">
{t('edit.pick.pickFor', { label: typeLabel })}
</span>{' '}
· {t('edit.pick.pickHint')}
</div>
{/* draggable + collapsible element panel, inside the canvas */}
<div
onClick={(e) => e.stopPropagation()}
style={{ left: panel.x, top: panel.y, width: PANEL_W }}
className="absolute flex max-h-[70%] flex-col overflow-hidden rounded-2xl border border-border bg-popover/95 shadow-xl shadow-black/15 backdrop-blur"
>
<div
onPointerDown={onPanelDown}
onPointerMove={onPanelMove}
onPointerUp={onPanelUp}
onPointerCancel={onPanelUp}
className="flex cursor-grab touch-none items-center gap-1.5 border-b border-border px-2.5 py-2 active:cursor-grabbing"
>
<GripHorizontal className="size-3.5 text-muted-foreground/40" />
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
{t('edit.pick.pageElements', { count: elements.length })}
</span>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="ml-auto grid size-5 place-items-center rounded text-muted-foreground/60 hover:bg-muted hover:text-foreground"
aria-label={collapsed ? t('edit.pick.expand') : t('edit.pick.collapse')}
>
<ChevronDown
className={`size-3.5 transition-transform ${collapsed ? '-rotate-90' : ''}`}
/>
</button>
{/* instruction banner */}
<div className="pointer-events-none absolute left-1/2 top-3 -translate-x-1/2 rounded-full border border-violet-300/60 bg-popover/95 px-3.5 py-1.5 text-[12px] font-medium text-foreground shadow-lg shadow-black/10 backdrop-blur">
<span className="text-violet-600 dark:text-violet-400">{banner.lead}</span> ·{' '}
{banner.hint}
</div>
{!collapsed && (
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{elements.length === 0 ? (
<p className="px-2 py-3 text-[11px] text-muted-foreground/70">
{t('edit.pick.noElements')}
</p>
) : (
elements.map((el) => (
<button
key={el.id}
type="button"
onMouseEnter={() => highlightById(el.id)}
onMouseLeave={() => {
setHover(null);
preview('');
}}
onClick={() => bind(el.id)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] transition-colors hover:bg-muted ${
el.id === currentBound
? 'bg-violet-50 ring-1 ring-violet-200 dark:bg-violet-500/10 dark:ring-violet-500/30'
: ''
}`}
>
<span className="min-w-0 flex-1 truncate text-foreground/90">
{elementLabel(el, t)}
</span>
<span className="shrink-0 font-mono text-[9px] text-muted-foreground/45">
{el.id.slice(0, 6)}
</span>
</button>
))
)}
{/* draggable + collapsible element panel, inside the canvas */}
<div
ref={panelRef}
onClick={(e) => e.stopPropagation()}
style={{ left: panel.x, top: panel.y, width: PANEL_W }}
className="absolute flex max-h-[70%] flex-col overflow-hidden rounded-2xl border border-border bg-popover/95 shadow-xl shadow-black/15 backdrop-blur"
>
<div
onPointerDown={onPanelDown}
onPointerMove={onPanelMove}
onPointerUp={onPanelUp}
onPointerCancel={onPanelUp}
className="flex cursor-grab touch-none items-center gap-1.5 border-b border-border px-2.5 py-2 active:cursor-grabbing"
>
<GripHorizontal className="size-3.5 text-muted-foreground/40" />
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
{t('edit.pick.pageElements', { count: elements.length })}
</span>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="ml-auto grid size-5 place-items-center rounded text-muted-foreground/60 hover:bg-muted hover:text-foreground"
aria-label={collapsed ? t('edit.pick.expand') : t('edit.pick.collapse')}
>
<ChevronDown
className={`size-3.5 transition-transform ${collapsed ? '-rotate-90' : ''}`}
/>
</button>
</div>
)}
{collapsed && (
<div className="flex items-center gap-1 px-2.5 py-1.5 text-[10px] text-muted-foreground/50">
<MousePointerClick className="size-3" /> {t('edit.pick.bindHint')}
</div>
)}
{!collapsed && (
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{elements.length === 0 ? (
<p className="px-2 py-3 text-[11px] text-muted-foreground/70">
{t('edit.pick.noElements')}
</p>
) : (
elements.map((el) => {
const ordinal = isRefMode
? elementRefOrdinal(refs, pickTarget.stageId, sceneId, el.id)
: 0;
const marked = isRefMode ? ordinal > 0 : el.id === currentBound;
return (
<button
key={el.id}
type="button"
onMouseEnter={() => highlightById(el.id)}
onMouseLeave={() => {
setHover(null);
preview('');
}}
onClick={() => pick(el.id)}
disabled={isRefMode && atCap && ordinal === 0}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] transition-colors hover:bg-muted disabled:opacity-40 disabled:hover:bg-transparent ${
marked
? 'bg-violet-50 ring-1 ring-violet-200 dark:bg-violet-500/10 dark:ring-violet-500/30'
: ''
}`}
>
<span className="min-w-0 flex-1 truncate text-foreground/90">
{elementRefLabel(el, t)}
</span>
{ordinal > 0 ? (
<span className="grid size-4 shrink-0 place-items-center rounded-full bg-violet-500 text-[9px] font-semibold tabular-nums text-white">
{ordinal}
</span>
) : marked ? (
<Check className="size-3 shrink-0 text-violet-500" />
) : (
<span className="shrink-0 font-mono text-[9px] text-muted-foreground/45">
{el.id.slice(0, 6)}
</span>
)}
</button>
);
})
)}
</div>
)}
{collapsed && (
<div className="flex items-center gap-1 px-2.5 py-1.5 text-[10px] text-muted-foreground/50">
<MousePointerClick className="size-3" />{' '}
{isRefMode ? t('edit.pick.refHint') : t('edit.pick.bindHint')}
</div>
)}
</div>
</div>
</div>
</CanvasOverlayPortal>
);
}
@@ -0,0 +1,157 @@
'use client';
/**
* ElementRefPinLayer — the staged element references, ON the canvas.
*
* A chip in the composer says WHAT was referenced; this says WHERE. Each staged
* element in the current scene keeps a numbered pin at its top-left corner and a
* hairline frame, so the list above the input box and the page below it can be
* read as one selection. The number is the ref's position, the same one the chip
* shows.
*
* Always mounted next to the picker (not only while picking): the references
* survive leaving pick mode, and a selection you cannot see is a selection you
* forget you made. With no references in the current scene it measures nothing
* and renders nothing.
*
* Pointer-transparent throughout: this is a read-out, and the canvas underneath
* must stay fully editable while a reference is staged. Removing one happens on
* its chip or in the lasso tool.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCanvasStore } from '@/lib/store/canvas';
import {
useElementRefsForSession,
useElementRefsHoveredForSession,
} from '@/lib/store/element-refs';
import { useStageStore } from '@/lib/store/stage';
import { useWorkbenchStore } from '@/lib/workbench/session-store';
import { elementPaintNode, measureElementBox, type CanvasBox } from './element-hit-test';
export function ElementRefPinLayer() {
const sessionId = useWorkbenchStore((s) => s.sessionId);
const refs = useElementRefsForSession(sessionId);
const hovered = useElementRefsHoveredForSession(sessionId);
const currentSceneId = useStageStore.use.currentSceneId();
const currentStageId = useStageStore((s) => s.stage?.id ?? null);
// A new content object is the stage store's scene revision. Position-only
// edits (for example keyboard nudges) do not resize the DOM node, so this is
// the signal that complements ResizeObserver below.
const sceneContent = useStageStore(
(s) => s.scenes.find((scene) => scene.id === s.currentSceneId)?.content ?? null,
);
// A canvas zoom / pan is an ancestor transform, so the pins have to be
// re-measured when it changes — the same reason `useTrackedRect` watches it.
const canvasScale = useCanvasStore.use.canvasScale();
const pickTarget = useCanvasStore.use.pickTarget();
const rootRef = useRef<HTMLDivElement>(null);
const [boxes, setBoxes] = useState<Record<string, CanvasBox>>({});
const sceneRefs = useMemo(
() =>
refs
.flatMap((ref, index) =>
ref.kind === 'slide-element'
? [
{
elementId: ref.elementId,
stageId: ref.stageId,
sceneId: ref.sceneId,
ordinal: index + 1,
},
]
: [],
)
.filter((ref) => ref.stageId === currentStageId && ref.sceneId === currentSceneId),
[refs, currentStageId, currentSceneId],
);
const hoveredElementId =
hovered && hovered.stageId === currentStageId && hovered.sceneId === currentSceneId
? hovered.elementId
: null;
const measure = useCallback(() => {
const next: Record<string, CanvasBox> = {};
for (const ref of sceneRefs) {
const box = measureElementBox(ref.elementId, rootRef.current);
if (box && (box.width > 0 || box.height > 0)) next[ref.elementId] = box;
}
setBoxes(next);
}, [sceneRefs]);
useEffect(() => {
if (sceneRefs.length === 0) return;
// Measured after paint and coalesced to one frame. Scene-content changes
// cover position-only edits; ResizeObserver covers intrinsic paint-size
// changes such as text reflow. There is deliberately no standing rAF loop.
let raf = 0;
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
measure();
});
};
schedule();
window.addEventListener('resize', schedule);
window.addEventListener('scroll', schedule, true);
window.addEventListener('pointerup', schedule);
const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => schedule());
for (const ref of sceneRefs) {
const paint = elementPaintNode(ref.elementId);
if (paint) observer?.observe(paint);
}
return () => {
if (raf) cancelAnimationFrame(raf);
observer?.disconnect();
window.removeEventListener('resize', schedule);
window.removeEventListener('scroll', schedule, true);
window.removeEventListener('pointerup', schedule);
};
}, [measure, sceneRefs, sceneContent, canvasScale]);
// Pick mode keeps the canvas clean except for the staged refs themselves:
// the pick layer owns the current hover ring, while this layer contributes
// only each already-picked ordinal (no persistent frame).
const pickingElementRefs = pickTarget?.purpose === 'element-ref';
const silent = sceneRefs.length === 0;
return (
<div ref={rootRef} className="pointer-events-none absolute inset-0 z-[110]">
{silent
? null
: sceneRefs.map((ref) => {
const box = boxes[ref.elementId];
if (!box) return null;
const isHovered = ref.elementId === hoveredElementId;
return (
<div
key={ref.elementId}
data-testid="element-ref-pin"
className={
pickingElementRefs
? 'absolute'
: isHovered
? 'absolute rounded-[4px] bg-violet-500/[0.07] ring-2 ring-violet-500 transition-colors'
: 'absolute rounded-[4px] ring-1 ring-violet-400/55 transition-colors'
}
style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
>
<span
className={
isHovered
? 'absolute -left-2 -top-2 grid size-[18px] place-items-center rounded-full bg-violet-500 font-mono text-[10px] font-semibold leading-none tabular-nums text-white shadow-md shadow-violet-500/30 ring-2 ring-white dark:ring-slate-900'
: 'absolute -left-2 -top-2 grid size-4 place-items-center rounded-full bg-violet-500/85 font-mono text-[9px] font-semibold leading-none tabular-nums text-white ring-2 ring-white dark:ring-slate-900'
}
>
{ref.ordinal}
</span>
</div>
);
})}
</div>
);
}
@@ -12,11 +12,13 @@ import { useResolvedSlide } from '@/components/slide-renderer/use-resolved-slide
import { createElementId } from '@/lib/edit/element-id';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useCanvasStore } from '@/lib/store/canvas';
import { useSyncCanvasViewportFromSlide } from '@/lib/store/sync-canvas-viewport';
import { EDITABLE_ELEMENT_ID_PREFIX } from './renderer-element-dom';
import { useSlideEditSession } from './slide-edit-session';
import { useResolvedSlideContent } from './use-slide-surface';
export function RendererEditorCanvas() {
useSyncCanvasViewportFromSlide();
const { locale, t } = useI18n();
const content = useResolvedSlideContent();
const slide = useResolvedSlide(content.canvas);
@@ -10,6 +10,7 @@ import { useCanvasStore } from '@/lib/store/canvas';
import { AnchoredTextBar } from './AnchoredTextBar';
import { AnchoredElementBar } from './AnchoredElementBar';
import { ElementPickLayer } from './ElementPickLayer';
import { ElementRefPinLayer } from './ElementRefPinLayer';
import { EDITABLE_ELEMENT_ID_PREFIX } from './renderer-element-dom';
import {
useEditingTextElementId,
@@ -49,6 +50,7 @@ export function SlideCanvas() {
</SceneProvider>
{!useRendererEditor && <AnchoredTextBar editingElementId={editingElementId} />}
{!useRendererEditor && <AnchoredElementBar element={nonTextElement} />}
<ElementRefPinLayer />
<ElementPickLayer />
</div>
);
@@ -6,8 +6,8 @@ import { useSlideSurfaceState, type SlideSelection } from './use-slide-surface';
/**
* The slide SceneEditorSurface. EditShell resolves this by scene type and
* renders `SurfaceComponent` + reads `useSurfaceState()` into the command
* bar / floating toolbar. PR1 ships geometry editing only; text / insert /
* image / z-order / slide management land in later sub-PRs.
* bar / floating toolbar. The registered surface supplies the current text,
* insert, image, z-order, geometry, and slide-management commands.
*/
export const slideSurface: SceneEditorSurface<SlideContent, SlideSelection> = {
sceneType: 'slide',
@@ -0,0 +1,109 @@
/**
* Canvas element hit-testing and measurement — the DOM half of element picking,
* shared by the cue picker, the lasso picker and the reference pin layer.
*
* Two things were renderer-specific and are now not:
*
* 1. WHICH attribute names an element. Only the `@openmaic/editor` package
* renderer emits `data-element-id` / `data-select-element-id` /
* `data-context-element-id`, and it is behind a flag — so on the DEFAULT
* legacy canvas (and on every playback screen) the hit-test found nothing
* and picking looked broken. `data-maic-element-id`, stamped by both
* app-owned hosts, is the renderer-agnostic answer; the package attributes
* stay in the list so the flagged path keeps working.
*
* 2. WHICH node carries the geometry. The `#editable-element-{id}` /
* `#screen-element-{id}` host is a zero-size absolutely-positioned wrapper
* (it only holds a z-index). The painted box is the renderer's
* `.slide-element-hit-target > .base-element-*` or, in the app renderers,
* `.element-content`. Measuring the wrapper collapses every outline to a
* 0×0 rect at the canvas origin.
*/
import {
MAIC_ELEMENT_ID_ATTRIBUTE,
editableElementDomId,
screenElementDomId,
} from './renderer-element-dom';
/**
* Element-id attributes a hit-test accepts, most specific first. The app's own
* marker leads: when both are present (a flagged renderer inside the editor)
* they agree, and when only one is, the walk finds it either way.
*/
export const INTERACTION_ELEMENT_ID_ATTRIBUTES = [
MAIC_ELEMENT_ID_ATTRIBUTE,
'data-element-id',
'data-select-element-id',
'data-context-element-id',
] as const;
const INTERACTION_SELECTOR = INTERACTION_ELEMENT_ID_ATTRIBUTES.map(
(attribute) => `[${attribute}]`,
).join(',');
/** The element id painted at these viewport coordinates, if any. */
export function elementIdAtPoint(x: number, y: number): string | null {
for (const node of document.elementsFromPoint(x, y)) {
const target = (node as HTMLElement).closest?.(INTERACTION_SELECTOR) as HTMLElement | null;
if (!target) continue;
for (const attribute of INTERACTION_ELEMENT_ID_ATTRIBUTES) {
const id = target.getAttribute(attribute);
if (id) return id;
}
}
return null;
}
/** The element's host wrapper, in whichever renderer currently owns the canvas. */
export function elementHostNode(elementId: string): HTMLElement | null {
return (
document.getElementById(editableElementDomId(elementId)) ??
document.getElementById(screenElementDomId(elementId))
);
}
/**
* The node whose box IS the element on screen. Falls back through the two
* renderer shapes and finally to the host itself, so a caller always gets
* something measurable rather than silently rendering nothing.
*/
export function elementPaintNode(elementId: string): HTMLElement | null {
const host = elementHostNode(elementId);
if (!host) return null;
return (
host.querySelector<HTMLElement>(
'.slide-element-hit-target > [class^="base-element-"], .slide-element-hit-target > [class*=" base-element-"]',
) ??
host.querySelector<HTMLElement>('.element-content') ??
host
);
}
export interface CanvasBox {
left: number;
top: number;
width: number;
height: number;
}
/** A viewport rect expressed relative to a canvas-local container. */
export function toCanvasBox(rect: DOMRect, container: HTMLElement | null): CanvasBox | null {
const origin = container?.getBoundingClientRect();
if (!origin) return null;
return {
left: rect.left - origin.left,
top: rect.top - origin.top,
width: rect.width,
height: rect.height,
};
}
/** Measure one element against a canvas-local container. */
export function measureElementBox(
elementId: string,
container: HTMLElement | null,
): CanvasBox | null {
const paint = elementPaintNode(elementId);
if (!paint) return null;
return toCanvasBox(paint.getBoundingClientRect(), container);
}
@@ -1,5 +1,9 @@
export const EDITABLE_ELEMENT_ID_PREFIX = 'editable-element-';
export function editableElementDomId(elementId: string): string {
return `${EDITABLE_ELEMENT_ID_PREFIX}${elementId}`;
}
/** Re-export the DOM contract emitted by the slide renderer. */
export {
EDITABLE_ELEMENT_ID_PREFIX,
MAIC_ELEMENT_ID_ATTRIBUTE,
SCREEN_ELEMENT_ID_PREFIX,
editableElementDomId,
maicElementIdAttributes,
screenElementDomId,
} from '@/components/slide-renderer/element-dom';
+54 -10
View File
@@ -1,33 +1,68 @@
'use client';
import type { ReactNode } from 'react';
import { ArrowLeft } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import type { StageMode } from '@/lib/types/stage';
import { classroomExitLabelKey, exitClassroom } from '@/lib/workbench/classroom-exit';
import { HeaderControls } from './stage/header-controls';
interface HeaderProps {
readonly currentSceneTitle: string;
readonly mode?: StageMode;
readonly proModeActive?: boolean;
readonly canEdit?: boolean;
readonly onToggleEditMode?: () => void;
/** Replaces the default back-to-home arrow as the header's leftmost
control. `PlaybackChromeRoot` passes the workbench's return control
here while a session is attached and full-screen playback is on, so the
top-left back affordance becomes the back-to-workspace control instead of a home arrow
(which would navigate away from the hosted classroom entirely). */
readonly backControl?: ReactNode;
/** Drops the back slot entirely (no `backControl`, no home arrow). The
embedded workbench form uses this: the conversation sits beside/above
the classroom, so any back affordance here would duplicate the chat's
own back and could exit the workbench. */
readonly hideBackControl?: boolean;
/** Hide application-global controls in a workbench-attached classroom. */
readonly hideGlobalControls?: boolean;
/** Hide course-level share/export in a workbench-attached classroom. */
readonly hideCourseActions?: boolean;
}
export function Header({ currentSceneTitle, mode, canEdit, onToggleEditMode }: HeaderProps) {
export function Header({
currentSceneTitle,
mode,
proModeActive,
canEdit,
onToggleEditMode,
backControl,
hideBackControl,
hideGlobalControls,
hideCourseActions,
}: HeaderProps) {
const { t } = useI18n();
const router = useRouter();
const searchParams = useSearchParams();
const exitLabel = t(classroomExitLabelKey(searchParams));
return (
<>
<header className="h-20 px-8 flex items-center justify-between z-10 bg-transparent gap-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<button
onClick={() => router.push('/')}
className="shrink-0 p-2 rounded-lg text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
title={t('generation.backToHome')}
>
<ArrowLeft className="w-5 h-5" />
</button>
{hideBackControl
? null
: (backControl ?? (
<button
onClick={() => exitClassroom(router, searchParams)}
className="shrink-0 p-2 rounded-lg text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
title={exitLabel}
aria-label={exitLabel}
>
<ArrowLeft className="w-5 h-5" />
</button>
))}
{/* Title block — hidden when `mode === 'edit'`. Header lives
inside `PlaybackChromeRoot`, which is unmounted by `Stage`
once mode flips to 'edit', so in steady state this branch
@@ -52,7 +87,16 @@ export function Header({ currentSceneTitle, mode, canEdit, onToggleEditMode }: H
)}
</div>
<HeaderControls mode={mode} canEdit={canEdit} onToggleEditMode={onToggleEditMode} />
{/* Standalone classroom keeps the full cluster. Workbench-attached
classrooms omit both the global capsule and course share/export. */}
<HeaderControls
mode={mode}
proModeActive={proModeActive}
canEdit={canEdit}
onToggleEditMode={onToggleEditMode}
showGlobalControls={!hideGlobalControls}
showCourseActions={!hideCourseActions}
/>
</header>
</>
);
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { useWidgetIframeStore } from '@/lib/store/widget-iframe';
import {
@@ -8,6 +8,62 @@ import {
type IframePoolEntry,
} from '@/lib/store/interactive-iframe-pool';
import { useSceneRuntimeErrors } from '@/lib/store/scene-runtime-errors';
import {
GENUI_LOGICAL_HEIGHT,
GENUI_LOGICAL_WIDTH,
fitGenUiViewport,
} from '@/lib/interactive/logical-viewport';
import { intersectClientBoxes } from '@/lib/edit/visible-client-rect';
import { useCanvasStore } from '@/lib/store/canvas';
import { useElementRefsStore } from '@/lib/store/element-refs';
import { useI18n } from '@/lib/hooks/use-i18n';
import {
ELEMENT_REF_SELECTOR_MAX,
ELEMENT_SNAPSHOT_MAX,
INTERACTIVE_OUTERHTML_MAX,
makeInteractiveElementRef,
} from '@/lib/workbench/element-refs';
type InteractivePickerMessage = {
__maicInteractive?: boolean;
kind?: string;
selector?: unknown;
outerHTML?: unknown;
text?: unknown;
};
/** Validate an untrusted iframe picker message and apply it to host-owned state. */
export function handleInteractivePickerMessage(
sceneId: string,
data: InteractivePickerMessage | undefined,
t: (key: string, options?: Record<string, unknown>) => string,
): boolean {
if (!data || data.__maicInteractive !== true) return false;
const target = useCanvasStore.getState().pickTarget;
const armed = target?.purpose === 'element-ref' && target.sceneId === sceneId;
if (data.kind === 'element-picker-disarmed') {
if (armed) useCanvasStore.getState().setPickTarget(null);
return armed;
}
if (data.kind !== 'element-picked' || !armed) return false;
if (
typeof data.selector !== 'string' ||
typeof data.outerHTML !== 'string' ||
typeof data.text !== 'string'
) {
return false;
}
const selector = data.selector.slice(0, ELEMENT_REF_SELECTOR_MAX);
const outerHTML = data.outerHTML.slice(0, INTERACTIVE_OUTERHTML_MAX);
const text = data.text.slice(0, ELEMENT_SNAPSHOT_MAX);
if (!selector.trim() || !outerHTML.trim()) return false;
const refsStore = useElementRefsStore.getState();
if (refsStore.ownerSessionId !== target.ownerSessionId) return false;
refsStore.toggle(
makeInteractiveElementRef(target.stageId, sceneId, { selector, outerHTML, text }, t),
);
return true;
}
/**
* Stable host for interactive scene iframes (#619).
@@ -98,8 +154,20 @@ interface PooledIframeProps {
* targetOrigin='*'.
*/
function PooledIframe({ sceneId, entry, visible }: PooledIframeProps) {
const { t } = useI18n();
const iframeRef = useRef<HTMLIFrameElement>(null);
const registerIframe = useWidgetIframeStore((s) => s.registerIframe);
const getSendMessage = useWidgetIframeStore((s) => s.getSendMessage);
const pickTarget = useCanvasStore.use.pickTarget();
const refs = useElementRefsStore.use.refs();
const armed = pickTarget?.purpose === 'element-ref' && pickTarget.sceneId === sceneId;
const selectors = useMemo(
() =>
refs.flatMap((ref) =>
ref.kind === 'interactive-element' && ref.sceneId === sceneId ? [ref.selector] : [],
),
[refs, sceneId],
);
// Register the postMessage callback for this scene (moved here from the
// placeholder, since the iframe now lives in the host). Stable per scene:
@@ -112,6 +180,20 @@ function PooledIframe({ sceneId, entry, visible }: PooledIframeProps) {
return () => registerIframe(sceneId, null);
}, [sceneId, registerIframe]);
useEffect(() => {
const send = getSendMessage(sceneId);
if (!send) return;
send(armed ? 'element-picker:arm' : 'element-picker:disarm', {});
return () => {
if (armed) send('element-picker:disarm', {});
};
}, [armed, entry.srcDoc, getSendMessage, sceneId]);
useEffect(() => {
if (!armed) return;
getSendMessage(sceneId)?.('element-picker:sync', { selectors });
}, [armed, entry.srcDoc, getSendMessage, sceneId, selectors]);
// Capture runtime errors the iframe's error shim posts out (see iframe.ts), so
// the editor agent can diagnose a blank/broken page. Matched to THIS iframe by
// event.source (sandboxed null-origin iframes still postMessage to the parent).
@@ -126,17 +208,21 @@ function PooledIframe({ sceneId, entry, visible }: PooledIframeProps) {
const onMessage = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
const d = e.data as
| { __maicInteractive?: boolean; kind?: string; errorKind?: string; message?: unknown }
| (InteractivePickerMessage & { errorKind?: string; message?: unknown })
| undefined;
if (!d || d.__maicInteractive !== true || d.kind !== 'runtime-error') return;
const kind = typeof d.errorKind === 'string' ? d.errorKind : 'error';
const msg = typeof d.message === 'string' ? d.message : String(d.message ?? '');
useSceneRuntimeErrors.getState().addError(sceneId, `[${kind}] ${msg}`);
if (!d || d.__maicInteractive !== true) return;
if (d.kind === 'runtime-error') {
const kind = typeof d.errorKind === 'string' ? d.errorKind : 'error';
const msg = typeof d.message === 'string' ? d.message : String(d.message ?? '');
useSceneRuntimeErrors.getState().addError(sceneId, `[${kind}] ${msg}`);
return;
}
handleInteractivePickerMessage(sceneId, d, t);
};
window.addEventListener('message', onMessage);
iframeRef.current?.contentWindow?.postMessage({ __maicErrorReplayRequest: true }, '*');
return () => window.removeEventListener('message', onMessage);
}, [sceneId, entry.srcDoc]);
}, [sceneId, entry.srcDoc, t]);
// A content change reloads the iframe; drop the previous render's errors so the
// captured set reflects the CURRENT page (e.g. after the agent applies a fix).
@@ -145,33 +231,55 @@ function PooledIframe({ sceneId, entry, visible }: PooledIframeProps) {
}, [sceneId, entry.srcDoc]);
const rect = entry.rect;
const clip = entry.clip ?? rect;
const viewport = rect ? fitGenUiViewport(rect) : null;
const visibleViewport = viewport && clip ? intersectClientBoxes(viewport.box, clip) : null;
// Require a real measured box before showing — a null or zero-size rect means
// the slot hasn't laid out yet; showing then would flash a 0x0 iframe pinned
// at the viewport origin.
const shown = visible && rect !== null && rect.width > 0 && rect.height > 0;
const style: CSSProperties = {
const shown =
visible &&
rect !== null &&
clip !== null &&
viewport !== null &&
visibleViewport !== null &&
visibleViewport.width > 0 &&
visibleViewport.height > 0 &&
rect.width > 0 &&
rect.height > 0;
const wrapStyle: CSSProperties = {
position: 'fixed',
left: rect?.left ?? 0,
top: rect?.top ?? 0,
width: rect?.width ?? 0,
height: rect?.height ?? 0,
border: 0,
borderRadius: '0.5rem', // matches the canvas box's rounded-lg
left: visibleViewport?.left ?? 0,
top: visibleViewport?.top ?? 0,
width: visibleViewport?.width ?? 0,
height: visibleViewport?.height ?? 0,
overflow: 'hidden',
borderRadius: '0.5rem',
zIndex: 1,
// visibility (not display) — display:none can drop the document on re-show.
visibility: shown ? 'visible' : 'hidden',
pointerEvents: shown ? 'auto' : 'none',
};
const iframeStyle: CSSProperties = {
position: 'absolute',
left: viewport && visibleViewport ? viewport.box.left - visibleViewport.left : 0,
top: viewport && visibleViewport ? viewport.box.top - visibleViewport.top : 0,
width: GENUI_LOGICAL_WIDTH,
height: GENUI_LOGICAL_HEIGHT,
border: 0,
transform: `scale(${viewport?.scale ?? 0})`,
transformOrigin: 'top left',
};
return (
<iframe
ref={iframeRef}
srcDoc={entry.srcDoc}
src={entry.srcDoc ? undefined : entry.src}
style={style}
title={`Interactive Scene ${sceneId}`}
sandbox="allow-scripts allow-forms allow-popups"
/>
<div style={wrapStyle}>
<iframe
ref={iframeRef}
srcDoc={entry.srcDoc}
src={entry.srcDoc ? undefined : entry.src}
style={iframeStyle}
title={`Interactive Scene ${sceneId}`}
sandbox="allow-scripts allow-forms allow-popups"
/>
</div>
);
}
@@ -4,6 +4,7 @@ import { useId, useMemo, useRef, useEffect } from 'react';
import type { InteractiveContent } from '@/lib/types/stage';
import { useInteractiveIframePool } from '@/lib/store/interactive-iframe-pool';
import { patchHtmlForIframe } from '@/lib/utils/iframe';
import { visibleClientRect } from '@/lib/edit/visible-client-rect';
interface InteractiveRendererProps {
readonly content: InteractiveContent;
@@ -57,7 +58,8 @@ export function InteractiveRenderer({ content, sceneId }: InteractiveRendererPro
const node = slotRef.current;
if (node) {
const r = node.getBoundingClientRect();
setRect(sceneId, { left: r.left, top: r.top, width: r.width, height: r.height });
const clip = visibleClientRect(node);
setRect(sceneId, { left: r.left, top: r.top, width: r.width, height: r.height }, clip);
}
raf = requestAnimationFrame(measure);
};
+24
View File
@@ -28,6 +28,7 @@ import {
Mic,
Plus,
CreditCard,
Sparkles,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
@@ -57,6 +58,7 @@ import { WebSearchSettings } from './web-search-settings';
import { WEB_SEARCH_PROVIDERS, getWebSearchProviderDisplayName } from '@/lib/web-search/constants';
import type { WebSearchProviderId } from '@/lib/web-search/types';
import { GeneralSettings } from './general-settings';
import { SkillSettings } from './skill-settings';
import { TokenPlanSettings } from './token-plan-settings';
import { ModelEditDialog } from './model-edit-dialog';
import { AddProviderDialog, type NewProviderData } from './add-provider-dialog';
@@ -551,6 +553,13 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD
switch (activeSection) {
case 'general':
return <h2 className="text-lg font-semibold">{t('settings.systemSettings')}</h2>;
case 'skills':
return (
<>
<Sparkles className="h-6 w-6 text-muted-foreground" />
<h2 className="text-lg font-semibold">{t('settings.skills.title')}</h2>
</>
);
case 'token-plan':
return <h2 className="text-lg font-semibold">{t('settings.tokenPlan.nav')}</h2>;
case 'providers':
@@ -834,6 +843,19 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD
<span className="truncate">{t('settings.webSearchSettings')}</span>
</button>
<button
onClick={() => setActiveSection('skills')}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
activeSection === 'skills'
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<Sparkles className="h-4 w-4 shrink-0" />
<span className="truncate">{t('settings.skills.nav')}</span>
</button>
<button
onClick={() => setActiveSection('general')}
className={cn(
@@ -1055,6 +1077,8 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD
<div className="flex-1 overflow-y-auto p-5">
{activeSection === 'general' && <GeneralSettings />}
{activeSection === 'skills' && <SkillSettings />}
{activeSection === 'token-plan' && <TokenPlanSettings />}
{activeSection === 'providers' && selectedProvider && (
+519
View File
@@ -0,0 +1,519 @@
'use client';
/**
* Skill management section of the global settings dialog.
*
* Lists the skills installed for the current account — built-in skills that
* ship with the product and the owner's own skills created from chat history —
* from the owner-scoped `GET /api/agent/skills` registry. The row layout and
* the grouped list follow the reference skill-settings dialog, which this
* surface replaces with REAL endpoints only:
*
* - every row opens a detail view (`SkillDetailDialog`) and offers a real
* Download action that hits `GET /api/skills/:id` and ships the zip the
* server builds;
* - a user skill's detail view loads its full body from the owner-scoped
* detail route (`GET /api/agent/skills/:id`); built-in skills have no
* detail route, so their detail view shows what the registry already
* carries and never issues a request that would 404.
*
* Owner rows can also be deleted after confirmation, and exported zips or bare
* SKILL.md files can be uploaded through the owner-scoped registry endpoint.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { Download, Loader2, Sparkles, Trash2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useI18n } from '@/lib/hooks/use-i18n';
import {
agentSkillsErrorText,
skillTitle,
useAgentSkills,
type AgentSkillInfo,
} from '@/lib/workbench/agent-skills';
import { cn } from '@/lib/utils';
/**
* The real Download affordance for one skill. A plain anchor to the export
* route (`GET /api/skills/:id`): same-origin, so the `download` attribute
* names the file and the server's `Content-Disposition` keeps it a download
* in every browser either way.
*/
function DownloadLink({ skill }: { skill: AgentSkillInfo }) {
const { t } = useI18n();
return (
<a
href={`/api/skills/${encodeURIComponent(skill.id)}`}
download={`${skill.name}-skill.zip`}
data-testid={`skill-settings-download-${skill.name}`}
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
>
<Download className="size-3.5" />
{t('settings.skills.download')}
</a>
);
}
/** The kind/constraint pills a row and the detail view share. */
function SkillBadges({ skill }: { skill: AgentSkillInfo }) {
const { t } = useI18n();
return (
<>
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary">
{skill.source === 'user'
? t('settings.skills.badgeOwner')
: t('settings.skills.badgeBuiltin')}
</span>
{skill.hasConstraints && (
<span className="shrink-0 rounded bg-muted px-1.5 py-px text-[10px] text-muted-foreground">
{t('settings.skills.badgeConstraints')}
</span>
)}
</>
);
}
function SkillRow({
skill,
onDetails,
onDelete,
}: {
skill: AgentSkillInfo;
onDetails: (skill: AgentSkillInfo) => void;
onDelete?: (skill: AgentSkillInfo) => void;
}) {
const { t } = useI18n();
const title = skillTitle(skill, t);
return (
<div
data-testid={`skill-settings-row-${skill.name}`}
className="flex items-start gap-2 rounded-lg px-2 py-2 hover:bg-muted/60"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-1.5">
{title ? (
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
{title}
</span>
) : null}
{/* The English id is the skill's contract — it is never dropped. */}
<span
className={cn(
'shrink-0 text-[11px] text-muted-foreground',
!title && 'text-[13px] font-medium text-foreground',
)}
>
/{skill.name}
</span>
<SkillBadges skill={skill} />
</div>
<p className="line-clamp-2 text-[11.5px] leading-snug text-muted-foreground">
{skill.description}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5 pt-0.5">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
data-testid={`skill-settings-details-${skill.name}`}
onClick={() => onDetails(skill)}
>
{t('settings.skills.details')}
</Button>
<DownloadLink skill={skill} />
{onDelete ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
data-testid={`skill-settings-delete-${skill.name}`}
onClick={() => onDelete(skill)}
>
<Trash2 className="size-3.5" />
{t('settings.skills.delete')}
</Button>
) : null}
</div>
</div>
);
}
function SkillGroup({
label,
skills,
emptyLabel,
testId,
onDetails,
onDelete,
}: {
label: string;
skills: AgentSkillInfo[];
emptyLabel: string;
testId: string;
onDetails: (skill: AgentSkillInfo) => void;
onDelete?: (skill: AgentSkillInfo) => void;
}) {
return (
<div>
{/* The label sits OUTSIDE the bordered box — it is the group's heading,
not a row of the list. */}
<h4 className="mb-1 leading-none text-[11px] font-semibold text-muted-foreground">{label}</h4>
<section data-testid={testId} className="rounded-lg border border-border pb-1 pt-0.5">
{skills.length === 0 ? (
<p className="px-2 py-3 text-xs text-muted-foreground">{emptyLabel}</p>
) : (
skills.map((skill) => (
<SkillRow key={skill.id} skill={skill} onDetails={onDetails} onDelete={onDelete} />
))
)}
</section>
</div>
);
}
interface SkillContentState {
loading: boolean;
failed: boolean;
content: string | null;
}
/**
* The full body of ONE user skill, from the owner-scoped detail route
* (`GET /api/agent/skills/:id`). Built-in skills have no detail route — their
* registry row already carries the whole story — so this hook is only ever
* handed a user-skill id and never issues a request that would 404.
*/
function useUserSkillContent(id: string | null): SkillContentState & { retry: () => void } {
const [state, setState] = useState<SkillContentState>({
loading: false,
failed: false,
content: null,
});
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!id) return;
let cancelled = false;
// The dialog re-opens per skill: reset synchronously so the previous
// skill's body never flashes under the new one's loading state.
// eslint-disable-next-line react-hooks/set-state-in-effect
setState({ loading: true, failed: false, content: null });
fetch(`/api/agent/skills/${encodeURIComponent(id)}`)
.then(async (res) => {
if (!res.ok) throw new Error(`skill detail request failed: ${res.status}`);
const body = (await res.json()) as { id: string; content: string };
if (!cancelled) setState({ loading: false, failed: false, content: body.content });
})
.catch(() => {
if (!cancelled) setState({ loading: false, failed: true, content: null });
});
return () => {
cancelled = true;
};
}, [id, attempt]);
const retry = useCallback(() => setAttempt((n) => n + 1), []);
return { ...state, retry };
}
/**
* The detail view, laid out like the reference skill-settings dialog: a
* header carrying the display name + id and the one-line description, the
* kind/constraint pills, and the skill body (user skills) or a note that the
* built-in ships with the product. Download stays available in the footer.
*/
function SkillDetailDialog({
skill,
onClose,
}: {
skill: AgentSkillInfo | null;
onClose: () => void;
}) {
const { t } = useI18n();
const content = useUserSkillContent(skill && skill.source === 'user' ? skill.id : null);
return (
<Dialog open={skill !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent
data-testid="skill-settings-detail-dialog"
className="max-h-[80vh] gap-3 overflow-y-auto p-4 sm:max-w-[520px]"
>
{skill ? (
<>
<DialogHeader className="space-y-0.5">
<DialogTitle className="flex items-baseline gap-1.5 text-base">
<Sparkles className="size-4 shrink-0 self-center text-primary" />
<span className="min-w-0 truncate">{skillTitle(skill, t) ?? skill.name}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">/{skill.name}</span>
</DialogTitle>
<DialogDescription className="text-xs">{skill.description}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-1.5">
<SkillBadges skill={skill} />
</div>
{skill.source === 'user' ? (
content.loading ? (
<p
data-testid="skill-settings-detail-loading"
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
{t('common.loading')}
</p>
) : content.failed ? (
<p
data-testid="skill-settings-detail-error"
className="flex items-center justify-between gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-2.5 py-2 text-xs text-destructive"
>
{t('settings.skills.detailFailed')}
<button
type="button"
data-testid="skill-settings-detail-retry"
onClick={content.retry}
className="text-xs font-semibold text-destructive hover:underline"
>
{t('settings.skills.retry')}
</button>
</p>
) : (
<div className="min-w-0">
<h4 className="mb-1 leading-none text-[11px] font-semibold text-muted-foreground">
{t('settings.skills.contentLabel')}
</h4>
<pre
data-testid="skill-settings-detail-content"
className="max-h-64 overflow-y-auto whitespace-pre-wrap rounded-lg border border-border bg-muted/50 p-3 font-mono text-[11px] leading-relaxed text-foreground"
>
{content.content}
</pre>
</div>
)
) : (
<p
data-testid="skill-settings-detail-note"
className="rounded-md border border-border bg-muted/50 px-2.5 py-2 text-xs text-muted-foreground"
>
{t('settings.skills.builtinDetailNote')}
</p>
)}
<DialogFooter className="gap-2 sm:justify-end">
<Button variant="outline" size="sm" onClick={onClose}>
{t('settings.close')}
</Button>
<DownloadLink skill={skill} />
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
);
}
/**
* The "Skills" section body, mounted by the settings dialog when its sidebar
* selects the section. Grouped by kind — the owner's skills first, then the
* built-ins — with the reference's loading / failed / empty patterns.
*/
export function SkillSettings() {
const { t } = useI18n();
const { skills, loading, error, reload } = useAgentSkills();
const [detailSkill, setDetailSkill] = useState<AgentSkillInfo | null>(null);
const [deleteSkill, setDeleteSkill] = useState<AgentSkillInfo | null>(null);
const [deleting, setDeleting] = useState(false);
const [uploading, setUploading] = useState(false);
const [actionError, setActionError] = useState<'deleteFailed' | 'uploadFailed' | null>(null);
const [hiddenSkillIds, setHiddenSkillIds] = useState<Set<string>>(() => new Set());
const [uploadedSkills, setUploadedSkills] = useState<AgentSkillInfo[]>([]);
const uploadRef = useRef<HTMLInputElement>(null);
const visibleSkills = [
...skills,
...uploadedSkills.filter((uploaded) => !skills.some((skill) => skill.id === uploaded.id)),
].filter((skill) => !hiddenSkillIds.has(skill.id));
const userSkills = visibleSkills.filter((skill) => skill.source === 'user');
const builtinSkills = visibleSkills.filter((skill) => skill.source === 'builtin');
const openDetails = useCallback((skill: AgentSkillInfo) => setDetailSkill(skill), []);
const confirmDelete = useCallback(async () => {
if (!deleteSkill || deleting) return;
setDeleting(true);
setActionError(null);
try {
const response = await fetch(`/api/agent/skills/${encodeURIComponent(deleteSkill.id)}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error(`skill delete request failed: ${response.status}`);
setHiddenSkillIds((current) => new Set(current).add(deleteSkill.id));
setDeleteSkill(null);
if (detailSkill?.id === deleteSkill.id) setDetailSkill(null);
await reload().catch(() => {});
} catch {
setActionError('deleteFailed');
} finally {
setDeleting(false);
}
}, [deleteSkill, deleting, detailSkill?.id, reload]);
const uploadSkill = useCallback(
async (file: File) => {
setUploading(true);
setActionError(null);
try {
const form = new FormData();
form.set('file', file);
const response = await fetch('/api/agent/skills', { method: 'POST', body: form });
if (!response.ok) throw new Error(`skill upload request failed: ${response.status}`);
const uploaded = (await response.json()) as AgentSkillInfo;
setUploadedSkills((current) => [
...current.filter((skill) => skill.id !== uploaded.id),
uploaded,
]);
await reload().catch(() => {});
} catch {
setActionError('uploadFailed');
} finally {
setUploading(false);
if (uploadRef.current) uploadRef.current.value = '';
}
},
[reload],
);
return (
<div className="flex flex-col gap-4" data-testid="skill-settings-section">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground">{t('settings.skills.description')}</p>
<input
ref={uploadRef}
type="file"
accept=".zip,.md,text/markdown,application/zip"
className="sr-only"
data-testid="skill-settings-upload-input"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void uploadSkill(file);
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 px-2.5 text-xs"
disabled={uploading}
data-testid="skill-settings-upload"
onClick={() => uploadRef.current?.click()}
>
{uploading ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="size-3.5" />
)}
{uploading ? t('settings.skills.uploading') : t('settings.skills.upload')}
</Button>
</div>
{actionError ? (
<p
data-testid="skill-settings-action-error"
className="rounded-md border border-destructive/40 bg-destructive/10 px-2.5 py-2 text-xs text-destructive"
>
{t(`settings.skills.${actionError}`)}
</p>
) : null}
{loading ? (
<p
data-testid="skill-settings-loading"
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
{t('common.loading')}
</p>
) : error ? (
// A failed list answers BOTH groups at once — rendering empty boxes
// under an error would read as "you have no skills".
<p
data-testid="skill-settings-list-error"
className="flex items-center justify-between gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-2.5 py-2 text-xs text-destructive"
>
{agentSkillsErrorText({ error }, t)}
<button
type="button"
data-testid="skill-settings-list-retry"
onClick={() => void reload().catch(() => {})}
className="text-xs font-semibold text-destructive hover:underline"
>
{t('settings.skills.retry')}
</button>
</p>
) : (
<>
<SkillGroup
label={t('settings.skills.mySkills')}
skills={userSkills}
emptyLabel={t('settings.skills.emptyMySkills')}
testId="skill-settings-my-group"
onDetails={openDetails}
onDelete={setDeleteSkill}
/>
<SkillGroup
label={t('settings.skills.builtinSkills')}
skills={builtinSkills}
emptyLabel={t('settings.skills.emptyBuiltinSkills')}
testId="skill-settings-builtin-group"
onDetails={openDetails}
/>
</>
)}
<SkillDetailDialog skill={detailSkill} onClose={() => setDetailSkill(null)} />
<AlertDialog
open={deleteSkill !== null}
onOpenChange={(open) => !open && setDeleteSkill(null)}
>
<AlertDialogContent data-testid="skill-settings-delete-dialog">
<AlertDialogHeader>
<AlertDialogTitle>{t('settings.skills.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>{t('settings.skills.deleteConfirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
data-testid="skill-settings-delete-confirm"
onClick={(event) => {
event.preventDefault();
void confirmDelete();
}}
>
{deleting ? t('settings.skills.deleting') : t('settings.skills.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Sun, Moon, Monitor } from 'lucide-react';
import { useTheme } from '@/lib/hooks/use-theme';
import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils';
import '@/components/workbench/workspace/pro-popover-scope.css';
const OPTIONS = [
{ value: 'light', icon: Sun },
{ value: 'dark', icon: Moon },
{ value: 'system', icon: Monitor },
] as const;
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open]);
const ActiveIcon = OPTIONS.find((o) => o.value === theme)?.icon ?? Monitor;
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="h-8 w-8 inline-flex items-center justify-center rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-gray-100 transition-all"
aria-label="Theme"
>
<ActiveIcon className="w-4 h-4" />
</button>
{open && (
<div className="pro-theme-popover absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[140px]">
{OPTIONS.map(({ value, icon: Icon }) => (
<button
key={value}
onClick={() => {
setTheme(value);
setOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
theme === value &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
<Icon className="w-4 h-4" />
{t(`settings.themeOptions.${value}`)}
</button>
))}
</div>
)}
</div>
);
}
@@ -21,6 +21,7 @@ import {
} from '@/components/ui/context-menu';
import { ElementOrderCommands, ElementAlignCommands } from '@/lib/types/edit';
import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
import { editableElementDomId, maicElementIdAttributes } from '../../element-dom';
export interface ContextmenuItem {
text?: string;
@@ -217,7 +218,8 @@ export function EditableElement({
if (!CurrentElementComponent) {
return (
<div
id={`editable-element-${elementInfo.id}`}
id={editableElementDomId(elementInfo.id)}
{...maicElementIdAttributes(elementInfo.id)}
className="editable-element absolute"
style={{
zIndex: elementIndex,
@@ -235,7 +237,8 @@ export function EditableElement({
return (
<div
id={`editable-element-${elementInfo.id}`}
id={editableElementDomId(elementInfo.id)}
{...maicElementIdAttributes(elementInfo.id)}
className="editable-element absolute"
style={{
zIndex: elementIndex,
@@ -15,6 +15,13 @@ export interface ViewportStyles {
export function useViewportSize(canvasRef: RefObject<HTMLElement | null>) {
const [viewportLeft, setViewportLeft] = useState(0);
const [viewportTop, setViewportTop] = useState(0);
// Local mirror of the computed scale. `canvasScale` in the store is GLOBAL:
// every mounted canvas writes it (crossfade-exiting panes, keep-alive tabs),
// so a sibling's settle can overwrite the scale this canvas renders with
// until this canvas's own observer fires again — the first-open mis-scale
// that a seam drag "fixed" by forcing a re-measure. Render from the local
// value; the store keeps being written for out-of-tree consumers.
const [fitScale, setFitScale] = useState(() => useCanvasStore.getState().canvasScale);
const canvasPercentage = useCanvasStore.use.canvasPercentage();
const canvasDragged = useCanvasStore.use.canvasDragged();
@@ -32,12 +39,16 @@ export function useViewportSize(canvasRef: RefObject<HTMLElement | null>) {
if (canvasHeight / canvasWidth > viewportRatio) {
const viewportActualWidth = canvasWidth * (canvasPercentage / 100);
setCanvasScale(viewportActualWidth / viewportSize);
const nextScale = viewportActualWidth / viewportSize;
setCanvasScale(nextScale);
setFitScale(nextScale);
setViewportLeft((canvasWidth - viewportActualWidth) / 2);
setViewportTop((canvasHeight - viewportActualWidth * viewportRatio) / 2);
} else {
const viewportActualHeight = canvasHeight * (canvasPercentage / 100);
setCanvasScale(viewportActualHeight / (viewportSize * viewportRatio));
const nextScale = viewportActualHeight / (viewportSize * viewportRatio);
setCanvasScale(nextScale);
setFitScale(nextScale);
setViewportLeft((canvasWidth - viewportActualHeight / viewportRatio) / 2);
setViewportTop((canvasHeight - viewportActualHeight) / 2);
}
@@ -56,7 +67,9 @@ export function useViewportSize(canvasRef: RefObject<HTMLElement | null>) {
const newViewportActualHeight = newViewportActualWidth * viewportRatio;
const oldViewportActualHeight = oldViewportActualWidth * viewportRatio;
setCanvasScale(newViewportActualWidth / viewportSize);
const nextScale = newViewportActualWidth / viewportSize;
setCanvasScale(nextScale);
setFitScale(nextScale);
setViewportLeft((prev) => prev - (newViewportActualWidth - oldViewportActualWidth) / 2);
setViewportTop((prev) => prev - (newViewportActualHeight - oldViewportActualHeight) / 2);
@@ -66,7 +79,9 @@ export function useViewportSize(canvasRef: RefObject<HTMLElement | null>) {
const newViewportActualWidth = newViewportActualHeight / viewportRatio;
const oldViewportActualWidth = oldViewportActualHeight / viewportRatio;
setCanvasScale(newViewportActualHeight / (viewportSize * viewportRatio));
const nextScale = newViewportActualHeight / (viewportSize * viewportRatio);
setCanvasScale(nextScale);
setFitScale(nextScale);
setViewportLeft((prev) => prev - (newViewportActualWidth - oldViewportActualWidth) / 2);
setViewportTop((prev) => prev - (newViewportActualHeight - oldViewportActualHeight) / 2);
@@ -161,5 +176,6 @@ export function useViewportSize(canvasRef: RefObject<HTMLElement | null>) {
return {
viewportStyles,
dragViewport,
fitScale,
};
}
@@ -2,6 +2,7 @@
import { useRef, useState, useEffect } from 'react';
import { useCanvasStore } from '@/lib/store/canvas';
import { useSyncCanvasViewportFromSlide } from '@/lib/store/sync-canvas-viewport';
import { useSceneSelector } from '@/lib/contexts/scene-context';
import { useKeyboardStore } from '@/lib/store/keyboard';
import { useViewportSize } from './hooks/useViewportSize';
@@ -62,6 +63,7 @@ export interface CanvasProps {
export function Canvas(_props: CanvasProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
useSyncCanvasViewportFromSlide();
// Subscribe to specific parts for performance optimization
const elements = useSceneSelector<SlideContent, PPTElement[]>(
@@ -69,7 +71,6 @@ export function Canvas(_props: CanvasProps) {
);
// Canvas UI state
const canvasScale = useCanvasStore.use.canvasScale();
const activeElementIdList = useCanvasStore.use.activeElementIdList();
const activeGroupElementId = useCanvasStore.use.activeGroupElementId();
const handleElementId = useCanvasStore.use.handleElementId();
@@ -100,8 +101,14 @@ export function Canvas(_props: CanvasProps) {
setElementList(newElements);
}, [elements]);
// Viewport size and positioning
const { viewportStyles, dragViewport } = useViewportSize(canvasRef);
// Viewport size and positioning. Render with the hook's LOCAL fitScale (not
// the global store canvasScale): sibling canvases (crossfade-exiting pane,
// keep-alive tabs) write the shared store value, which could leave this
// canvas rendering a scale computed for another container until a seam drag
// forced a re-measure. The store is still written by the hook for
// out-of-tree consumers.
const { viewportStyles, dragViewport, fitScale } = useViewportSize(canvasRef);
const canvasScale = fitScale;
// Initialize drop handler
useDrop(canvasRef);
@@ -7,6 +7,7 @@ import { useCanvasStore } from '@/lib/store/canvas';
import type { SlideContent } from '@/lib/types/stage';
import type { PPTElement } from '@openmaic/dsl';
import { LaserOverlay } from './LaserOverlay';
import { SCREEN_ELEMENT_ID_PREFIX } from '../element-dom';
interface LaserPointerOverlayProps {
/**
@@ -28,7 +29,7 @@ interface LaserPointerOverlayProps {
* were collapsed into a spotlight instead.
*/
export function LaserPointerOverlay({
domIdPrefix = 'screen-element-',
domIdPrefix = SCREEN_ELEMENT_ID_PREFIX,
}: LaserPointerOverlayProps = {}) {
const laserElementId = useCanvasStore.use.laserElementId();
const laserOptions = useCanvasStore.use.laserOptions();
@@ -13,6 +13,7 @@ import { retryMediaTask } from '@/lib/media/media-orchestrator';
import { useI18n } from '@/lib/hooks/use-i18n';
import { createLogger } from '@/lib/logger';
import { mediaResolutionCanRetry, type MediaResolution } from '@/lib/media/resolve-media-ref';
import { SCREEN_ELEMENT_ID_PREFIX } from '../element-dom';
const log = createLogger('RendererScreenCanvas');
@@ -353,7 +354,7 @@ export function RendererScreenCanvas() {
canvasPercentage={canvasPercentage}
onScaleChange={handleScaleChange}
effects={effects}
elementIdPrefix="screen-element-"
elementIdPrefix={SCREEN_ELEMENT_ID_PREFIX}
renderImage={(element, _src, defaultContent) => (
<PlaybackImageContent
element={element}
@@ -7,6 +7,7 @@ import { LaserOverlay } from './LaserOverlay';
import { RendererScreenCanvas } from './RendererScreenCanvas';
import { useSlideBackgroundStyle } from '@/lib/hooks/use-slide-background-style';
import { useCanvasStore } from '@/lib/store';
import { useSyncCanvasViewportFromSlide } from '@/lib/store/sync-canvas-viewport';
import { useSceneSelector } from '@/lib/contexts/scene-context';
import { findElementGeometry } from '@/lib/utils/geometry';
import type { SlideContent } from '@/lib/types/stage';
@@ -18,6 +19,7 @@ import { AnimatePresence } from 'motion/react';
import { isPlaybackRendererEnabled } from '@/lib/config/feature-flags';
export function ScreenCanvas() {
useSyncCanvasViewportFromSlide();
const canvasScale = useCanvasStore.use.canvasScale();
const elements = useSceneSelector<SlideContent, PPTElement[]>(
(content) => content.canvas.elements,

Some files were not shown because too many files have changed in this diff Show More