From af2f5edd4044ddf186e7f76c096bdc9bc12581e3 Mon Sep 17 00:00:00 2001 From: Failerko Date: Sat, 18 Apr 2026 17:04:25 +0200 Subject: [PATCH 01/36] docs: add design spec for vault assistant mobile parity Captures the design for bringing mobile feature parity with the desktop Vault Assistant and fixing width issues on Galaxy Fold inner screens and narrow desktop windows. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-04-18-vault-assistant-mobile-design.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-18-vault-assistant-mobile-design.md diff --git a/docs/superpowers/specs/2026-04-18-vault-assistant-mobile-design.md b/docs/superpowers/specs/2026-04-18-vault-assistant-mobile-design.md new file mode 100644 index 00000000..fd4cb33c --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-vault-assistant-mobile-design.md @@ -0,0 +1,166 @@ +# Vault Assistant — Mobile & Narrow-Width Parity + +**Date:** 2026-04-18 +**Component:** `src/lib/components/vault/InteractiveVaultAssistant.svelte` +**Branch:** `fix/vault-assistant-mobile` + +## Problem + +The Interactive Vault Assistant's desktop layout is a two-panel split (entity edit panel on the left, chat on the right) that gives users simultaneous access to an ongoing chat and the entity being created/modified. The mobile experience does not have feature parity: + +1. **Auto-open behaviors are gated to desktop.** On mobile, the entity editor never surfaces automatically when the AI proposes a change, when it calls `show_entity`, when it auto-creates a lorebook, or when a `focusedEntity` is passed in on mount. Users must manually tap an inline diff card to reach the editor. +2. **The mobile edit sheet is a second-tier affordance.** `VaultEntityEditPanel` is rendered inside a bottom `Sheet.Root` that's only mounted when `vaultEditor.activeChange` is set. This makes `editPanelMobileRef` unreliable — e.g. the "Set Portrait" action on a generated image silently does nothing if the sheet isn't open. +3. **Width is clipped at ~672px on small-but-not-mobile screens.** The main modal applies `max-w-2xl` even in drawer mode, so on the Galaxy Fold inner screen (~700–800px CSS width) the drawer doesn't stretch edge-to-edge. There's also a conflict between the outer `h-[90vh]` and the drawer's own `max-h-[85vh]`. +4. **Narrow-desktop layout cramps the entity panel.** When the editor is open, the chat uses `max-w-2xl shrink-0` and the edit panel uses `flex-1`, so at viewports between ~770px and ~1280px, the chat hogs 672px and the edit panel collapses to ~100–200px — unusable. + +## Goals + +- Mobile users get the same set of functional affordances as desktop (entity editor, all auto-open triggers, Set Portrait from chat, etc.). +- Auto-opening must not hijack the chat stream on mobile — badge/pulse discoverability only, no forced tab switches. +- The modal stretches edge-to-edge on Fold-inner-class devices. +- Narrow-desktop viewports (including split-screen browsers) get a working layout at every width. + +## Non-goals + +- No changes to `VaultEntityEditPanel` internals, `VaultDiffView`, the service layer, or the `vaultEditor` store API. +- No changes to the shared `createIsMobile` hook, `ResponsiveModal` primitive, or `Drawer`/`Sheet` primitives. Other call-sites keep their current breakpoint. +- Desktop ≥1024px two-panel layout keeps its current shape (flex behavior is tweaked, but the user-facing structure is unchanged). + +## Design + +### Breakpoints + +Add a new hook `createIsCompact()` at `src/lib/hooks/is-compact.svelte.ts` with a `1024px` threshold (matches Tailwind `lg`). This hook mirrors `createIsMobile`'s shape but is scoped to this component's layout decisions so bumping the threshold doesn't ripple through `ResponsiveModal`, `TemplateEditor`, or `PromptPackEditor`. + +```ts +export function createIsCompact() { + let isCompact = $state(false) + onMount(() => { + const mql = window.matchMedia('(max-width: 1023px)') + const onChange = () => (isCompact = mql.matches) + mql.addEventListener('change', onChange) + isCompact = mql.matches + return () => mql.removeEventListener('change', onChange) + }) + return { + get current() { return isCompact }, + } +} +``` + +The existing `isMobile` usage in `InteractiveVaultAssistant.svelte` is replaced by `isCompact`. Touch-vs-mouse input behavior (`handleKeyDown`) continues to use `isTouchDevice()` unchanged. + +### Shell: compact vs wide + +- **Wide (`!isCompact.current`)**: keep the current `ResponsiveModal` path rendering as a `Dialog`. No visual change. +- **Compact (`isCompact.current`)**: bypass `ResponsiveModal` and render a **full-viewport `Dialog`** with `w-screen h-[100dvh] max-w-none max-h-none p-0 rounded-none`. No drawer, no width cap, no height conflict. The full-screen overlay covers the Fold-inner and tablet-width cases cleanly. + +Branching happens at the `InteractiveVaultAssistant.svelte` level — it renders either `` (wide) or `` with full-screen classes (compact). The inner content layout is shared. + +### Compact layout: tab architecture + +``` +┌─ Vault Assistant ─────── [Approve All] [✕] ─┐ +│ History ▾ │ +│ Pending ▾ (if any) │ +│ ╭─ Chat ─╮ ╭─ Entity (3) ─╮ │ ← (n) = pending count badge +│ ╰────────╯ ╰──────────────╯ │ tab pulses when new change arrives +│ │ while user is on Chat +│ │ +│ │ +│ │ +└──────────────────────────────────────────────┘ +``` + +- **Segmented control** directly below the existing dropdowns (history selector and pending-list popover stay where they are). +- **Entity tab visibility:** shown only when `vaultEditor.editorOpen && vaultEditor.activeChange` is truthy. When no active change exists, the tab bar is hidden entirely and the chat fills the viewport as today. +- **Default tab:** `chat`. Resets to `chat` on new conversation and on conversation switch. +- **Badge:** shows `vaultEditor.pendingCount` when >0. `0` → no badge. +- **Pulse:** a one-shot Tailwind keyframe highlight on the Entity tab trigger for ~800ms, fired from an `$effect` watching `vaultEditor.pendingCount`. Only pulses when the increase happened while `activeTab === 'chat'`. +- **Tab content:** + - *Chat tab body* = messages list (existing block) + streaming progress indicator + error banner + input bar. + - *Entity tab body* = `VaultEntityEditPanel` for `vaultEditor.activeChange`, filling available height. This replaces the old `Sheet.Root` path entirely; the panel is mounted inside the tab so `editPanelMobileRef` is available whenever the tab has been rendered. +- **Input bar** is only rendered on the chat tab so the virtual keyboard never fights with the entity form. The entity form's own Approve/Reject footer (inside `VaultEntityEditPanel`) is the bottom-anchored UI on that tab. +- **Safe area:** whichever element is at the bottom on a given tab gets `padding-bottom: env(safe-area-inset-bottom)` so content clears the iOS home indicator / Android nav bar. + +### Auto-open policy (compact) + +The `!isMobile.current` guards in `onMount`, the `tool_end` handler (new-lorebook + `openEditorSmart`), and the `show_entity` handler are **removed**. State updates to `vaultEditor` (opening the viewer, setting `activeChange`, auto-approving lorebooks) now run on all widths, so compact and wide share the same state model. + +Navigation is the only thing that differs: + +- **Wide:** state updates make the left panel visible immediately (as today). +- **Compact:** state updates cause the Entity tab to appear in the tab bar (if not already present) and, if the change is new, the tab pulses once. **No automatic tab switch** — the user stays on whatever tab they were on. + +**Exception — explicit user intent:** "Set Portrait" on a chat image is a direct user action that only makes sense inside the edit panel. When tapped on compact, it switches to the Entity tab *and* applies the portrait. This replaces the current `editPanelRef ?? editPanelMobileRef` fallback and fixes the silent-failure case when neither ref is mounted. + +### Wide layout: narrow-desktop flex fix + +With editor open on wide layout (≥1024px), the current flex sizing cramps the edit panel between ~1024px and ~1344px. Change the sizing to: + +- **Modal container** (editor open): `max-w-[90vw]` — unchanged. +- **Edit panel**: `flex-1 min-w-[28rem]` (adds 448px minimum). +- **Chat panel** (editor open): `flex-1 min-w-[22rem] max-w-2xl` (replaces `w-full max-w-2xl shrink-0`). Chat can shrink below 672px down to 352px, then the edit panel's minimum kicks in. +- **Chat panel** (editor closed): unchanged (`mx-auto w-full max-w-2xl`). + +At 1024px viewport: container = 90vw = 921px → chat fills the balance above 448px, edit panel fills ≥448px. Both panels remain legible at the breakpoint boundary. Above ~1344px, chat reaches its 672px max and edit panel takes the remainder, matching current behavior. + +### Feature-parity fixes recap + +| Item | Current (mobile) | After | +| ----------------------------------------- | -------------------------- | ------------------------------------------------------------------ | +| Auto-open focused entity on mount | Skipped | Sets viewer state (Entity tab appears, doesn't switch) | +| Auto-open new lorebook after creation | Skipped | Sets editor state (Entity tab appears, doesn't switch) | +| `openEditorSmart` on `tool_end` | Skipped | Runs; Entity tab appears with pulse | +| `openViewer` on `show_entity` | Skipped | Runs; Entity tab appears with pulse | +| Entity edit panel mount | Inside bottom `Sheet.Root` | Inside Entity tab body | +| `editPanelMobileRef` | Null unless sheet is open | Mounted whenever Entity tab has rendered | +| "Set Portrait" when edit panel not active | Silent no-op | Switches to Entity tab + applies portrait | +| Modal width on Fold inner (~700–800px) | Capped at 672px | Full viewport (`w-screen h-[100dvh]`) | +| Height conflict (`90vh` vs `85vh`) | Conflicting | Single `h-[100dvh]` on compact, original values on wide | +| Narrow-desktop editor panel (1024–1344px) | ~100–200px wide | ≥448px via `min-w-[28rem]`; chat shrinks to 352px minimum | + +## Files touched + +**New:** +- `src/lib/hooks/is-compact.svelte.ts` + +**Modified:** +- `src/lib/components/vault/InteractiveVaultAssistant.svelte` + - Replace `isMobile` with `isCompact` for all layout decisions. + - Remove `!isMobile.current` guards on auto-open logic. + - Add `activeTab` state + tab bar + pulse effect (compact only). + - Branch shell: `ResponsiveModal` (wide) vs full-screen `Dialog` (compact). + - Remove the separate bottom `Sheet.Root` for the mobile edit panel. + - Adjust wide-layout flex sizing (`min-w-[22rem]`, `min-w-[28rem]`, drop `shrink-0`). + - Update `handleSetPortrait` to switch to the Entity tab when on compact. + - Replace `editPanelMobileRef` with a single ref used across layouts. + +No other files change. No store, service, or shared-primitive changes. + +## Testing plan + +Manual verification only — there are no existing unit/E2E tests for this component. + +**Device widths to check:** +- Phone portrait (~390px) — tabs, full-screen, no drawer artifacts. +- Galaxy Fold outer (~344px) and inner (~700–820px) — both should be full-width, tabs present. +- Tablet portrait (~768px) / tablet landscape (~1024px) — compact layout up to 1024px, two-panel at 1024px exactly and above. +- Narrow desktop window / split-screen (1024–1280px) — two-panel, edit panel ≥448px, chat shrinks. +- Desktop (≥1344px) — two-panel at current proportions, chat ≤672px. + +**Flows to verify on compact:** +- Cold start with `focusedEntity` → Entity tab appears pre-populated, stays on Chat tab. +- AI proposes character change → Entity tab pulses, badge shows `1`, tap switches tab, edit panel functional. +- AI calls `show_entity` → same pattern in view mode. +- New lorebook auto-created → Entity tab appears (no pulse for auto-approved items is fine, but current behavior of auto-opening the new lorebook into editor is preserved). +- Generate image → tap "Set Portrait" → switches to Entity tab, portrait applied. +- Approve All from top bar works from either tab. +- Open keyboard on Chat tab → input stays visible, no viewport jump. Switch to Entity tab while keyboard open → keyboard dismisses. + +## Risks & mitigations + +- **Vaul-svelte drawer removal on compact** — we're not modifying `ResponsiveModal`, just bypassing it for this component on compact. Other call-sites unaffected. +- **Tab pulse feels spammy during streaming** — pulse is gated to "count increased while on chat tab" and runs at most once per change. If multi-change bursts feel noisy, we can throttle in a follow-up. +- **`h-[100dvh]` on older browsers** — `dvh` has solid 2023+ support; Tailwind v3 emits it directly. Acceptable for target audience. +- **Existing `Sheet.Root` import removal** — unused after refactor, will be pruned. From d3f20deca66451a4e45f3d3dd9cf4bb62a1fcf11 Mon Sep 17 00:00:00 2001 From: Failerko Date: Sat, 18 Apr 2026 17:05:09 +0200 Subject: [PATCH 02/36] docs: clarify Entity tab fallback when active tab disappears Spec self-review caught a missing behavior: if the editor closes while the Entity tab is active, auto-switch back to Chat tab. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-04-18-vault-assistant-mobile-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-04-18-vault-assistant-mobile-design.md b/docs/superpowers/specs/2026-04-18-vault-assistant-mobile-design.md index fd4cb33c..e0c29a7b 100644 --- a/docs/superpowers/specs/2026-04-18-vault-assistant-mobile-design.md +++ b/docs/superpowers/specs/2026-04-18-vault-assistant-mobile-design.md @@ -74,7 +74,7 @@ Branching happens at the `InteractiveVaultAssistant.svelte` level — it renders - **Segmented control** directly below the existing dropdowns (history selector and pending-list popover stay where they are). - **Entity tab visibility:** shown only when `vaultEditor.editorOpen && vaultEditor.activeChange` is truthy. When no active change exists, the tab bar is hidden entirely and the chat fills the viewport as today. -- **Default tab:** `chat`. Resets to `chat` on new conversation and on conversation switch. +- **Default tab:** `chat`. Resets to `chat` on new conversation, on conversation switch, and whenever the Entity tab disappears while active (e.g. user closed the editor from inside the panel, or the last pending change was approved/rejected and the editor closed) — a `$effect` watching `vaultEditor.editorOpen && vaultEditor.activeChange` flips `activeTab` back to `'chat'` when it goes falsy. - **Badge:** shows `vaultEditor.pendingCount` when >0. `0` → no badge. - **Pulse:** a one-shot Tailwind keyframe highlight on the Entity tab trigger for ~800ms, fired from an `$effect` watching `vaultEditor.pendingCount`. Only pulses when the increase happened while `activeTab === 'chat'`. - **Tab content:** From 2cefbabaa71e95e640a49153ba52fdcb055bd4d2 Mon Sep 17 00:00:00 2001 From: Failerko Date: Sat, 18 Apr 2026 17:13:31 +0200 Subject: [PATCH 03/36] docs: add implementation plan for vault assistant mobile parity 10 bite-sized tasks covering hook creation, layout refactor, tab architecture, auto-open guard removal, pulse animation, narrow-desktop flex fix, and manual smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-18-vault-assistant-mobile.md | 1120 +++++++++++++++++ 1 file changed, 1120 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-18-vault-assistant-mobile.md diff --git a/docs/superpowers/plans/2026-04-18-vault-assistant-mobile.md b/docs/superpowers/plans/2026-04-18-vault-assistant-mobile.md new file mode 100644 index 00000000..3dbec17c --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-vault-assistant-mobile.md @@ -0,0 +1,1120 @@ +# Vault Assistant Mobile & Narrow-Width Parity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the `InteractiveVaultAssistant` component mobile feature parity with desktop (via a tabbed layout) and make it stretch edge-to-edge on Fold-inner-class screens and narrow desktop windows. + +**Architecture:** Introduce a 1024px `isCompact` breakpoint used only by this component. At that width and below, render a full-screen overlay (not a drawer) with a Chat / Entity tab bar that surfaces all the auto-open behaviors desktop users already get — without hijacking the chat stream. Above 1024px, keep the two-panel desktop layout but let both panels shrink gracefully via flex minimums so the editor doesn't get cramped on narrow desktops. + +**Tech Stack:** Svelte 5 (`$state`, `$derived`, `$effect`), Tailwind, `vaul-svelte` (drawer primitive, left in place for other call-sites), `bits-ui` (Dialog primitive). + +**Spec:** `docs/superpowers/specs/2026-04-18-vault-assistant-mobile-design.md` + +--- + +## File Structure + +**New:** +- `src/lib/hooks/is-compact.svelte.ts` — `createIsCompact()` hook mirroring `createIsMobile` at a 1024px breakpoint. + +**Modified:** +- `src/lib/components/vault/InteractiveVaultAssistant.svelte` — all UI changes happen here. No store, service, or shared-primitive changes. + +The plan splits the component changes into small, independently-committable tasks. Between tasks the app stays functional; the compact layout may look rougher than final until Task 4 lands. + +## Verification conventions + +- After every code change, run `npm run check` (svelte-check / TypeScript) and `npm run lint` (eslint). Both must pass before committing. +- Manual smoke test happens in Task 10 only. Between tasks, type-check + lint are the gates. +- Commit messages follow existing repo convention (imperative mood, lowercase type prefix). + +--- + +## Task 1: Create the `createIsCompact` hook + +**Files:** +- Create: `src/lib/hooks/is-compact.svelte.ts` + +- [ ] **Step 1: Create the hook file** + +File path: `src/lib/hooks/is-compact.svelte.ts` + +```ts +import { onMount } from 'svelte' + +/** + * Layout breakpoint hook for components that need a tighter "compact" threshold + * than the 768px `createIsMobile` gives. Fires true below 1024px (Tailwind lg). + */ +export function createIsCompact() { + let isCompact = $state(false) + + onMount(() => { + const mql = window.matchMedia('(max-width: 1023px)') + + const onChange = () => { + isCompact = mql.matches + } + + mql.addEventListener('change', onChange) + isCompact = mql.matches + + return () => mql.removeEventListener('change', onChange) + }) + + return { + get current() { + return isCompact + }, + } +} +``` + +- [ ] **Step 2: Verify it type-checks** + +Run: `npm run check` +Expected: no new errors relating to `is-compact.svelte.ts`. + +- [ ] **Step 3: Verify it lints** + +Run: `npm run lint` +Expected: no new lint errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/hooks/is-compact.svelte.ts +git commit -m "feat: add createIsCompact hook at 1024px breakpoint" +``` + +--- + +## Task 2: Swap `isMobile` → `isCompact` for layout decisions + +**Goal:** Widen the existing "mobile" layout path from 768px to 1024px without behavior guards changing yet. This is a mechanical rename touching only layout sites. The onMount / tool_end / show_entity guards stay as `!isMobile.current` until Task 5. + +**Files:** +- Modify: `src/lib/components/vault/InteractiveVaultAssistant.svelte` + +- [ ] **Step 1: Import the new hook alongside the existing one** + +Replace the import on line 54: + +```ts +import { createIsMobile } from '$lib/hooks/is-mobile.svelte' +``` + +with: + +```ts +import { createIsMobile } from '$lib/hooks/is-mobile.svelte' +import { createIsCompact } from '$lib/hooks/is-compact.svelte' +``` + +- [ ] **Step 2: Instantiate `isCompact` next to `isMobile`** + +Replace the block at lines 64–65: + +```ts + // Mobile detection + const isMobile = createIsMobile() +``` + +with: + +```ts + // Mobile detection (touch/native-device flavour + 768px breakpoint — kept for non-layout guards) + const isMobile = createIsMobile() + // Layout breakpoint: below 1024px we use the compact (tabs, full-screen) layout + const isCompact = createIsCompact() +``` + +- [ ] **Step 3: Replace layout-related `isMobile.current` usages with `isCompact.current`** + +These are the ONLY layout sites to change in this task. Guards in auto-open handlers stay on `isMobile` for now. + +Line 549 — modal container width: + +```svelte +vaultEditor.editorOpen && !isMobile.current ? 'max-w-[90vw]' : 'max-w-2xl', +``` + +→ + +```svelte +vaultEditor.editorOpen && !isCompact.current ? 'max-w-[90vw]' : 'max-w-2xl', +``` + +Line 598 — desktop edit panel render: + +```svelte +{#if vaultEditor.editorOpen && vaultEditor.activeChange && !isMobile.current} +``` + +→ + +```svelte +{#if vaultEditor.editorOpen && vaultEditor.activeChange && !isCompact.current} +``` + +Line 616 — chat panel sizing class expression: + +```svelte +
+``` + +→ + +```svelte +
+``` + +Line 1093 — mobile Sheet guard: + +```svelte +{#if isMobile.current && vaultEditor.activeChange} +``` + +→ + +```svelte +{#if isCompact.current && vaultEditor.activeChange} +``` + +Leave lines 145, 389, 396, and 421 alone (they stay on `!isMobile.current` for this task). + +- [ ] **Step 4: Type-check and lint** + +Run: + +```bash +npm run check +npm run lint +``` + +Expected: no new errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/components/vault/InteractiveVaultAssistant.svelte +git commit -m "refactor: use isCompact for vault assistant layout decisions" +``` + +--- + +## Task 3: Replace compact shell with full-screen Dialog + +**Goal:** On compact widths, render a full-viewport `Dialog` instead of the `ResponsiveModal` drawer. Eliminates the `max-w-2xl` clip on Fold-inner screens and the `h-[90vh]`/`max-h-[85vh]` height conflict. Wide path untouched. + +**Files:** +- Modify: `src/lib/components/vault/InteractiveVaultAssistant.svelte` + +- [ ] **Step 1: Add a shell wrapper around the existing content** + +Find the current top-level render (starts at line 545): + +```svelte + !open && onClose()}> + +
+``` + +Replace with: + +```svelte +{#if isCompact.current} + !open && onClose()}> + + Vault Assistant +
+{:else} + !open && onClose()}> + +
+{/if} +``` + +Notes: +- The `!isCompact.current` check in the `max-w-[90vw]` class is now redundant because we're only inside the ResponsiveModal branch when `!isCompact.current`; I've simplified it to `vaultEditor.editorOpen`. +- `Dialog.Title class="sr-only"` is required by bits-ui's Dialog for accessibility; it hides a title from visual users. + +- [ ] **Step 2: Add matching closing tags** + +Find the current closing (line 1089–1090): + +```svelte +
+
+
+``` + +Replace with: + +```svelte +
+ {#if isCompact.current} +
+
+ {:else} + + + {/if} +``` + +Note: Svelte's indentation inside `{#if}` doesn't affect rendering; the intent is that the `
` closes the inner flex container and the if/else closes the correct shell. + +*Tip:* if the `{#if}` / `{:else}` pair placement is awkward to read, it is acceptable to split into two full copies of the shell (each wrapping `{@render ...}` or the same markup). Either form is fine — pick the one you find clearer. Keep whichever you pick consistent with the opening tag placement from Step 1. + +- [ ] **Step 3: Type-check and lint** + +Run: + +```bash +npm run check +npm run lint +``` + +Expected: no new errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/components/vault/InteractiveVaultAssistant.svelte +git commit -m "feat: render vault assistant as full-screen dialog on compact" +``` + +--- + +## Task 4: Tab architecture and unified edit panel mounting + +**Goal:** Add the Chat/Entity tab bar, move the compact-width edit panel out of the bottom `Sheet.Root` and into the Entity tab body, collapse the two edit-panel refs into one, and add an effect that auto-switches the tab back to Chat when the Entity tab disappears. + +**Files:** +- Modify: `src/lib/components/vault/InteractiveVaultAssistant.svelte` + +- [ ] **Step 1: Add tab state and the auto-switch-to-chat effect** + +After the existing `let viewedEntity = $state(null)` line (currently around line 100) add: + +```ts + // Compact-width tab state + let activeTab = $state<'chat' | 'entity'>('chat') + + // Auto-fall-back to chat tab when the Entity tab loses its content + // (e.g. user closed the editor, approved/rejected the last pending change, + // or conversation switched away from an active change). + $effect(() => { + const entityTabAvailable = + vaultEditor.editorOpen && vaultEditor.activeChange !== null + if (!entityTabAvailable && activeTab === 'entity') { + activeTab = 'chat' + } + }) +``` + +- [ ] **Step 2: Reset tab to chat on new / switched conversation** + +In `handleNewConversation` (currently around line 222): + +```ts + async function handleNewConversation() { + if (!service) return + // Auto-save current conversation before starting new one + if (messages.some((m) => !m.isGreeting)) { + await service.saveConversation(messages, vaultEditor.pendingChanges).catch(() => {}) + } + service.reset() + vaultEditor.reset() + initializeService() + await loadConversationsList() + } +``` + +Insert `activeTab = 'chat'` right after `vaultEditor.reset()`: + +```ts + service.reset() + vaultEditor.reset() + activeTab = 'chat' + initializeService() + await loadConversationsList() +``` + +In `handleSwitchConversation` (currently around line 234), similarly add `activeTab = 'chat'` after the existing `vaultEditor.reset()` call: + +```ts + const loaded = await service.loadConversation(id) + if (loaded) { + vaultEditor.reset() + activeTab = 'chat' +``` + +In `handleDeleteConversation` (around line 272), add the same after the `vaultEditor.reset()`: + +```ts + await database.deleteVaultConversation(id) + if (service?.getConversationId() === id) { + service.reset() + vaultEditor.reset() + activeTab = 'chat' + initializeService() + } +``` + +- [ ] **Step 3: Collapse `editPanelRef` and `editPanelMobileRef` into a single ref** + +Remove the two ref declarations (currently around lines 291–292): + +```ts + let editPanelRef = $state | null>(null) + let editPanelMobileRef = $state | null>(null) +``` + +Replace with a single ref: + +```ts + let editPanelRef = $state | null>(null) +``` + +Update `handleSetPortrait` (currently around line 294) to use the single ref; the tab-switch behavior is added in Task 7, so for this task the body stays minimal: + +```ts + function handleSetPortrait(imageId: string) { + if (!activeCharacterEntity || !service) return + const dataUrl = service.generatedImages.get(imageId) + if (!dataUrl) return + editPanelRef?.setPortrait(dataUrl) + } +``` + +- [ ] **Step 4: Render the tab bar inside the chat-side column** + +Find the chat-side column opening (currently around line 614): + +```svelte + +
+ +``` + +Right before the `` comment, insert the tab bar. But keep the conversation selector and pending-list popovers above the tabs — so actually insert the tab bar BELOW the pending-list popover block (currently around line 813, right after the `{#if pendingOnly.length > 0} ... {/if}` closes): + +```svelte + + {#if isCompact.current && vaultEditor.editorOpen && vaultEditor.activeChange} +
+ + +
+ {/if} +``` + +- [ ] **Step 5: Gate the messages/input body on active tab + render Entity tab body** + +The chat content (currently lines 815–1085 — messages container + error + input) needs to be wrapped so it only renders when the Chat tab is active on compact (or always on wide). + +Wrap the existing content with a conditional. Find the start of the messages div (around line 816): + +```svelte + +
+``` + +Immediately before this comment, open a new conditional wrapper: + +```svelte + {#if !isCompact.current || activeTab === 'chat'} + +
+``` + +Then find the input-area closing (around line 1085–1086): + +```svelte + +
+
+``` + +Close the wrapper right after the input area's outer `
` (the one that closes the input-area padding container, NOT the chat-side column div). Add an `{:else}` branch for the Entity tab body: + +```svelte + +
+ {:else} + + {#if vaultEditor.activeChange} +
+ + handleApprove(specificChange ?? vaultEditor.activeChange!)} + onReject={(change) => handleReject(change)} + onClose={() => vaultEditor.closeEditor()} + /> +
+ {/if} + {/if} +
+``` + +- [ ] **Step 6: Remove the standalone `Sheet.Root` compact edit panel** + +Find the block starting around line 1092: + +```svelte + +{#if isCompact.current && vaultEditor.activeChange} + { + if (!open) vaultEditor.closeEditor() + }} + > + + {#if vaultEditor.activeChange} + handleApprove(specificChange ?? vaultEditor.activeChange!)} + onReject={(change) => handleReject(change)} + onClose={() => vaultEditor.closeEditor()} + /> + {/if} + + +{/if} +``` + +Delete the entire block (including the surrounding comment). + +- [ ] **Step 7: Remove the now-unused `Sheet` import** + +Find the imports block (lines 47): + +```ts + import * as Sheet from '$lib/components/ui/sheet' +``` + +Delete this line. + +- [ ] **Step 8: Type-check and lint** + +Run: + +```bash +npm run check +npm run lint +``` + +Expected: no new errors. If lint complains about `editPanelMobileRef` being unused, double-check that Step 3 replaced both the declaration and all `editPanelMobileRef` usages. + +- [ ] **Step 9: Commit** + +```bash +git add src/lib/components/vault/InteractiveVaultAssistant.svelte +git commit -m "feat: tab-based compact layout for vault assistant" +``` + +--- + +## Task 5: Remove `!isMobile.current` auto-open guards + +**Goal:** Now that compact has a place to show the entity panel (Entity tab), the guards that skipped auto-opening on mobile can go. State updates run on every width; the tab system handles whether they're visually surfaced. + +**Files:** +- Modify: `src/lib/components/vault/InteractiveVaultAssistant.svelte` + +- [ ] **Step 1: Remove the onMount focused-entity guard** + +Find the block at lines 145–169: + +```ts + // Auto-open focused entity if provided + if (focusedEntity && !isMobile.current) { + let entityData: any = null + if (focusedEntity.entityType === 'character') { + entityData = characterVault.getById(focusedEntity.entityId) + } else if (focusedEntity.entityType === 'lorebook') { + entityData = lorebookVault.getById(focusedEntity.entityId) + } else if (focusedEntity.entityType === 'scenario') { + entityData = scenarioVault.getById(focusedEntity.entityId) + } + + if (entityData) { + // Construct a dummy change to satisfy the viewer store requirement + const dummyChange = { + id: `view-${focusedEntity.entityId}`, + toolCallId: 'init', + entityType: focusedEntity.entityType, + action: 'update', + status: 'pending', + entityId: focusedEntity.entityId, + data: JSON.parse(JSON.stringify(entityData)), + } as unknown as VaultPendingChange + + vaultEditor.openViewer(dummyChange, focusedEntity.entityId, focusedEntity.entityType) + } + } +``` + +Replace `focusedEntity && !isMobile.current` with just `focusedEntity`: + +```ts + // Auto-open focused entity if provided + if (focusedEntity) { +``` + +(Leave the rest of the block unchanged.) + +- [ ] **Step 2: Remove the tool_end auto-created-lorebook guard** + +Find the block at lines 384–399 (inside the `tool_end` case): + +```ts + // Auto-approve lorebook creation (it's a prerequisite step) + if (incoming.entityType === 'lorebook' && incoming.action === 'create' && service) { + vaultEditor.addPendingChange(incoming) + await handleApprove(incoming) + // Open the newly created lorebook in the editor + if (!isMobile.current) { + await tick() + vaultEditor.openEditor(incoming) + } + } else { + vaultEditor.addPendingChange(incoming) + // Auto-open entity editor on desktop (store handles same-lorebook skip) + if (!isMobile.current) { + vaultEditor.openEditorSmart(incoming) + } + } +``` + +Replace with (guards removed): + +```ts + // Auto-approve lorebook creation (it's a prerequisite step) + if (incoming.entityType === 'lorebook' && incoming.action === 'create' && service) { + vaultEditor.addPendingChange(incoming) + await handleApprove(incoming) + // Open the newly created lorebook in the editor + await tick() + vaultEditor.openEditor(incoming) + } else { + vaultEditor.addPendingChange(incoming) + // Auto-open entity editor (store handles same-lorebook skip). + // On compact, the Entity tab becomes available — user still has to tap to switch. + vaultEditor.openEditorSmart(incoming) + } +``` + +- [ ] **Step 3: Remove the show_entity viewer guard** + +Find the block at lines 419–434 (inside the `show_entity` case): + +```ts + case 'show_entity': + // Open entity in view mode (no approval workflow) + if (!isMobile.current) { + vaultEditor.openViewer(event.change, event.entityId, event.entityType) + } + // Track which character is currently being viewed so the Set Portrait button appears + if (event.entityType === 'character') { +``` + +Replace with: + +```ts + case 'show_entity': + // Open entity in view mode (no approval workflow) + vaultEditor.openViewer(event.change, event.entityId, event.entityType) + // Track which character is currently being viewed so the Set Portrait button appears + if (event.entityType === 'character') { +``` + +- [ ] **Step 4: Remove the now-unused `isMobile` hook** + +Confirm no remaining `isMobile.current` references exist: + +Run (via Grep tool, not shell): search pattern `isMobile\.current` in `src/lib/components/vault/InteractiveVaultAssistant.svelte`. +Expected: zero matches. + +If zero matches, remove the instantiation (currently around line 64): + +```ts + // Mobile detection (touch/native-device flavour + 768px breakpoint — kept for non-layout guards) + const isMobile = createIsMobile() +``` + +Delete these two lines. + +Also remove the import (around line 54): + +```ts + import { createIsMobile } from '$lib/hooks/is-mobile.svelte' +``` + +Delete it. + +Update the comment on the remaining `isCompact` block so the context is still clear: + +```ts + // Layout breakpoint: below 1024px we use the compact (tabs, full-screen) layout + const isCompact = createIsCompact() +``` + +- [ ] **Step 5: Type-check and lint** + +Run: + +```bash +npm run check +npm run lint +``` + +Expected: no new errors. If the lint catches an unused import that Step 4 missed, delete it. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/components/vault/InteractiveVaultAssistant.svelte +git commit -m "feat: remove mobile guards so auto-open state runs on all widths" +``` + +--- + +## Task 6: Entity tab pulse animation on new pending changes + +**Goal:** When a new pending change lands while the user is viewing the Chat tab, the Entity tab pulses once (~800ms) so the user notices without being yanked to a new screen. + +**Files:** +- Modify: `src/lib/components/vault/InteractiveVaultAssistant.svelte` + +- [ ] **Step 1: Add pulse state and count-tracker effect** + +Just below the `activeTab` declaration added in Task 4 Step 1, add: + +```ts + // Pulse the Entity tab when a new pending change arrives while user is on Chat + let entityTabPulsing = $state(false) + let prevPendingCount = vaultEditor.pendingCount + $effect(() => { + const current = vaultEditor.pendingCount + if (current > prevPendingCount && activeTab === 'chat') { + entityTabPulsing = true + const timer = setTimeout(() => { + entityTabPulsing = false + }, 800) + prevPendingCount = current + return () => clearTimeout(timer) + } + prevPendingCount = current + }) +``` + +- [ ] **Step 2: Apply the pulse class to the Entity tab button** + +Find the Entity tab button added in Task 4 Step 4. Update its `class={cn(...)}` expression to include a `vault-tab-pulse` class when `entityTabPulsing` is true: + +```svelte + -
-
- -
-

Vault Assistant

+{#snippet assistantContent()} +
+ +
+
+ +
+
+
+

Vault Assistant

- {#if vaultEditor.pendingCount > 0} -
- -
- {/if}
- - -
- - {#if vaultEditor.editorOpen && vaultEditor.activeChange && !isCompact.current} -
0} +
+
- {/if} + + Approve All + + {vaultEditor.pendingBreakdown} + + +
+ {/if} +
- + +
+ + {#if vaultEditor.editorOpen && vaultEditor.activeChange && !isCompact.current}
- -
+ + handleApprove(specificChange ?? vaultEditor.activeChange!)} + onReject={(change) => handleReject(change)} + onClose={() => vaultEditor.closeEditor()} + /> +
+ {/if} + + +
+ +
+ + {#if conversationSelectorOpen} + + + +
(conversationSelectorOpen = false)} + transition:fade={{ duration: 100 }} + >
+ +
+
+ + + + {#if conversations.length > 0} +
+ {#each conversations as conv, i (conv.id)} +
+ + +
+ {/each} + {/if} +
+
+ {/if} +
+ + + {#if pendingOnly.length > 0} +
- {#if conversationSelectorOpen} + {#if pendingListOpen}
(conversationSelectorOpen = false)} + onclick={() => (pendingListOpen = false)} transition:fade={{ duration: 100 }} >
@@ -653,382 +746,219 @@ transition:slide={{ duration: 150 }} >
- - - - {#if conversations.length > 0} -
- {#each conversations as conv, i (conv.id)} + +
+ +
+ +
+
+ {getChangeName(change)} +
+
+ + {aStyle.label} + + + {change.entityType === 'lorebook-entry' ? 'entry' : change.entityType} + +
+
+ + +
e.stopPropagation()} >
- {/each} - {/if} +
+ {/each}
{/if}
+ {/if} - - {#if pendingOnly.length > 0} -
- - {#if pendingListOpen} - - -
(pendingListOpen = false)} - transition:fade={{ duration: 100 }} - >
- -
-
- {#each pendingOnly as change (change.id)} - {@const Icon = entityIcons[change.entityType]} - {@const eStyle = entityStyles[change.entityType]} - {@const aStyle = actionStyles[change.action]} -
handleEdit(change)} - onkeydown={(e) => e.key === 'Enter' && handleEdit(change)} - > - + +
+ +
+ {#if message.role === 'assistant'}
- -
- -
-
- {getChangeName(change)} -
-
- - {aStyle.label} - - - {change.entityType === 'lorebook-entry' ? 'entry' : change.entityType} - -
+
- - - + {:else}
e.stopPropagation()} + class="mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md bg-white/10" > - - + +
+ {/if} +
+
+ {@html parseMarkdown(message.content)}
- {/each} -
-
- {/if} -
- {/if} +
- -
- {#each messages as message (message.id)} -
-
-
- -
- -
- {#if message.role === 'assistant'} -
- -
- {:else} + + {#if message.role === 'assistant' && message.reasoning} +
+ + {#if expandedReasoning.has(message.id)}
- + {message.reasoning}
{/if} -
-
- {@html parseMarkdown(message.content)} -
-
+ {/if} +
- - {#if message.role === 'assistant' && message.reasoning} -
- - {#if expandedReasoning.has(message.id)} -
{formatToolCallName(toolCall.name)} - {message.reasoning} -
- {/if} -
- {/if} -
- - - {#if message.toolCalls && message.toolCalls.length > 0} -
- {#each message.toolCalls as toolCall (toolCall.id)} - {#if !IMAGE_TOOL_NAMES.has(toolCall.name) || toolCall.imageUrl} -
+ {/if} + {#if toolCall.imageUrl} +
+
- {/if} - {#if toolCall.imageUrl} -
+ AI generated result + + {#if toolCall.imageId} - {#if toolCall.imageId} + {#if activeCharacterEntity} - {#if activeCharacterEntity} - - {/if} {/if} -
- {/if} - {/each} -
- {/if} - - -
- {new Date(message.timestamp).toLocaleTimeString()} -
-
-
- - - {#if message.pendingChanges && message.pendingChanges.length > 0} -
- {#each message.pendingChanges as change (change.id)} - {@const liveChange = vaultEditor.getLiveChange(change.id) ?? change} - handleApprove(liveChange)} - onReject={() => handleReject(liveChange)} - onEdit={() => handleEdit(liveChange)} - /> - {/each} -
- {/if} -
- {/each} - - - {#if isGenerating} -
-
-
-
-
- -
-
- {#if activeToolCalls.length > 0} -
- {#each activeToolCalls as toolCall (toolCall.id)} -
- {#if toolCall.result === '...'} - - {:else} - - {/if} - {formatToolCallName(toolCall.name)} -
- {/each} -
- {:else if isThinking} -
- - Thinking... + {/if}
{/if} -
+ {/each}
+ {/if} + + +
+ {new Date(message.timestamp).toLocaleTimeString()}
- - {#if streamingChanges.length > 0} + + {#if message.pendingChanges && message.pendingChanges.length > 0}
- {#each streamingChanges as change (change.id)} + {#each message.pendingChanges as change (change.id)} {@const liveChange = vaultEditor.getLiveChange(change.id) ?? change} {/if} - {/if} -
- - - {#if error} -
-
- - {error} +
+ {/each} + + + {#if isGenerating} +
+
+
+
+
+ +
+
+ {#if activeToolCalls.length > 0} +
+ {#each activeToolCalls as toolCall (toolCall.id)} +
+ {#if toolCall.result === '...'} + + {:else} + + {/if} + {formatToolCallName(toolCall.name)} +
+ {/each} +
+ {:else if isThinking} +
+ + Thinking... +
+ {/if} +
+
+
+ + + {#if streamingChanges.length > 0} +
+ {#each streamingChanges as change (change.id)} + {@const liveChange = vaultEditor.getLiveChange(change.id) ?? change} + handleApprove(liveChange)} + onReject={() => handleReject(liveChange)} + onEdit={() => handleEdit(liveChange)} + /> + {/each} +
+ {/if} {/if} +
- -
-
-