From f067ddb766ee1dbb3c79975d64655e26b0060cdb Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Mon, 20 Jul 2026 17:59:16 +0530 Subject: [PATCH] Add collapsible left "Script State" drawer on the play screen Exposes the scripting `state` object (every variable scripts read/write via state.x, persisted per-adventure) in a collapsible left rail that refreshes after each turn. New owner-scoped GET /api/adventures/{id}/script-state endpoint; the drawer only fetches while open and shows an empty-state hint until a script sets a variable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5 --- backend/app/routers/adventures.py | 11 ++++++ frontend/src/api.js | 1 + frontend/src/index.css | 66 +++++++++++++++++++++++++++++++ frontend/src/pages/Play.jsx | 55 ++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index 6a58761..b10b434 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -141,6 +141,17 @@ def get_adventure( return get_adventure_or_404(adventure_id, db, user) +@router.get("/{adventure_id}/script-state") +def get_script_state( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """The scripting `state` object — every variable scripts read/write via + `state.x`, persisted after each hook. Empty {} until a script sets one.""" + adventure = get_adventure_or_404(adventure_id, db, user) + state = adventure.script_state if isinstance(adventure.script_state, dict) else {} + return {"state": state} + + @router.patch("/{adventure_id}", response_model=schemas.AdventureOut) def update_adventure( adventure_id: int, diff --git a/frontend/src/api.js b/frontend/src/api.js index 3205bdb..dcc2400 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -79,6 +79,7 @@ export const api = { // Adventures listAdventures: () => request('/adventures'), getAdventure: (id) => request(`/adventures/${id}`), + getScriptState: (id) => request(`/adventures/${id}/script-state`), createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }), updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }), diff --git a/frontend/src/index.css b/frontend/src/index.css index baf8fff..39832f0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -439,6 +439,72 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n top: 56px; animation: panel-in 0.25s ease both; } + +/* ---------- Left status drawer (script state) ---------- */ + +.status-drawer { + flex-shrink: 0; + position: sticky; + top: 56px; + height: calc(100vh - 56px); + display: flex; + align-items: flex-start; + border-right: 1px solid var(--border); + background: var(--bg-panel); +} +.status-drawer.open { width: 300px; } +.status-toggle { + flex-shrink: 0; + height: 100%; + width: 30px; + padding: 12px 0; + border: none; + border-radius: 0; + background: transparent; + color: var(--text-dim); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} +.status-toggle:hover { color: var(--text); background: var(--bg-input); } +.status-toggle-label { + writing-mode: vertical-rl; + text-orientation: mixed; + letter-spacing: 0.12em; + font-size: 0.72rem; + text-transform: uppercase; +} +.status-body { + flex: 1; + min-width: 0; + height: 100%; + overflow-y: auto; + padding: 20px 16px; + animation: panel-in 0.2s ease both; +} +.status-vars { list-style: none; margin: 0; padding: 0; } +.status-vars li { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.status-key { + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + color: var(--accent-bright); + word-break: break-word; +} +.status-val { + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} @keyframes panel-in { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: none; } diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index b53c46d..87fe660 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -317,6 +317,60 @@ function ScriptsPanel({ advId }) { ) } +// Collapsible left rail showing the scripting `state` object — every variable +// scripts read/write via state.x, refreshed after each turn. +function StatusDrawer({ advId, refreshKey }) { + const [open, setOpen] = useState(false) + const [state, setState] = useState(null) + const [failed, setFailed] = useState(false) + + const load = useCallback(() => { + api.getScriptState(advId) + .then((r) => { setState(r.state || {}); setFailed(false) }) + .catch(() => setFailed(true)) + }, [advId]) + + // Only fetch while open; re-fetch after each turn so values stay live. + useEffect(() => { if (open) load() }, [open, refreshKey, load]) + + const entries = state ? Object.entries(state) : [] + + return ( +
+ + {open && ( +
+
+

Script State

+ +
+ {failed ? ( +
Couldn’t load state.
+ ) : entries.length === 0 ? ( +
+ No variables yet. Scripts that use state will appear here after a turn. +
+ ) : ( +
    + {entries.map(([k, v]) => ( +
  • + {k} + + {typeof v === 'string' ? v : JSON.stringify(v, null, 2)} + +
  • + ))} +
+ )} +
+ )} +
+ ) +} + function ScriptReport({ script }) { if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) { return null @@ -620,6 +674,7 @@ export default function Play() { return (
+

{adventure.title}