diff --git a/.gitignore b/.gitignore index 5093b58e..79c2c87b 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,6 @@ main.js # Local research artifacts — internal, not for publication /competitor-analysis.html -/smart-second-brain-features.html \ No newline at end of file +/smart-second-brain-features.html +# SDD scratch workspace +.superpowers/ diff --git a/docs/superpowers/plans/2026-09-27-s2b-session-sidebar.md b/docs/superpowers/plans/2026-09-27-s2b-session-sidebar.md new file mode 100644 index 00000000..4a286d29 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-s2b-session-sidebar.md @@ -0,0 +1,952 @@ +# S2B Session Sidebar 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:** Add an in-leaf, resizable session-list sidebar to the S2B chat view — near 1:1 with the Claudian plugin — listing all `.chat` sessions with Pinned/Archived sections, search, sort, and per-row actions (open, rename, delete, pin, archive, restore). + +**Architecture:** The sidebar renders inside `Chat.svelte` as a flex-row sibling of the existing chat column (`[sidebar][resizer][chat]`, order flipped by a setting), revealed at container width ≥600px with a dropdown fallback below. Session list data comes from the existing `AgentManager.getAllThreads()` (returns `ThreadSnapshot[]`, title from filename, sorted newest-first). Pin/archive flags live in **plugin data** (`sessionFlags` map) — NOT in the `.chat` codec — because the thread index never loads per-file metadata; the sidebar store merges snapshots with flags. A reactive `SessionSidebarStore` re-reads the list on `.chat` create/delete/rename events (debounced). + +**Tech Stack:** Svelte 5 (runes: `$state`/`$derived`/`$props`, `mount()`), TypeScript, Obsidian API, Tailwind + `src/styles.css`, Vitest (jsdom, `obsidian` mocked), Biome, svelte-check. + +**Spec:** `docs/superpowers/specs/2026-09-27-s2b-session-sidebar-design.md` + +## Global Constraints + +- Base branch: `feat/session-sidebar` off `Direct-Launch/smart-second-brain` `main`. PR target: fork `main`. +- No `THREAD_DATA_VERSION` bump; do not modify `threadDataCodec.ts` serialization. +- Reuse existing `AgentManager` methods for open/new/rename/delete — do not reimplement thread CRUD. +- Settings are added via: field on `PluginData` (`src/types/plugin.ts`) + default in `DEFAULT_SETTINGS` (`src/stores/dataStore.svelte.ts`) + getter/setter pair (setter calls `saveSettings()`) + a `SettingItem` control in a settings `.svelte`. +- `getData()` (from `src/stores/dataStore.svelte`) returns the settings singleton. `getPlugin()` (from `src/stores/state.svelte`) returns the plugin. `getSessionRegistry()` (from `src/stores/chatStore.svelte`) returns `SessionRegistry | null` — always null-check. +- Tests: `vitest run`; mock via `vi.mock("obsidian", () => import("../__mocks__/obsidian"))`; tests live under `test/` mirroring `src/`. Must also pass `npm run check` (svelte-check) and `npm run lint` (biome). +- CSS classes prefixed `s2b-session-`, using Obsidian theme vars (`--background-modifier-hover`, `--interactive-accent`, `--text-*`, `--color-red`). + +--- + +### Task 1: Session flags storage (plugin data) + +Pin/archive flags, keyed by thread path, persisted in plugin data. Whole-list read is O(1); no codec touch. + +**Files:** +- Modify: `src/types/plugin.ts` (add `SessionFlags` type + `sessionFlags` field to `PluginData`) +- Modify: `src/stores/dataStore.svelte.ts` (default + getter + mutation helpers) +- Test: `test/stores/sessionFlags.test.ts` + +**Interfaces:** +- Produces: + - `interface SessionFlags { pinned?: boolean; pinnedAt?: number; archived?: boolean }` + - `PluginData.sessionFlags: Record` + - On the data store class: `getSessionFlags(path: string): SessionFlags` (returns `{}` if absent), `setSessionPinned(path: string, pinned: boolean): void`, `setSessionArchived(path: string, archived: boolean): void`, `renameSessionFlags(oldPath: string, newPath: string): void`, `removeSessionFlags(path: string): void`. Each mutation calls `saveSettings()`. + +- [ ] **Step 1: Write failing test** — `test/stores/sessionFlags.test.ts` + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +vi.mock("obsidian", () => import("../__mocks__/obsidian")); + +// Minimal harness: exercise flag helpers against a plain data object. +import { DataStore } from "../../src/stores/dataStore.svelte"; + +function makeStore() { + const plugin = { saveData: vi.fn().mockResolvedValue(undefined) } as any; + const store = new DataStore(plugin); + return { store, plugin }; +} + +describe("sessionFlags", () => { + let store: DataStore; + let plugin: any; + beforeEach(() => { ({ store, plugin } = makeStore()); }); + + it("defaults to empty flags", () => { + expect(store.getSessionFlags("Chats/a.chat")).toEqual({}); + }); + it("sets pinned with pinnedAt and persists", () => { + store.setSessionPinned("Chats/a.chat", true); + const f = store.getSessionFlags("Chats/a.chat"); + expect(f.pinned).toBe(true); + expect(typeof f.pinnedAt).toBe("number"); + expect(plugin.saveData).toHaveBeenCalled(); + }); + it("unpin clears pinnedAt", () => { + store.setSessionPinned("Chats/a.chat", true); + store.setSessionPinned("Chats/a.chat", false); + expect(store.getSessionFlags("Chats/a.chat").pinned).toBe(false); + }); + it("archive toggles independently of pin", () => { + store.setSessionPinned("Chats/a.chat", true); + store.setSessionArchived("Chats/a.chat", true); + const f = store.getSessionFlags("Chats/a.chat"); + expect(f.pinned).toBe(true); + expect(f.archived).toBe(true); + }); + it("rename migrates the key", () => { + store.setSessionPinned("Chats/a.chat", true); + store.renameSessionFlags("Chats/a.chat", "Chats/b.chat"); + expect(store.getSessionFlags("Chats/a.chat")).toEqual({}); + expect(store.getSessionFlags("Chats/b.chat").pinned).toBe(true); + }); + it("remove deletes the key", () => { + store.setSessionArchived("Chats/a.chat", true); + store.removeSessionFlags("Chats/a.chat"); + expect(store.getSessionFlags("Chats/a.chat")).toEqual({}); + }); +}); +``` + +> Note: confirm the exported class name of the data store (the extraction showed `getData()` returns a singleton; find the class — likely `DataStore`/`PluginDataStore`). If it is not directly newable in a test, test the helpers via the singleton `getData()` after `vi.mock`ing `saveData`. Adjust imports to the real class name; do not invent one. + +- [ ] **Step 2: Run test, verify it fails** + +Run: `npx vitest run test/stores/sessionFlags.test.ts` +Expected: FAIL (`getSessionFlags` not a function / `sessionFlags` undefined). + +- [ ] **Step 3: Add the type** — `src/types/plugin.ts` + +```ts +export interface SessionFlags { + pinned?: boolean; + pinnedAt?: number; + archived?: boolean; +} +``` +Add to the `PluginData` interface (near `chatOpenLocation`): +```ts + sessionFlags: Record; +``` + +- [ ] **Step 4: Default + helpers** — `src/stores/dataStore.svelte.ts` + +Add to `DEFAULT_SETTINGS`: +```ts + sessionFlags: {}, +``` +Add methods on the data store class (mirror the existing getter/setter style; `this.#data` is the backing object, `this.saveSettings()` persists): +```ts + getSessionFlags(path: string): SessionFlags { + return this.#data.sessionFlags?.[path] ?? {}; + } + setSessionPinned(path: string, pinned: boolean): void { + if (!this.#data.sessionFlags) this.#data.sessionFlags = {}; + const cur = this.#data.sessionFlags[path] ?? {}; + this.#data.sessionFlags[path] = pinned + ? { ...cur, pinned: true, pinnedAt: cur.pinnedAt ?? Date.now() } + : { ...cur, pinned: false, pinnedAt: undefined }; + void this.saveSettings(); + } + setSessionArchived(path: string, archived: boolean): void { + if (!this.#data.sessionFlags) this.#data.sessionFlags = {}; + const cur = this.#data.sessionFlags[path] ?? {}; + this.#data.sessionFlags[path] = { ...cur, archived }; + void this.saveSettings(); + } + renameSessionFlags(oldPath: string, newPath: string): void { + const flags = this.#data.sessionFlags?.[oldPath]; + if (!flags) return; + if (!this.#data.sessionFlags) this.#data.sessionFlags = {}; + this.#data.sessionFlags[newPath] = flags; + delete this.#data.sessionFlags[oldPath]; + void this.saveSettings(); + } + removeSessionFlags(path: string): void { + if (this.#data.sessionFlags?.[path]) { + delete this.#data.sessionFlags[path]; + void this.saveSettings(); + } + } +``` + +- [ ] **Step 5: Run test, verify pass** + +Run: `npx vitest run test/stores/sessionFlags.test.ts` → PASS. Then `npm run check`. + +- [ ] **Step 6: Commit** + +```bash +git add src/types/plugin.ts src/stores/dataStore.svelte.ts test/stores/sessionFlags.test.ts +git commit -m "feat(sessions): plugin-data-backed pin/archive flags" +``` + +--- + +### Task 2: AgentManager pin/archive with running-guard + +Public API the sidebar calls; archive refused while a session is running (Claudian parity). + +**Files:** +- Modify: `src/agent/AgentManager.ts` (add two methods) +- Test: `test/agent/sessionFlagsGuard.test.ts` + +**Interfaces:** +- Consumes: data store helpers from Task 1; `getSessionRegistry()` from `src/stores/chatStore.svelte`; `getData()` from `src/stores/dataStore.svelte`. +- Produces (on `AgentManager`): + - `setThreadPinned(threadId: string, pinned: boolean): void` + - `setThreadArchived(threadId: string, archived: boolean): boolean` — returns `false` (and shows a `Notice`) if archiving a running session; `true` on success. + +- [ ] **Step 1: Write failing test** — `test/agent/sessionFlagsGuard.test.ts` + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +vi.mock("obsidian", () => import("../__mocks__/obsidian")); + +const flags = { setSessionArchived: vi.fn(), setSessionPinned: vi.fn() }; +vi.mock("../../src/stores/dataStore.svelte", () => ({ + getData: () => flags, +})); +const registry = { sessionFor: vi.fn() }; +vi.mock("../../src/stores/chatStore.svelte", async (orig) => ({ + ...(await orig()), + getSessionRegistry: () => registry, +})); + +import { AgentManager } from "../../src/agent/AgentManager"; + +describe("setThreadArchived guard", () => { + let mgr: AgentManager; + beforeEach(() => { + vi.clearAllMocks(); + mgr = new AgentManager({} as any); // constructor only stores plugin ref + }); + it("archives when not running", () => { + registry.sessionFor.mockReturnValue({ isRunning: false }); + expect(mgr.setThreadArchived("Chats/a.chat", true)).toBe(true); + expect(flags.setSessionArchived).toHaveBeenCalledWith("Chats/a.chat", true); + }); + it("refuses to archive a running session", () => { + registry.sessionFor.mockReturnValue({ isRunning: true }); + expect(mgr.setThreadArchived("Chats/a.chat", true)).toBe(false); + expect(flags.setSessionArchived).not.toHaveBeenCalled(); + }); + it("restore (archived=false) is allowed while running", () => { + registry.sessionFor.mockReturnValue({ isRunning: true }); + expect(mgr.setThreadArchived("Chats/a.chat", false)).toBe(true); + expect(flags.setSessionArchived).toHaveBeenCalledWith("Chats/a.chat", false); + }); +}); +``` + +> If `new AgentManager({})` does heavy work in the constructor (it builds `ObsidianChatManager`), instead cast a minimal object with the methods under test, or extract the two methods to only touch `getData()`/`getSessionRegistry()` (they should). Keep the constructor untouched; adjust the test harness to the real constructor needs. + +- [ ] **Step 2: Run test, verify fails** — `npx vitest run test/agent/sessionFlagsGuard.test.ts` → FAIL (methods undefined). + +- [ ] **Step 3: Implement** — `src/agent/AgentManager.ts` + +Add imports if missing: `import { Notice } from "obsidian";`, `import { getSessionRegistry } from "../stores/chatStore.svelte";`, `import { getData } from "../stores/dataStore.svelte";`. Add methods to the class: +```ts + setThreadPinned(threadId: string, pinned: boolean): void { + getData().setSessionPinned(threadId, pinned); + } + + setThreadArchived(threadId: string, archived: boolean): boolean { + if (archived) { + const running = getSessionRegistry()?.sessionFor(threadId)?.isRunning; + if (running) { + new Notice("Cannot archive a running session"); + return false; + } + } + getData().setSessionArchived(threadId, archived); + return true; + } +``` + +- [ ] **Step 4: Run test, verify pass** — `npx vitest run test/agent/sessionFlagsGuard.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/agent/AgentManager.ts test/agent/sessionFlagsGuard.test.ts +git commit -m "feat(sessions): AgentManager pin/archive with running-guard" +``` + +--- + +### Task 3: Sidebar list logic (pure functions) + +Sort, filter, and section-partition — pure and fully tested. The store (Task 4) and view (Task 5) consume these. + +**Files:** +- Create: `src/stores/session-sidebar/logic.ts` +- Test: `test/stores/sessionSidebarLogic.test.ts` + +**Interfaces:** +- Consumes: `ThreadSnapshot` (`src/agent/memory/ThreadStore`), `SessionFlags` (`src/types/plugin`). +- Produces: +```ts +export type SortMode = "last-updated" | "created"; +export interface SessionRow { + threadId: string; + title: string; // snapshot.title ?? "New conversation" + createdAt: number; + updatedAt: number; + pinned: boolean; + pinnedAt: number; + archived: boolean; +} +export interface SessionSections { pinned: SessionRow[]; active: SessionRow[]; archived: SessionRow[] } + +export function toRows(snapshots: ThreadSnapshot[], flagsFor: (id: string) => SessionFlags): SessionRow[]; +export function sortRows(rows: SessionRow[], mode: SortMode): SessionRow[]; // desc; pinned section sorts by pinnedAt desc +export function filterRows(rows: SessionRow[], query: string): SessionRow[]; // case-insensitive title contains +export function partition(rows: SessionRow[]): SessionSections; // pinned(non-archived) / active(non-archived,non-pinned) / archived +``` + +- [ ] **Step 1: Write failing test** — `test/stores/sessionSidebarLogic.test.ts` + +```ts +import { describe, it, expect } from "vitest"; +import { toRows, sortRows, filterRows, partition } from "../../src/stores/session-sidebar/logic"; + +const snap = (id: string, c: number, u: number, title?: string) => ({ threadId: id, title, createdAt: c, updatedAt: u }); + +describe("session sidebar logic", () => { + it("toRows applies flags and title fallback", () => { + const rows = toRows([snap("a.chat", 1, 2)], () => ({ pinned: true, pinnedAt: 5 })); + expect(rows[0]).toMatchObject({ threadId: "a.chat", title: "New conversation", pinned: true, pinnedAt: 5, archived: false }); + }); + it("sortRows last-updated desc", () => { + const rows = toRows([snap("a", 1, 10), snap("b", 1, 20)], () => ({})); + expect(sortRows(rows, "last-updated").map(r => r.threadId)).toEqual(["b", "a"]); + }); + it("sortRows created desc", () => { + const rows = toRows([snap("a", 30, 1), snap("b", 10, 1)], () => ({})); + expect(sortRows(rows, "created").map(r => r.threadId)).toEqual(["a", "b"]); + }); + it("filterRows matches title case-insensitively", () => { + const rows = toRows([snap("a", 1, 1, "Hello World"), snap("b", 1, 1, "Other")], () => ({})); + expect(filterRows(rows, "hello").map(r => r.threadId)).toEqual(["a"]); + }); + it("partition splits pinned/active/archived; archived beats pinned", () => { + const rows = toRows( + [snap("p", 1, 1), snap("a", 1, 1), snap("z", 1, 1)], + (id) => id === "p" ? { pinned: true, pinnedAt: 9 } : id === "z" ? { pinned: true, archived: true } : {}, + ); + const s = partition(rows); + expect(s.pinned.map(r => r.threadId)).toEqual(["p"]); + expect(s.active.map(r => r.threadId)).toEqual(["a"]); + expect(s.archived.map(r => r.threadId)).toEqual(["z"]); + }); +}); +``` + +- [ ] **Step 2: Run, verify fails** — `npx vitest run test/stores/sessionSidebarLogic.test.ts` → FAIL (module missing). + +- [ ] **Step 3: Implement** — `src/stores/session-sidebar/logic.ts` + +```ts +import type { ThreadSnapshot } from "../../agent/memory/ThreadStore"; +import type { SessionFlags } from "../../types/plugin"; + +export type SortMode = "last-updated" | "created"; + +export interface SessionRow { + threadId: string; + title: string; + createdAt: number; + updatedAt: number; + pinned: boolean; + pinnedAt: number; + archived: boolean; +} +export interface SessionSections { pinned: SessionRow[]; active: SessionRow[]; archived: SessionRow[] } + +export function toRows(snapshots: ThreadSnapshot[], flagsFor: (id: string) => SessionFlags): SessionRow[] { + return snapshots.map((s) => { + const f = flagsFor(s.threadId); + return { + threadId: s.threadId, + title: s.title ?? "New conversation", + createdAt: s.createdAt, + updatedAt: s.updatedAt, + pinned: !!f.pinned, + pinnedAt: f.pinnedAt ?? 0, + archived: !!f.archived, + }; + }); +} + +export function sortRows(rows: SessionRow[], mode: SortMode): SessionRow[] { + const key = mode === "created" ? (r: SessionRow) => r.createdAt : (r: SessionRow) => r.updatedAt; + return [...rows].sort((a, b) => key(b) - key(a)); +} + +export function filterRows(rows: SessionRow[], query: string): SessionRow[] { + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((r) => r.title.toLowerCase().includes(q)); +} + +export function partition(rows: SessionRow[]): SessionSections { + const pinned: SessionRow[] = []; + const active: SessionRow[] = []; + const archived: SessionRow[] = []; + for (const r of rows) { + if (r.archived) archived.push(r); + else if (r.pinned) pinned.push(r); + else active.push(r); + } + pinned.sort((a, b) => b.pinnedAt - a.pinnedAt); + return { pinned, active, archived }; +} +``` + +- [ ] **Step 4: Run, verify pass** — `npx vitest run test/stores/sessionSidebarLogic.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/stores/session-sidebar/logic.ts test/stores/sessionSidebarLogic.test.ts +git commit -m "feat(sessions): pure sort/filter/partition logic" +``` + +--- + +### Task 4: Reactive SessionSidebarStore + +Holds the live snapshot list, refreshes on `.chat` vault events (debounced), and exposes reactive derived sections. + +**Files:** +- Create: `src/stores/session-sidebar/sessionSidebarStore.svelte.ts` +- Test: `test/stores/sessionSidebarStore.test.ts` + +**Interfaces:** +- Consumes: `AgentManager.getAllThreads()`; `getData().getSessionFlags`; `logic.ts`; Obsidian `Plugin.registerEvent` + `vault.on`. +- Produces: +```ts +export class SessionSidebarStore { + rows: SessionRow[]; // $state, raw (unsorted/unfiltered) + init(plugin: SecondBrainPlugin): void; // registers vault listeners, kicks first refresh + refresh(): Promise; // re-read getAllThreads + merge flags + dispose(): void; +} +export function getSessionSidebarStore(): SessionSidebarStore; // singleton +``` + +- [ ] **Step 1: Write failing test** — `test/stores/sessionSidebarStore.test.ts` + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +vi.mock("obsidian", () => import("../__mocks__/obsidian")); + +const getAllThreads = vi.fn(); +const flags = { getSessionFlags: vi.fn(() => ({})) }; +vi.mock("../../src/stores/state.svelte", () => ({ + getPlugin: () => ({ agentManager: { getAllThreads } }), +})); +vi.mock("../../src/stores/dataStore.svelte", () => ({ getData: () => flags })); + +import { getSessionSidebarStore } from "../../src/stores/session-sidebar/sessionSidebarStore.svelte"; + +describe("SessionSidebarStore", () => { + beforeEach(() => vi.clearAllMocks()); + it("refresh loads rows from getAllThreads and merges flags", async () => { + getAllThreads.mockResolvedValue([{ threadId: "a.chat", title: "A", createdAt: 1, updatedAt: 2 }]); + flags.getSessionFlags.mockReturnValue({ pinned: true, pinnedAt: 7 }); + const store = getSessionSidebarStore(); + await store.refresh(); + expect(store.rows).toHaveLength(1); + expect(store.rows[0]).toMatchObject({ threadId: "a.chat", title: "A", pinned: true }); + }); + it("registers a delete+rename+create listener on init", () => { + const registerEvent = vi.fn(); + const on = vi.fn(() => ({})); + const plugin: any = { registerEvent, app: { vault: { on } }, agentManager: { getAllThreads } }; + getAllThreads.mockResolvedValue([]); + getSessionSidebarStore().init(plugin); + const events = on.mock.calls.map((c) => c[0]); + expect(events).toEqual(expect.arrayContaining(["create", "delete", "rename"])); + }); +}); +``` + +- [ ] **Step 2: Run, verify fails** — `npx vitest run test/stores/sessionSidebarStore.test.ts` → FAIL. + +- [ ] **Step 3: Implement** — `src/stores/session-sidebar/sessionSidebarStore.svelte.ts` + +```ts +import { TFile, type Plugin, debounce } from "obsidian"; +import { getPlugin } from "../state.svelte"; +import { getData } from "../dataStore.svelte"; +import { toRows, type SessionRow } from "./logic"; + +export class SessionSidebarStore { + rows = $state([]); + #refreshDebounced = debounce(() => void this.refresh(), 150, false); + + async refresh(): Promise { + const snapshots = await getPlugin().agentManager.getAllThreads(); + this.rows = toRows(snapshots, (id) => getData().getSessionFlags(id)); + } + + init(plugin: Plugin): void { + const isChat = (f: unknown): f is TFile => f instanceof TFile && f.extension === "chat"; + // ponytail: full re-read per .chat change; O(n) but n = chat count, fine for realistic vaults. + plugin.registerEvent(plugin.app.vault.on("create", (f) => { if (isChat(f)) this.#refreshDebounced(); })); + plugin.registerEvent(plugin.app.vault.on("delete", (f) => { if (isChat(f)) this.#refreshDebounced(); })); + plugin.registerEvent(plugin.app.vault.on("rename", (f) => { if (isChat(f)) this.#refreshDebounced(); })); + void this.refresh(); + } + + dispose(): void { + this.rows = []; + } +} + +let singleton: SessionSidebarStore | null = null; +export function getSessionSidebarStore(): SessionSidebarStore { + if (!singleton) singleton = new SessionSidebarStore(); + return singleton; +} +``` + +> `debounce` is Obsidian's exported util (already used in `ObsidianChatManager`). If `$state` at class-field scope in a `.svelte.ts` file needs the file to be compiled by the Svelte plugin, confirm other `*.svelte.ts` stores use the same pattern (they do — `chatStore.svelte.ts`). The test mocks `obsidian`, so `debounce` resolves to the mock; ensure the mock exports a passthrough `debounce`. If not present in `test/__mocks__/obsidian.ts`, add `export const debounce = (fn: any) => fn;` there. + +- [ ] **Step 4: Run, verify pass** — `npx vitest run test/stores/sessionSidebarStore.test.ts` → PASS. Then `npm run check`. + +- [ ] **Step 5: Commit** + +```bash +git add src/stores/session-sidebar/sessionSidebarStore.svelte.ts test/stores/sessionSidebarStore.test.ts test/__mocks__/obsidian.ts +git commit -m "feat(sessions): reactive session sidebar store" +``` + +--- + +### Task 5: Sidebar Svelte components + resize + +The panel UI. Pure-view Svelte; correctness gate is `npm run check` + the extracted resize-clamp unit test. Rows/sections use Task 3 logic; actions call Task 2 + `AgentManager`. + +**Files:** +- Create: `src/components/chat/session-sidebar/SessionSidebar.svelte` +- Create: `src/components/chat/session-sidebar/SessionSidebarItem.svelte` +- Create: `src/components/chat/session-sidebar/resize.ts` (pure clamp) + Svelte action +- Test: `test/components/sessionResize.test.ts` + +**Interfaces:** +- Consumes: `getSessionSidebarStore()`, `sortRows`/`filterRows`/`partition` (Task 3), `getPlugin().agentManager` (`openChatByThreadId`, `createNewChat`, `renameThread`, `deleteThread`, `setThreadPinned`, `setThreadArchived`), `getSessionRegistry()` for running state, `getData()` for `sessionManagerSort`/`sessionSidebarWidth` (Task 6), `setIcon` from obsidian, `threadPath` prop for active highlight. +- Produces: `` default export; `clampSidebarWidth(desired, containerWidth, side)` from `resize.ts`. + +- [ ] **Step 1: Write failing test for clamp** — `test/components/sessionResize.test.ts` + +```ts +import { describe, it, expect } from "vitest"; +import { clampSidebarWidth } from "../../src/components/chat/session-sidebar/resize"; + +describe("clampSidebarWidth", () => { + it("clamps to min 180", () => { + expect(clampSidebarWidth(50, 1000, "right")).toBe(180); + }); + it("clamps to max containerWidth - 325", () => { + expect(clampSidebarWidth(999, 600, "right")).toBe(275); // 600 - 325 + }); + it("passes through a valid width", () => { + expect(clampSidebarWidth(300, 1000, "right")).toBe(300); + }); + it("never returns below min even when container is tiny", () => { + expect(clampSidebarWidth(300, 400, "left")).toBe(180); // max would be 75 < 180 -> min wins + }); +}); +``` + +- [ ] **Step 2: Run, verify fails** — `npx vitest run test/components/sessionResize.test.ts` → FAIL. + +- [ ] **Step 3: Implement `resize.ts`** + +```ts +export type SidebarSide = "left" | "right"; +export const SIDEBAR_MIN = 180; +export const CHAT_RESERVED = 325; // 320 chat + 5 resizer + +export function clampSidebarWidth(desired: number, containerWidth: number, _side: SidebarSide): number { + const max = containerWidth - CHAT_RESERVED; + const upper = Math.max(SIDEBAR_MIN, max); + return Math.min(Math.max(desired, SIDEBAR_MIN), upper); +} + +// Svelte action: pointer + keyboard resize on the divider element. +export function sessionResizer( + node: HTMLElement, + opts: { getWidth: () => number; setWidth: (w: number) => void; getContainerWidth: () => number; side: SidebarSide }, +) { + let startX = 0; + let startW = 0; + const onMove = (e: PointerEvent) => { + const dir = opts.side === "left" ? 1 : -1; + const next = clampSidebarWidth(startW + (e.clientX - startX) * dir, opts.getContainerWidth(), opts.side); + opts.setWidth(next); + }; + const onUp = () => { + document.body.classList.remove("s2b-resizing-session-sidebar"); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + const onDown = (e: PointerEvent) => { + startX = e.clientX; startW = opts.getWidth(); + document.body.classList.add("s2b-resizing-session-sidebar"); + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + e.preventDefault(); + }; + const onKey = (e: KeyboardEvent) => { + const step = 16; + const dir = opts.side === "left" ? 1 : -1; + if (e.key === "ArrowLeft") opts.setWidth(clampSidebarWidth(opts.getWidth() - step * dir, opts.getContainerWidth(), opts.side)); + else if (e.key === "ArrowRight") opts.setWidth(clampSidebarWidth(opts.getWidth() + step * dir, opts.getContainerWidth(), opts.side)); + else return; + e.preventDefault(); + }; + node.addEventListener("pointerdown", onDown); + node.addEventListener("keydown", onKey); + return { destroy() { node.removeEventListener("pointerdown", onDown); node.removeEventListener("keydown", onKey); onUp(); } }; +} +``` + +- [ ] **Step 4: Run clamp test, verify pass** — `npx vitest run test/components/sessionResize.test.ts` → PASS. + +- [ ] **Step 5: Implement `SessionSidebarItem.svelte`** + +```svelte + + +
+ +
+ {#if archivedView} + + {:else} + + + + {/if} + +
+
+``` + +- [ ] **Step 6: Implement `SessionSidebar.svelte`** + +```svelte + + +
+
+ + +
+
+ + +
+ + {#if !showArchived && sections.pinned.length} +
+ + {#each sections.pinned as row (row.threadId)} + open(row.threadId)} onRename={() => rename(row.threadId, row.title)} onDelete={() => del(row.threadId)} + onTogglePin={() => togglePin(row.threadId, row.pinned)} onToggleArchive={() => toggleArchive(row.threadId, row.archived)} /> + {/each} +
+ {/if} + +
+ + {#if visibleRows.length === 0} +
{query ? "No matching sessions" : "No conversations"}
+ {/if} + {#each visibleRows as row (row.threadId)} + open(row.threadId)} onRename={() => rename(row.threadId, row.title)} onDelete={() => del(row.threadId)} + onTogglePin={() => togglePin(row.threadId, row.pinned)} onToggleArchive={() => toggleArchive(row.threadId, row.archived)} /> + {/each} + {#if remaining > 0} + + {/if} +
+
+``` + +> `confirm`/`prompt` are placeholders for the codebase's existing dialog helpers if present (the extraction mentioned `promptText`/`confirmDelete` in Claudian, not confirmed in S2B). During implementation, grep S2B for an existing confirm/prompt modal (e.g. in `src/components/modal`); if one exists, use it instead of window `confirm`/`prompt`. If none, window dialogs are acceptable for v1 (desktop-only plugin). + +- [ ] **Step 7: `npm run check`** — fix any svelte-check type errors. Expected: clean. + +- [ ] **Step 8: Commit** + +```bash +git add src/components/chat/session-sidebar/ test/components/sessionResize.test.ts +git commit -m "feat(sessions): sidebar components + resize action" +``` + +--- + +### Task 6: Settings + integrate into Chat.svelte + +Wire the sidebar into the chat leaf with the reveal rule, add the four settings, and init the store in `main.ts`. + +**Files:** +- Modify: `src/types/plugin.ts` (3 new fields) +- Modify: `src/stores/dataStore.svelte.ts` (defaults + getters/setters) +- Modify: `src/views/settings/AgentsSettings.svelte` (controls) +- Modify: `src/views/chat/Chat.svelte` (layout + reveal) +- Modify: `src/main.ts` (init store) + +**Interfaces:** +- Consumes: everything above. +- Produces settings: `enableSessionSidebar: boolean` (default `true`), `sessionSidebarSide: "left" | "right"` (default `"right"`), `sessionManagerSort: SortMode` (default `"last-updated"`), `sessionSidebarWidth: number` (default `240`). Getter/setter pairs as per existing pattern. + +- [ ] **Step 1: Add settings fields + defaults + accessors** + +`src/types/plugin.ts` (in `PluginData`): +```ts + enableSessionSidebar: boolean; + sessionSidebarSide: "left" | "right"; + sessionManagerSort: "last-updated" | "created"; + sessionSidebarWidth: number; +``` +`DEFAULT_SETTINGS` (`dataStore.svelte.ts`): +```ts + enableSessionSidebar: true, + sessionSidebarSide: "right", + sessionManagerSort: "last-updated", + sessionSidebarWidth: 240, +``` +Accessors (mirror `chatOpenLocation` getter/setter, each setter calls `saveSettings()`), one pair per field. + +- [ ] **Step 2: Settings controls** — `src/views/settings/AgentsSettings.svelte` (below the `chatOpenLocation` control) + +```svelte + + (pluginData.enableSessionSidebar = v)} /> + + + (pluginData.sessionSidebarSide = v)} /> + +``` +(Sort is also controllable inline in the panel; the setting persists it.) + +- [ ] **Step 3: Integrate into `Chat.svelte`** + +Add imports: +```ts + import SessionSidebar from "../../components/chat/session-sidebar/SessionSidebar.svelte"; + import { sessionResizer } from "../../components/chat/session-sidebar/resize"; + import { getData } from "../../stores/dataStore.svelte"; +``` +Add reactive state near the top of ` + +
+ + +
+ +
+ + +
+ +{#if !showArchived && sections.pinned.length} +
+ + {#each sections.pinned as row (row.threadId)} + open(row.threadId)} + onRename={() => rename(row.threadId, row.title)} + onDelete={() => del(row.threadId, row.title)} + onTogglePin={() => togglePin(row.threadId, row.pinned)} + onToggleArchive={() => toggleArchive(row.threadId, row.archived)} + /> + {/each} +
+{/if} + +
+ + {#if visibleRows.length === 0} +
{query ? "No matching sessions" : "No conversations"}
+ {/if} + {#each visibleRows as row (row.threadId)} + open(row.threadId)} + onRename={() => rename(row.threadId, row.title)} + onDelete={() => del(row.threadId, row.title)} + onTogglePin={() => togglePin(row.threadId, row.pinned)} + onToggleArchive={() => toggleArchive(row.threadId, row.archived)} + /> + {/each} + {#if remaining > 0} + + {/if} +
+ + diff --git a/src/components/chat/session-sidebar/SessionSheetModal.ts b/src/components/chat/session-sidebar/SessionSheetModal.ts new file mode 100644 index 00000000..2e00a543 --- /dev/null +++ b/src/components/chat/session-sidebar/SessionSheetModal.ts @@ -0,0 +1,35 @@ +import type { App } from "obsidian"; +import type { ThreadPathStore } from "../../../views/chat/threadPathStore.svelte"; +import { SvelteModal } from "../../modal/SvelteModal"; +import SessionList from "./SessionList.svelte"; + +/** + * Full-height modal hosting the shared session list for narrow/mobile widths, + * where the docked `SessionSidebar` has no room. Opening a chat from the list + * closes the modal (`onOpenSession`). + */ +export class SessionSheetModal extends SvelteModal { + constructor( + app: App, + private threadPathStore: ThreadPathStore, + ) { + super(app); + } + + onOpen() { + this.setTitle("Sessions"); + this.modalEl.addClass("s2b-session-sheet"); + + this.mountComponent( + SessionList, + { threadPathStore: this.threadPathStore, onOpenSession: () => this.close() }, + { + fullScreenOnPhone: true, + width: "min(420px, 94vw)", + maxWidth: "94vw", + height: "min(840px, 92vh)", + contentOverflow: "hidden", + }, + ); + } +} diff --git a/src/components/chat/session-sidebar/SessionSidebar.svelte b/src/components/chat/session-sidebar/SessionSidebar.svelte new file mode 100644 index 00000000..5b5f4f57 --- /dev/null +++ b/src/components/chat/session-sidebar/SessionSidebar.svelte @@ -0,0 +1,24 @@ + + +
+ +
+ + diff --git a/src/components/chat/session-sidebar/SessionSidebarItem.svelte b/src/components/chat/session-sidebar/SessionSidebarItem.svelte new file mode 100644 index 00000000..8c195b4c --- /dev/null +++ b/src/components/chat/session-sidebar/SessionSidebarItem.svelte @@ -0,0 +1,293 @@ + + +
+ +
+ {#if archivedView} + + {:else} + + + + {/if} + +
+ +
+ + diff --git a/src/components/chat/session-sidebar/resize.ts b/src/components/chat/session-sidebar/resize.ts new file mode 100644 index 00000000..59dd9b9d --- /dev/null +++ b/src/components/chat/session-sidebar/resize.ts @@ -0,0 +1,150 @@ +export type SidebarSide = "left" | "right"; +export const SIDEBAR_MIN = 180; +export const CHAT_RESERVED = 325; // 320 chat + 5 resizer + +/** How long a run of discrete width changes (held arrow key) is coalesced + * before being persisted. Drag gestures never wait on this — they persist on + * pointerup. */ +export const COMMIT_DEBOUNCE_MS = 250; + +/** Clamp a desired sidebar width to `[SIDEBAR_MIN, containerWidth - CHAT_RESERVED]`, + * falling back to `SIDEBAR_MIN` when the container is too small to leave room + * for the chat pane. `_side` is unused today (both sides share the same clamp) + * but kept so callers don't need to special-case direction here. */ +export function clampSidebarWidth(desired: number, containerWidth: number, _side: SidebarSide): number { + const max = containerWidth - CHAT_RESERVED; + const upper = Math.max(SIDEBAR_MIN, max); + return Math.min(Math.max(desired, SIDEBAR_MIN), upper); +} + +interface SessionResizerOptions { + getWidth: () => number; + /** On-screen only: update the rendered width. Must NOT persist. */ + previewWidth: (w: number) => void; + /** Persist the settled width. Called at most once per gesture. */ + commitWidth: (w: number) => void; + getContainerWidth: () => number; + side: SidebarSide; +} + +/** Svelte action for the sidebar's resize divider: pointer-drag and + * ArrowLeft/ArrowRight (16px step) both clamp through {@link clampSidebarWidth}. + * Adds/removes a body class while dragging so other UI (e.g. iframes) can + * suspend pointer-event handling for the duration. + * + * Persistence is deliberately separated from rendering. A drag previews on every + * `pointermove` but writes **once**, on `pointerup`; a held arrow key is + * coalesced through a short debounce. Writing settings per pointer move is what + * corrupted `data.json` on 2026-09-27 — the store's setter saves on every + * assignment, so a single drag produced a burst of whole-file writes. + * + * The pointer is captured on the divider for the duration of a drag, so a release + * that lands outside the strip still ends the gesture rather than leaving it + * stuck to the cursor. */ +export function sessionResizer(node: HTMLElement, opts: SessionResizerOptions) { + let startX = 0; + let startW = 0; + let dragging = false; + let activePointer: number | null = null; + let commitTimer: ReturnType | null = null; + let pending: number | null = null; + + const clearCommitTimer = () => { + if (commitTimer !== null) { + clearTimeout(commitTimer); + commitTimer = null; + } + }; + + /** Write the settled width, if one is outstanding. Idempotent. */ + const flush = () => { + clearCommitTimer(); + if (pending === null) return; + const w = pending; + pending = null; + opts.commitWidth(w); + }; + + /** Preview immediately; mark the width as needing a commit. */ + const apply = (w: number) => { + opts.previewWidth(w); + pending = w; + }; + + /** Capture the pointer for the whole gesture, so a release that lands outside + * the 5px strip — or over an iframe/webview — still ends the drag instead of + * leaving it stuck to the cursor. Guarded, because headless and test + * environments need not implement capture; the window listeners below deliver + * the gesture either way. */ + const capturePointer = (id: number) => { + try { + node.setPointerCapture(id); + } catch { + activePointer = null; + } + }; + + const onMove = (e: PointerEvent) => { + if (!dragging) return; + const dir = opts.side === "left" ? 1 : -1; + // Preview only. The commit happens on pointerup, so a long or slow drag + // cannot turn into a stream of settings writes. + apply(clampSidebarWidth(startW + (e.clientX - startX) * dir, opts.getContainerWidth(), opts.side)); + }; + const onUp = () => { + if (!dragging) return; + dragging = false; + document.body.classList.remove("s2b-resizing-session-sidebar"); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + if (activePointer !== null) { + try { + if (node.hasPointerCapture(activePointer)) node.releasePointerCapture(activePointer); + } catch { + // Capture was never established; there is nothing to release. + } + activePointer = null; + } + flush(); + }; + const onDown = (e: PointerEvent) => { + dragging = true; + startX = e.clientX; + startW = opts.getWidth(); + activePointer = e.pointerId; + capturePointer(e.pointerId); + document.body.classList.add("s2b-resizing-session-sidebar"); + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + e.preventDefault(); + }; + const onKey = (e: KeyboardEvent) => { + const step = 16; + const dir = opts.side === "left" ? 1 : -1; + if (e.key === "ArrowLeft") { + apply(clampSidebarWidth(opts.getWidth() - step * dir, opts.getContainerWidth(), opts.side)); + } else if (e.key === "ArrowRight") { + apply(clampSidebarWidth(opts.getWidth() + step * dir, opts.getContainerWidth(), opts.side)); + } else { + return; + } + // Discrete presses with no "release" signal, so coalesce a held key. + if (commitTimer === null) commitTimer = setTimeout(flush, COMMIT_DEBOUNCE_MS); + e.preventDefault(); + }; + + node.addEventListener("pointerdown", onDown); + node.addEventListener("keydown", onKey); + + return { + destroy() { + node.removeEventListener("pointerdown", onDown); + node.removeEventListener("keydown", onKey); + // Do not lose a width the user already chose. + flush(); + onUp(); + }, + }; +} diff --git a/src/main.ts b/src/main.ts index ceb0c100..86580877 100755 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,7 @@ import { SkillsService } from "./skills"; import { createSessionRegistry, getSessionRegistry, type SessionRegistry } from "./stores/chatStore.svelte"; import { type PluginDataStore, createData, getData } from "./stores/dataStore.svelte"; import { PendingChangesStore, initPendingChangesStore } from "./stores/pendingChangesStore.svelte"; +import { getSessionSidebarStore } from "./stores/session-sidebar/sessionSidebarStore.svelte"; import { setPlugin } from "./stores/state.svelte"; import { onCodexSessionChange } from "./stores/providerRuntime.svelte"; import { invalidateAuthState, invalidateProviderState } from "./lib/query"; @@ -511,6 +512,7 @@ export default class SecondBrainPlugin extends Plugin { // update it later, so the indicator would never show a running chat. this.agentManager = new AgentManager(this); this.sessionRegistry = createSessionRegistry(this.agentManager); + getSessionSidebarStore().init(this); // Global running-agent indicator in the status bar: shows the single // streaming chat (if any) and lets the user stop it from anywhere. Mobile has diff --git a/src/stores/chatStore.svelte.ts b/src/stores/chatStore.svelte.ts index 3dcda9dc..55281728 100644 --- a/src/stores/chatStore.svelte.ts +++ b/src/stores/chatStore.svelte.ts @@ -14,6 +14,7 @@ import { formatSelectionContext, type SelectionRef } from "../hooks/useSelection import { type UUIDv7, genUUIDv7 } from "../utils/uuid7Validator"; import { DEFAULT_AGENT_ID } from "./agentDefaults"; import { getData } from "./dataStore.svelte"; +import { emitRunCue, outcomeForAssistantState, primeAudio } from "./runCue"; import { getPlugin } from "./state.svelte"; import { Logger } from "../utils/logging"; import { shouldSummarizeForEstimatedTokens } from "../agent/summarization"; @@ -132,6 +133,14 @@ export class ChatSession { return this.running; } + /** State of the last message pair's assistant message, or undefined while the + * session holds no messages. Read by the session sidebar to show a per-row + * outcome for sessions that are already loaded, without opening the thread. + * Reactive: messages is $state, so a row updates as a run settles. */ + get lastAssistantState(): AssistantState | undefined { + return this.messages[this.messages.length - 1]?.assistantMessage.state; + } + touch(): void { this.lastTouchedAt = Date.now(); } @@ -613,6 +622,9 @@ export class ChatSession { } this.abortController = new AbortController(); + // A run is always started by a user gesture, which is the only moment + // Chromium lets us open an AudioContext that can actually be heard later. + primeAudio(); this.running = true; const signal = this.abortController.signal; this.touch(); @@ -759,6 +771,57 @@ export class ChatSession { this.summarizingHistory = false; this.messageState = MessageState.idle; this.touch(); + // One cue per settled run. The `catch` above has already normalised the + // outcome onto `pair`, so `pair` is the reliable read and the lookup is + // only a preference (a rebuild may have replaced the pair with an + // equivalent one that should carry the stamp instead). + // + // Do NOT read only the lookup result. `findPairAcrossRebuild` prefers + // `stableKey`, but the optimistic pair this run started from is built in + // `sendMessage` *without* one — so once the settle rebuild renumbers + // `MessagePair.id`, the lookup matches nothing, returns undefined, and the + // cue goes silent on a run that actually succeeded. Verified live + // 2026-10-01: `settledPair=MISSING … emit outcome=null` on a successful + // turn, so the settled state must never depend on the identity surviving. + const settledState = settledPair?.assistantMessage.state ?? pair.assistantMessage.state; + const settledOutcome = outcomeForAssistantState(settledState); + + // Record the outcome against the thread so the sidebar keeps showing a + // settled run after this session is parked and evicted — the live + // session is the only other place the outcome lives. Only a terminal + // state is written; anything else is left as it was rather than + // guessed. `this.id` is already the post-rename path here, because + // the auto-title rename runs (and rekeys the flags) earlier in this same + // try block — and stays the old path when no rename happened. + const settledStatus = + settledState === AssistantState.success + ? AssistantState.success + : settledState === AssistantState.error + ? AssistantState.error + : settledState === AssistantState.cancelled + ? AssistantState.cancelled + : undefined; + + let cueName: string | undefined; + try { + if (settledStatus !== undefined) getData().setSessionStatus(String(this.id), settledStatus); + cueName = getData().getSessionFlags(String(this.id)).title; + } catch { + // Settings can be unavailable in a headless settle (or the unit + // tests for this path). The cue still fires below; a missing name + // only costs the notification its title. + } + + emitRunCue( + settledOutcome, + () => ({ + sound: getData().runSoundEnabled, + notification: getData().runNotificationEnabled, + successSoundPath: getData().runSuccessSoundPath, + failureSoundPath: getData().runFailureSoundPath, + }), + cueName, + ); } } @@ -1616,6 +1679,33 @@ export class SessionRegistry { /* ---------------- Chat Creation / Metadata ---------------- */ + /** Mirror the outcome a just-loaded thread already carries onto its session + * flags, so the sidebar keeps showing it after this session is parked and + * evicted. The run's settle handler is the only other writer, and it fires + * only for a run that settles while the thread is loaded — so a thread that + * settled earlier, or before this feature existed, reached its row with no + * persisted status and rendered `idle`. Loading is the first moment that + * outcome is readable without re-parsing the whole thread. Idempotent: + * `setSessionStatus` is a no-op when the value is unchanged. */ + private persistLoadedSessionStatus(session: ChatSession): void { + try { + const state = session.lastAssistantState; + // Only a terminal state is written; anything else is left as it was + // rather than guessed — the same contract as the settle handler. + if ( + state !== AssistantState.success && + state !== AssistantState.error && + state !== AssistantState.cancelled + ) { + return; + } + getData().setSessionStatus(String(session.id), state); + } catch { + // Settings can be unavailable during a headless or unit-test load. + // The status icon is cosmetic; the load itself must not fail on it. + } + } + async loadSession(file: TFile, targetCheckpointId?: string) { // Claim the latest-load token synchronously, before any await, so // overlapping loads are ordered by call order rather than by whichever @@ -1677,6 +1767,17 @@ export class SessionRegistry { }); } + // Mirror the thread's on-disk title into its flags as soon as it is + // known, so a cue can name the chat before its first settle. + const loadTitle = history?.title?.trim(); + if (loadTitle) { + try { + getData().setSessionTitle(id, loadTitle); + } catch { + // Settings may be unavailable early in boot; not fatal. + } + } + const savedCheckpointId = this.getLastViewedCheckpointId(history); const graph = buildCheckpointGraph(checkpointHistory); const resolution = resolveActiveCheckpointId(graph, { @@ -1729,6 +1830,7 @@ export class SessionRegistry { }), ); this.sessions.set(id, session); + this.persistLoadedSessionStatus(session); await this.persistLastViewedCheckpoint(id, resolution.checkpointId, session); this.evictParkedSessions(); @@ -1802,6 +1904,7 @@ export class SessionRegistry { ); await this.persistLastViewedCheckpoint(id, resolution.checkpointId, session); + this.persistLoadedSessionStatus(session); } /** Switch a specific thread to a different branch by activating a checkpoint. diff --git a/src/stores/dataStore.svelte.ts b/src/stores/dataStore.svelte.ts index e43c443b..533343c9 100644 --- a/src/stores/dataStore.svelte.ts +++ b/src/stores/dataStore.svelte.ts @@ -29,12 +29,14 @@ import type { PrivacyMode, PromptFileReader, RecentNoteEntry, + SessionFlags, SkillUsageEntry, StaleGuidance, ToolConfig, } from "../types/plugin"; import { RECENT_NOTE_WINDOW_MS } from "../types/plugin"; import { getDefaultEmbeddingBatchSize, normalizeEmbeddingBatchSize } from "../vectorstore/batchSize"; +import type { AssistantState } from "./chatTimeline"; import { type UUIDv7, genUUIDv7 } from "../utils/uuid7Validator"; import { type SmartGraphSettings, DEFAULT_SMART_GRAPH_SETTINGS } from "../types/graph"; @@ -230,6 +232,15 @@ export const DEFAULT_SETTINGS: PluginData = { showToolIODetails: false, chatOpenLocation: "tab", lastActiveChatId: null, + enableSessionSidebar: true, + sessionSidebarSide: "right", + sessionManagerSort: "last-updated", + sessionSidebarWidth: 240, + runSoundEnabled: true, + runNotificationEnabled: true, + runSuccessSoundPath: "", + runFailureSoundPath: "", + sessionFlags: {}, onboardingComplete: false, onboardingSplashSeen: false, dismissedRecommendations: [], @@ -1562,6 +1573,130 @@ export class PluginDataStore { void this.saveSettings(); } + // --- Session Sidebar Settings --- + + get enableSessionSidebar(): boolean { + return this.#data.enableSessionSidebar ?? true; + } + set enableSessionSidebar(val: boolean) { + this.#data.enableSessionSidebar = val; + void this.saveSettings(); + } + + get sessionSidebarSide(): "left" | "right" { + return this.#data.sessionSidebarSide ?? "right"; + } + set sessionSidebarSide(val: "left" | "right") { + this.#data.sessionSidebarSide = val; + void this.saveSettings(); + } + + get sessionManagerSort(): "last-updated" | "created" { + return this.#data.sessionManagerSort ?? "last-updated"; + } + set sessionManagerSort(val: "last-updated" | "created") { + this.#data.sessionManagerSort = val; + void this.saveSettings(); + } + + get sessionSidebarWidth(): number { + return this.#data.sessionSidebarWidth ?? 240; + } + set sessionSidebarWidth(val: number) { + this.#data.sessionSidebarWidth = val; + void this.saveSettings(); + } + + get runSoundEnabled(): boolean { + return this.#data.runSoundEnabled ?? true; + } + set runSoundEnabled(val: boolean) { + this.#data.runSoundEnabled = val; + void this.saveSettings(); + } + + get runNotificationEnabled(): boolean { + return this.#data.runNotificationEnabled ?? true; + } + set runNotificationEnabled(val: boolean) { + this.#data.runNotificationEnabled = val; + void this.saveSettings(); + } + + get runSuccessSoundPath(): string { + return this.#data.runSuccessSoundPath ?? ""; + } + set runSuccessSoundPath(val: string) { + this.#data.runSuccessSoundPath = val; + void this.saveSettings(); + } + + get runFailureSoundPath(): string { + return this.#data.runFailureSoundPath ?? ""; + } + set runFailureSoundPath(val: string) { + this.#data.runFailureSoundPath = val; + void this.saveSettings(); + } + + // --- Session Flags (pin/archive) --- + + getSessionFlags(path: string): SessionFlags { + return this.#data.sessionFlags?.[path] ?? {}; + } + setSessionPinned(path: string, pinned: boolean): void { + if (!this.#data.sessionFlags) this.#data.sessionFlags = {}; + const cur = this.#data.sessionFlags[path] ?? {}; + this.#data.sessionFlags[path] = pinned + ? { ...cur, pinned: true, pinnedAt: cur.pinnedAt ?? Date.now() } + : { ...cur, pinned: false, pinnedAt: undefined }; + void this.saveSettings(); + } + setSessionArchived(path: string, archived: boolean): void { + if (!this.#data.sessionFlags) this.#data.sessionFlags = {}; + const cur = this.#data.sessionFlags[path] ?? {}; + this.#data.sessionFlags[path] = { ...cur, archived }; + void this.saveSettings(); + } + /** Record the outcome of a thread's last settled turn, so the sidebar can + * show it after the thread is unloaded and its live session evicted. + * + * The path is the thread's *current* path: the auto-title rename fires + * mid-run and moves this whole record to the new path before the settle + * handler runs, so no migration is needed here. + * + * Idempotent: an unchanged status does not touch disk. */ + setSessionStatus(path: string, status: AssistantState): void { + if (!this.#data.sessionFlags) this.#data.sessionFlags = {}; + const cur = this.#data.sessionFlags[path] ?? {}; + if (cur.lastStatus === status) return; + this.#data.sessionFlags[path] = { ...cur, lastStatus: status }; + void this.saveSettings(); + } + /** Mirror the thread's display name into its flags, so a notification (or the + * sidebar) can name a chat that is not loaded into a live session. */ + setSessionTitle(path: string, title: string): void { + if (!this.#data.sessionFlags) this.#data.sessionFlags = {}; + const cur = this.#data.sessionFlags[path] ?? {}; + if (!title || cur.title === title) return; + this.#data.sessionFlags[path] = { ...cur, title }; + void this.saveSettings(); + } + renameSessionFlags(oldPath: string, newPath: string): void { + const flags = this.#data.sessionFlags?.[oldPath]; + if (!flags) return; + if (!this.#data.sessionFlags) this.#data.sessionFlags = {}; + this.#data.sessionFlags[newPath] = flags; + delete this.#data.sessionFlags[oldPath]; + void this.saveSettings(); + } + removeSessionFlags(path: string): void { + if (this.#data.sessionFlags?.[path]) { + delete this.#data.sessionFlags[path]; + void this.saveSettings(); + } + } + // --- Favorite Models --- get favoriteModels(): Array<{ provider: string; model: string }> { @@ -2280,6 +2415,17 @@ export function getData(): PluginDataStore { return _pluginDataStore; } +/** The last settled outcome recorded for a thread, or undefined when the thread + * has never settled a run (or its flags were cleared). Reads the persisted flag + * record, so it answers for a thread whose live session has been evicted. */ +export function getSessionStatus(path: string): AssistantState | undefined { + try { + return getData().getSessionFlags(path).lastStatus; + } catch { + return undefined; + } +} + /** * Test-only: clear the module-level singleton so a subsequent `createData` runs migrations + * normalization on fresh input. `createData` memoizes its result, which is correct at runtime diff --git a/src/stores/runCue.ts b/src/stores/runCue.ts new file mode 100644 index 00000000..361d01eb --- /dev/null +++ b/src/stores/runCue.ts @@ -0,0 +1,251 @@ +import { Notice } from "obsidian"; +import { AssistantState } from "./chatTimeline"; + +/** The terminal outcome of a chat run. Mirrors the settled assistant state, so + * there is one source of truth for "how did this turn end". */ +export type RunOutcome = "success" | "error" | "cancelled"; + +export interface RunCueSettings { + /** Play a sound when a run settles. */ + sound: boolean; + /** Raise a native notification when a run settles. */ + notification: boolean; + /** Absolute path to a sound file to play on success. Blank uses the + * built-in tone. Desktop-only: ignored where there is no filesystem. */ + successSoundPath?: string; + /** Absolute path to a sound file to play on failure. Blank uses the + * built-in tone. Desktop-only: ignored where there is no filesystem. */ + failureSoundPath?: string; +} + +/** What a settled run should play. `default` is the built-in synthesised cue; + * `file` is a user-supplied audio file read at play time. */ +export type CueSound = { kind: "default"; frequency: number } | { kind: "file"; path: string }; + +export interface RunCue { + /** false for outcomes that must stay silent. */ + play: boolean; + /** The sound to play, or null for silence. */ + sound: CueSound | null; + /** Notification body, or null for no notification. */ + body: string | null; + /** Display title for the notification — the session name, so a cue can say + * *which* chat settled. Null when the session has no name yet. */ + name: string | null; +} + +/** How long the built-in tone is held. A cue, not a jingle. */ +export const CUE_DURATION_MS = 140; +/** Distinct by ear: a failure must not sound like a completion. */ +export const CUE_DONE_HZ = 880; +export const CUE_FAIL_HZ = 320; + +/** A path that is only whitespace is not a setting. */ +function trimmedOrNull(value: string | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +/** + * Decide what a settled run should emit. + * + * `cancelled` is deliberately silent: the user asked for the stop, so a cue + * would be noise rather than information. Pure, so the decision is testable + * without an AudioContext or a renderer. + */ +export function cueForOutcome(outcome: RunOutcome, settings: RunCueSettings, sessionName?: string): RunCue { + // The session name rides alongside the body rather than inside it: several + // chats can be in flight, so "Chat run finished" on its own does not tell + // the user which one it was. + const name = trimmedOrNull(sessionName); + if (outcome === "cancelled") return { play: false, sound: null, body: null, name: null }; + const failed = outcome === "error"; + // The user's own file wins over the built-in tone, but only when sound is + // on at all — the toggle stays the master switch. + const customPath = trimmedOrNull(failed ? settings.failureSoundPath : settings.successSoundPath); + const sound: CueSound | null = settings.sound + ? customPath + ? { kind: "file", path: customPath } + : { kind: "default", frequency: failed ? CUE_FAIL_HZ : CUE_DONE_HZ } + : null; + return { + play: true, + sound, + body: settings.notification ? (failed ? "Chat run failed" : "Chat run finished") : null, + name, + }; +} + +/** Map a settled assistant state to an outcome. Anything not terminal returns + * null and fires nothing. */ +export function outcomeForAssistantState(state: AssistantState | undefined): RunOutcome | null { + switch (state) { + case AssistantState.success: + return "success"; + case AssistantState.error: + return "error"; + case AssistantState.cancelled: + return "cancelled"; + default: + return null; + } +} + +// --- emission: renderer-dependent, best-effort, must never throw --- + +let audioCtx: AudioContext | null = null; + +/** Decoded audio, keyed by file path, so a settle does not re-read and + * re-decode the same file from disk on every run. */ +const decodedSounds = new Map(); +/** Decodes already in flight, keyed the same way, so two settles in quick + * succession do not both hit the disk for the same file. */ +const pendingDecodes = new Map>(); + +/** + * Called from a user gesture (send / submit). Chromium gates audio until a + * gesture has happened, so this is the only reliable moment to open a context + * that can actually be heard later. Best-effort: failure just means no sound. + */ +export function primeAudio(): void { + try { + if (typeof AudioContext === "undefined") return; + if (!audioCtx) audioCtx = new AudioContext(); + if (audioCtx.state === "suspended") void audioCtx.resume(); + } catch { + audioCtx = null; + } +} + +function playTone(frequency: number): void { + try { + if (!audioCtx || audioCtx.state !== "running") return; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.frequency.value = frequency; + gain.gain.value = 0.05; // quiet: a cue, not an alarm + osc.connect(gain).connect(audioCtx.destination); + const now = audioCtx.currentTime; + osc.start(now); + osc.stop(now + CUE_DURATION_MS / 1000); + } catch { + // A blocked or failed cue must never break the run's bookkeeping. + } +} + +/** + * Read a file into an ArrayBuffer using Electron's real require. + * + * This is exactly the path a plugin bundle's Node `require` does NOT provide: + * Obsidian injects a stub that returns null for builtins, while the renderer's + * own `require` (window.require / globalThis.require) is Electron's real one. + * Returns null wherever there is no filesystem, i.e. mobile. + */ +function readFileBytes(path: string): ArrayBuffer | null { + try { + const scope = globalThis as { require?: (id: string) => unknown }; + const req = scope.require ?? (window as unknown as { require?: (id: string) => unknown }).require; + if (typeof req !== "function") return null; + const fs = req("fs") as { readFileSync?: (p: string) => Uint8Array }; + if (typeof fs?.readFileSync !== "function") return null; + const bytes = fs.readFileSync(path); + // Slice out exactly this view: Buffer may be a window onto a shared pool. + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + } catch { + // Missing file, permissions, unreadable format — all mean "no sound". + return null; + } +} + +function playBuffer(buffer: AudioBuffer): void { + if (!audioCtx || audioCtx.state !== "running") return; + const source = audioCtx.createBufferSource(); + const gain = audioCtx.createGain(); + gain.gain.value = 1; // the file is already mastered; only the synth is attenuated + source.buffer = buffer; + source.connect(gain).connect(audioCtx.destination); + source.start(); +} + +async function decodeSound(path: string): Promise { + try { + if (!audioCtx) return null; + const bytes = readFileBytes(path); + if (!bytes) return null; + const decoded = await audioCtx.decodeAudioData(bytes); + decodedSounds.set(path, decoded); + return decoded; + } catch { + return null; + } finally { + pendingDecodes.delete(path); + } +} + +/** + * Play a user-supplied file. Falls back to the built-in tone for that outcome + * when the file cannot be read or decoded — silence would hide the very event + * the cue exists to announce. + */ +function playFile(path: string, fallbackHz: number): void { + try { + const cached = decodedSounds.get(path); + if (cached) { + playBuffer(cached); + return; + } + if (!audioCtx || audioCtx.state !== "running") return; + let pending = pendingDecodes.get(path); + if (!pending) { + pending = decodeSound(path); + pendingDecodes.set(path, pending); + } + void pending.then((buffer) => { + if (buffer) playBuffer(buffer); + else playTone(fallbackHz); + }); + } catch { + // Never throw into a settled run. + } +} + +function notify(body: string, name: string | null): void { + try { + if (typeof Notification !== "undefined" && Notification.permission === "granted") { + // The session name is the OS notification's title; the outcome is + // its body. Falls back to the plugin name for an unnamed session. + new Notification(name ?? "Smart Second Brain", { body }); + return; + } + // Notice is the guaranteed floor, and is not gated by permission. It + // renders one string, so the name is composed in rather than dropped. + new Notice(name ? `${name} — ${body}` : body); + } catch { + // Never throw into a settled run. + } +} + +/** + * Emit the cue for a settled run. No-op when the outcome is not terminal. + * + * Settings arrive as a thunk and are read *inside* the guard, because the read + * itself can throw: the run's `finally` can execute in a context with no plugin + * (headless, or the unit tests for this path). A cue must never be able to break + * the bookkeeping of a run that has already settled. + */ +export function emitRunCue(outcome: RunOutcome | null, readSettings: () => RunCueSettings, sessionName?: string): void { + if (!outcome) return; + let settings: RunCueSettings; + try { + settings = readSettings(); + } catch { + return; + } + const cue = cueForOutcome(outcome, settings, sessionName); + if (!cue.play) return; + if (cue.sound) { + if (cue.sound.kind === "default") playTone(cue.sound.frequency); + else playFile(cue.sound.path, outcome === "error" ? CUE_FAIL_HZ : CUE_DONE_HZ); + } + if (cue.body) notify(cue.body, cue.name); +} diff --git a/src/stores/session-sidebar/logic.ts b/src/stores/session-sidebar/logic.ts new file mode 100644 index 00000000..1efa2458 --- /dev/null +++ b/src/stores/session-sidebar/logic.ts @@ -0,0 +1,58 @@ +import type { ThreadSnapshot } from "../../agent/memory/ThreadStore"; +import type { SessionFlags } from "../../types/plugin"; + +export type SortMode = "last-updated" | "created"; + +export interface SessionRow { + threadId: string; + title: string; + createdAt: number; + updatedAt: number; + pinned: boolean; + pinnedAt: number; + archived: boolean; +} +export interface SessionSections { + pinned: SessionRow[]; + active: SessionRow[]; + archived: SessionRow[]; +} + +export function toRows(snapshots: ThreadSnapshot[], flagsFor: (id: string) => SessionFlags): SessionRow[] { + return snapshots.map((s) => { + const f = flagsFor(s.threadId); + return { + threadId: s.threadId, + title: s.title ?? "New conversation", + createdAt: s.createdAt, + updatedAt: s.updatedAt, + pinned: !!f.pinned, + pinnedAt: f.pinnedAt ?? 0, + archived: !!f.archived, + }; + }); +} + +export function sortRows(rows: SessionRow[], mode: SortMode): SessionRow[] { + const key = mode === "created" ? (r: SessionRow) => r.createdAt : (r: SessionRow) => r.updatedAt; + return [...rows].sort((a, b) => key(b) - key(a)); +} + +export function filterRows(rows: SessionRow[], query: string): SessionRow[] { + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((r) => r.title.toLowerCase().includes(q)); +} + +export function partition(rows: SessionRow[]): SessionSections { + const pinned: SessionRow[] = []; + const active: SessionRow[] = []; + const archived: SessionRow[] = []; + for (const r of rows) { + if (r.archived) archived.push(r); + else if (r.pinned) pinned.push(r); + else active.push(r); + } + pinned.sort((a, b) => b.pinnedAt - a.pinnedAt); + return { pinned, active, archived }; +} diff --git a/src/stores/session-sidebar/sessionSidebarStore.svelte.ts b/src/stores/session-sidebar/sessionSidebarStore.svelte.ts new file mode 100644 index 00000000..e976fe7c --- /dev/null +++ b/src/stores/session-sidebar/sessionSidebarStore.svelte.ts @@ -0,0 +1,56 @@ +import { TFile, type Plugin, debounce } from "obsidian"; +import { getPlugin } from "../state.svelte"; +import { getData } from "../dataStore.svelte"; +import { toRows, type SessionRow } from "./logic"; + +export class SessionSidebarStore { + rows = $state([]); + #refreshDebounced = debounce(() => void this.refresh(), 150, false); + + async refresh(): Promise { + const snapshots = await getPlugin().agentManager.getAllThreads(); + this.rows = toRows(snapshots, (id) => getData().getSessionFlags(id)); + // Mirror each thread's on-disk title into its flags. This is the writer + // that keeps the mirror true across an external rename (a flag record is + // not recreated by a file rename, so without this a renamed thread's + // cached name would be the one it had when it first settled). + for (const row of this.rows) getData().setSessionTitle(row.threadId, row.title); + } + + init(plugin: Plugin): void { + const isChat = (f: unknown): f is TFile => f instanceof TFile && f.extension === "chat"; + // ponytail: full re-read per .chat change; O(n) but n = chat count, fine for realistic vaults. + plugin.registerEvent( + plugin.app.vault.on("create", (f) => { + if (isChat(f)) this.#refreshDebounced(); + }), + ); + plugin.registerEvent( + plugin.app.vault.on("delete", (f) => { + if (isChat(f)) { + getData().removeSessionFlags(f.path); + this.#refreshDebounced(); + } + }), + ); + plugin.registerEvent( + plugin.app.vault.on("rename", (f, oldPath) => { + if (isChat(f)) { + getData().renameSessionFlags(oldPath, f.path); + this.#refreshDebounced(); + } + }), + ); + void this.refresh(); + } + + dispose(): void { + this.rows = []; + } +} + +let singleton: SessionSidebarStore | null = null; +export function getSessionSidebarStore(): SessionSidebarStore { + if (!singleton) singleton = new SessionSidebarStore(); + return singleton; +} diff --git a/src/stores/session-sidebar/status.ts b/src/stores/session-sidebar/status.ts new file mode 100644 index 00000000..6a816bb9 --- /dev/null +++ b/src/stores/session-sidebar/status.ts @@ -0,0 +1,75 @@ +import { AssistantState } from "../chatTimeline"; + +/** At-a-glance state of a chat session in the sidebar. + * + * `idle` is the honest unknown: the thread is not loaded into a live session, + * so the plugin genuinely does not know what happened to its last turn. It is + * deliberately not coloured green — a status surface must never guess. */ +export type SessionStatus = "running" | "done" | "failed" | "cancelled" | "interrupted" | "idle"; + +export interface SessionStatusDisplay { + /** Obsidian (lucide) icon name. */ + icon: string; + /** Text label. Always rendered — colour alone is never the only signal. */ + label: string; + /** CSS class suffix; the colour lives in the stylesheet, not here. */ + tone: "neutral" | "progress" | "good" | "bad"; +} + +export const SESSION_STATUS_DISPLAY: Record = { + running: { icon: "loader-circle", label: "Running", tone: "progress" }, + done: { icon: "circle-check", label: "Done", tone: "good" }, + failed: { icon: "circle-alert", label: "Failed", tone: "bad" }, + cancelled: { icon: "circle-slash", label: "Stopped", tone: "neutral" }, + interrupted: { icon: "circle-dashed", label: "Interrupted", tone: "neutral" }, + idle: { icon: "circle", label: "Not loaded", tone: "neutral" }, +}; + +/** + * Derive a row's status. + * + * `isRunning` is the live registry flag (`ChatSession.isRunning`). Otherwise the + * answer comes from the *last* message pair's assistant state, which the session + * holds reactively in memory. + * + * `streaming` with nothing running means the app was closed mid-stream: the pair + * stays in that state forever, but nothing is actually happening. It is neither + * done nor failed, so it reports `interrupted` rather than inventing an outcome. + */ +export function deriveSessionStatus(isRunning: boolean, lastAssistantState: AssistantState | undefined): SessionStatus { + if (isRunning) return "running"; + switch (lastAssistantState) { + case AssistantState.success: + return "done"; + case AssistantState.error: + return "failed"; + case AssistantState.cancelled: + return "cancelled"; + case AssistantState.streaming: + return "interrupted"; + default: + return "idle"; + } +} + +/** + * Status for a sidebar row, which may have no live session at all. + * + * `running` needs a live session: only it knows a stream is in flight, so that + * can never be inferred from persistence. But a *settled* outcome outlives the + * session — the registry keeps only a few parked idle sessions and evicts the + * rest, so after navigating away the in-memory state is simply gone. Reading + * `idle` there presented a known outcome as unknown and the row's icon + * disappeared; the persisted outcome is what the row should fall back to. + * + * Live state wins when it is present, because it is the fresher of the two. + */ +export function deriveRowStatus( + isRunning: boolean, + lastAssistantState: AssistantState | undefined, + persistedState: AssistantState | undefined, +): SessionStatus { + if (isRunning) return "running"; + const state = lastAssistantState ?? persistedState; + return state === undefined ? "idle" : deriveSessionStatus(false, state); +} diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 1686ff57..dffd4a91 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -1,5 +1,6 @@ import type { StoredProviderState } from "../stores/dataStore.svelte"; import type { ProviderInstanceMeta } from "../types/provider/index"; +import type { AssistantState } from "../stores/chatTimeline"; import type { UUIDv7 } from "../utils/uuid7Validator"; import type { SmartGraphSettings } from "./graph"; @@ -481,6 +482,21 @@ export type ChatOpenLocation = "tab" | "left" | "right"; export type PrivacyMode = "private-by-default" | "public-by-default"; +/** Pin/archive flags for a chat session, keyed by its thread file path in `PluginData.sessionFlags`. */ +export interface SessionFlags { + pinned?: boolean; + pinnedAt?: number; + archived?: boolean; + /** The session's display name, mirrored from the thread title. Kept here so + * a settled run can name its chat in a notification even when the thread is + * not loaded into a live session. */ + title?: string; + /** Outcome of the thread's last settled turn. The live session knows this + * too, but a parked idle session is evicted, so the sidebar reads this + * rather than showing a known outcome as unknown. */ + lastStatus?: AssistantState; +} + export interface PluginData { /** Incremented whenever a breaking schema change is made; drives runMigrations(). Absent on pre-versioning data ⇒ treated as 0. */ schemaVersion: number; @@ -559,6 +575,24 @@ export interface PluginData { chatOpenLocation: ChatOpenLocation; lastActiveChatId: UUIDv7 | null; + /** Whether the chat session sidebar is enabled. */ + enableSessionSidebar: boolean; + /** Which side of the workspace the session sidebar opens on. */ + sessionSidebarSide: "left" | "right"; + /** Sort order for the session manager's session list. */ + sessionManagerSort: "last-updated" | "created"; + /** Persisted width (px) of the session sidebar. */ + sessionSidebarWidth: number; + /** Play a short tone when a chat run settles (success or failure). */ + runSoundEnabled: boolean; + /** Raise a notification when a chat run settles (success or failure). */ + runNotificationEnabled: boolean; + /** Absolute path to a sound file played when a run succeeds. Blank uses the built-in tone. */ + runSuccessSoundPath: string; + /** Absolute path to a sound file played when a run fails. Blank uses the built-in tone. */ + runFailureSoundPath: string; + /** Pin/archive flags per chat session, keyed by thread file path. */ + sessionFlags: Record; /** Whether the user has completed (or dismissed) the first-run onboarding flow. */ onboardingComplete: boolean; /** Whether the onboarding splash intro animation has already played (so it plays only once). */ diff --git a/src/views/chat/Chat.svelte b/src/views/chat/Chat.svelte index 7a299c85..6ea5af47 100644 --- a/src/views/chat/Chat.svelte +++ b/src/views/chat/Chat.svelte @@ -2,7 +2,11 @@ import { QueryClientProvider } from "@tanstack/svelte-query"; import Input from "../../components/chat/Input.svelte"; import MessageContainer from "../../components/chat/MessageContainer.svelte"; +import { SessionSheetModal } from "../../components/chat/session-sidebar/SessionSheetModal"; +import SessionSidebar from "../../components/chat/session-sidebar/SessionSidebar.svelte"; +import { sessionResizer } from "../../components/chat/session-sidebar/resize"; import { getSessionRegistry } from "../../stores/chatStore.svelte"; +import { getData } from "../../stores/dataStore.svelte"; import { getPlugin } from "../../stores/state.svelte"; import { icon } from "../../utils/utils"; import { isMobileUI } from "../../utils/platform"; @@ -20,6 +24,33 @@ const plugin = getPlugin(); const registry = getSessionRegistry(); +const data = getData(); +// Reveal rule mirrors Claudian: the in-leaf session list only shows once the +// leaf is wide enough to hold it alongside the chat column, else it's simply +// hidden (dropdown fallback for the narrow case is a deferred fast-follow). +let rootEl = $state(null); +let containerWidth = $state(0); +/** Width being dragged right now. Non-null only during a gesture, so the + * on-screen size follows the pointer while the *persisted* value + * (`data.sessionSidebarWidth`) is written once, on release. */ +let dragWidth = $state(null); +const wide = $derived(data.enableSessionSidebar && containerWidth >= 600); + +function observeWidth(node: HTMLElement) { + rootEl = node; + const ro = new ResizeObserver((entries) => { + containerWidth = entries[0].contentRect.width; + }); + ro.observe(node); + return { + destroy: () => ro.disconnect(), + }; +} + +$effect(() => { + if (rootEl) rootEl.style.setProperty("--s2b-session-sidebar-width", `${dragWidth ?? data.sessionSidebarWidth}px`); +}); + let messageContainer = $state | undefined>(); let input = $state | undefined>(); let lastSessionId: string | null = null; @@ -297,68 +328,162 @@ function portalComposer(node: HTMLElement) {
- {#if registry} - - { - isDragging = state.isDragging; - dragMessage = state.dragMessage; - dragHasIssue = state.dragHasIssue; - }} - onMessageSent={() => messageContainer?.scrollToLatestMessage()} - /> - {:else} + {#if wide} + + +
- Chat session is not available yet. Reopen this view after plugin initialization completes. -
+ class="s2b-session-resizer" + role="separator" + aria-orientation="vertical" + aria-label="Resize conversation sessions" + tabindex="0" + use:sessionResizer={{ + getWidth: () => data.sessionSidebarWidth, + // Live width while dragging: component-local, so a drag never writes settings. + previewWidth: (w) => (dragWidth = w), + commitWidth: (w) => { dragWidth = null; data.sessionSidebarWidth = w; }, + getContainerWidth: () => containerWidth, + side: data.sessionSidebarSide, + }} + >
{/if} - - {#if isDragging} - -
-
-
+ {#if !wide && data.enableSessionSidebar} +
+ +
+ {/if} + + {#if registry} + + { + isDragging = state.isDragging; + dragMessage = state.dragMessage; + dragHasIssue = state.dragHasIssue; + }} + onMessageSent={() => messageContainer?.scrollToLatestMessage()} + /> + {:else} +
+ Chat session is not available yet. Reopen this view after plugin initialization completes. +
+ {/if} + + {#if isDragging} + +
+
- {dragMessage} + class="chat-drop-overlay-panel flex items-center gap-3 rounded-2xl px-5 py-4 text-sm font-medium" + > +
+ {dragMessage} +
-
- {/if} + {/if} +