diff --git a/docs/superpowers/plans/2026-05-30-dedicated-link-scope.md b/docs/superpowers/plans/2026-05-30-dedicated-link-scope.md new file mode 100644 index 0000000..89e2f7a --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-dedicated-link-scope.md @@ -0,0 +1,156 @@ +# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=`) so it survives refresh — fixing item 4 too. + +**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist. + +**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.) + +**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md` + +--- + +## Task 1: Store scope state + pure resolve helper (with test) + +**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`. + +READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style). + +- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`: + +```typescript +/** Given the desired scoped id (from ?bot) and the known bot ids, decide the + * effective scope. Returns the id if it exists, else null (graceful clear: + * a stale/forbidden id never locks the UI). */ +export function resolveScopedBot( + requestedId: string | null | undefined, + knownBotIds: readonly string[], +): string | null { + if (!requestedId) return null; + return knownBotIds.includes(requestedId) ? requestedId : null; +} +``` + +`web/src/stores/scope.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { resolveScopedBot } from "./scope.js"; + +describe("resolveScopedBot", () => { + it("returns null when no id requested", () => { + expect(resolveScopedBot(null, ["a", "b"])).toBeNull(); + expect(resolveScopedBot(undefined, ["a"])).toBeNull(); + expect(resolveScopedBot("", ["a"])).toBeNull(); + }); + it("returns the id when it exists in the bot list", () => { + expect(resolveScopedBot("b", ["a", "b"])).toBe("b"); + }); + it("clears (null) when the requested id is not a known bot", () => { + expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull(); + }); +}); +``` + +- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing. + (Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.) + +- [ ] **Step 3 — implement the helper** (code above). + +- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:** + - state: `scopedBotId: null as string | null`. + - getter: `isScoped: (state) => state.scopedBotId !== null`. + - actions: + - `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does. + - `clearScope()` → `this.scopedBotId = null;`. + - `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.) + - Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped. + +- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0. + +- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"` + +--- + +## Task 2: Router guard — sync scope from `?bot` + preserve across navigation + +**Files:** Modify `web/src/router/index.ts`. + +READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs). + +- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add: + +```typescript +const store = usePlayerStore(); +const qBot = typeof to.query.bot === "string" ? to.query.bot : null; +if (qBot) { + // entering/with a scope in the URL — store will validate against bots later + store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears + return next(); +} +if (store.scopedBotId) { + // scoped but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh + if (to.query.bot !== store.scopedBotId) { + return next({ ...to, query: { ...to.query, bot: store.scopedBotId } }); + } +} +return next(); +``` + +(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.) + +- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again). + +- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"` + +--- + +## Task 3: BotRedirect seeds the URL scope + +**Files:** Modify `web/src/views/BotRedirect.vue`. + +READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found. + +- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`: + - keep the fetchBots + existence check, + - if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy. + - if not found: unchanged (show "机器人不存在或未加载"). + +- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. +- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"` + +--- + +## Task 4: Navbar lock UI + apply-scope-on-load + +**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`. + +READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect). + +- [ ] **Step 1 — Navbar lock.** When `store.isScoped`: + - render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`), + - disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch, + - hide other bots' "copy link" affordances (only the scoped bot remains anyway), + - show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present. + When not scoped: behavior unchanged. + +- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.) + +- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged. +- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"` + +--- + +## Final verification +- [ ] `cd web && npx vue-tsc --noEmit` → exit 0 +- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected) +- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass +- [ ] `npm run build` → succeeds +- [ ] Manual: open `/bot/` → URL becomes `/?bot=`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=` → gracefully shows all bots (no lock). + +## Notes +- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock. +- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing. +- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation. diff --git a/docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md b/docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md new file mode 100644 index 0000000..6df5ea8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md @@ -0,0 +1,65 @@ +# Dedicated-link bot scoping (+ refresh fix) — design + +**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4 +**Date:** 2026-05-30 +**Status:** Approved (brainstorm), pending implementation plan + +## Problem + +- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector. +- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot. + +Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`. + +## Decision (from brainstorm: Q2 = URL-carried scope) + +Carry the scoped bot in the **URL query** (`?bot=`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock. + +## Design + +### Scope state (store) +Add to the player store: +- `scopedBotId: string | null` — the bot the UI is locked to. +- getter `isScoped` = `scopedBotId !== null`. +- action `setScope(id)` / `clearScope()`. +- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots. + +### URL as the durable source of truth +- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.) +- **Router `beforeEach` guard** (the heart of it): + - If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue. + - Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.). + - Else → no scope; continue. + This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**. +- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id. + +### Exit +- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional). + +### Navbar (the lock UI) +- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown. +- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`. +- When not scoped: unchanged (full selector over `controllableBots`). + +### Active-bot coherence +Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above). + +## Components / files + +- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing). +- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation. +- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`. +- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance. +- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard). + +## Testing + +Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces: +- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot. +- `clearScope` resets state. +- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested. +Manual: open `/bot/` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock). + +## Non-goals + +- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `/bot/`); only what happens when one is opened. diff --git a/web/src/App.vue b/web/src/App.vue index d7dbbf0..3781225 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -140,12 +140,20 @@ function cycleMobileMode() { playerStore.setMode(nextMode); } -onMounted(() => { +onMounted(async () => { playerStore.loadTheme(); connect(); - playerStore.fetchBots(); syncTimer = setInterval(() => playerStore.syncElapsed(), 3000); mobileRaf = requestAnimationFrame(updateMobileProgress); + // Reconcile the dedicated-link scope only after the bot list is known: the + // router guard sets scopedBotId tentatively from ?bot, but applyScopeFromQuery + // validates it against the loaded bots (locks if it exists, clears if stale). + await playerStore.fetchBots(); + // Read from the authoritative current route (not a possibly-stale reactive + // snapshot) so the scope reconciles against the ?bot present at refresh time. + const routeBot = router.currentRoute.value.query.bot; + const qBot = typeof routeBot === 'string' ? routeBot : null; + playerStore.applyScopeFromQuery(qBot); }); onUnmounted(() => { diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index 1024b3f..bd05bd0 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -10,8 +10,20 @@