From 5e57f30103157678a12e663977853e46361aa3ea Mon Sep 17 00:00:00 2001 From: Digital-Meta <137065378+Digital-Meta@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:25:24 +0100 Subject: [PATCH 01/15] feat(sessions): chat session sidebar (Claudian-style), rebased onto upstream 2.2.0 In-leaf resizable session list: Pinned/Archived sections, search, sort, new/open/rename/delete, pin/archive (running-guard), resize + width persist, reveals >=600px. Plugin-data-backed flags with rename/delete lifecycle. Rebased onto current upstream (was 2.1.0); resolved dataStore import + Chat.svelte layout conflicts (dropped upstream-removed messageNavHotkeys). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 +- .../plans/2026-09-27-s2b-session-sidebar.md | 952 ++++++++++++++++++ .../2026-09-27-s2b-session-sidebar-design.md | 85 ++ src/agent/AgentManager.ts | 20 + .../session-sidebar/SessionSidebar.svelte | 228 +++++ .../session-sidebar/SessionSidebarItem.svelte | 164 +++ src/components/chat/session-sidebar/resize.ts | 71 ++ src/main.ts | 2 + src/stores/dataStore.svelte.ts | 74 ++ src/stores/session-sidebar/logic.ts | 54 + .../sessionSidebarStore.svelte.ts | 51 + src/types/plugin.ts | 17 + src/views/chat/Chat.svelte | 216 +++- src/views/settings/AgentsSettings.svelte | 21 + test/agent/sessionFlagsGuard.test.ts | 77 ++ test/components/sessionResize.test.ts | 17 + test/stores/sessionFlags.test.ts | 82 ++ test/stores/sessionSidebarLogic.test.ts | 33 + test/stores/sessionSidebarStore.test.ts | 39 + 19 files changed, 2152 insertions(+), 55 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-27-s2b-session-sidebar.md create mode 100644 docs/superpowers/specs/2026-09-27-s2b-session-sidebar-design.md create mode 100644 src/components/chat/session-sidebar/SessionSidebar.svelte create mode 100644 src/components/chat/session-sidebar/SessionSidebarItem.svelte create mode 100644 src/components/chat/session-sidebar/resize.ts create mode 100644 src/stores/session-sidebar/logic.ts create mode 100644 src/stores/session-sidebar/sessionSidebarStore.svelte.ts create mode 100644 test/agent/sessionFlagsGuard.test.ts create mode 100644 test/components/sessionResize.test.ts create mode 100644 test/stores/sessionFlags.test.ts create mode 100644 test/stores/sessionSidebarLogic.test.ts create mode 100644 test/stores/sessionSidebarStore.test.ts 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/SessionSidebarItem.svelte b/src/components/chat/session-sidebar/SessionSidebarItem.svelte new file mode 100644 index 00000000..c7ee310e --- /dev/null +++ b/src/components/chat/session-sidebar/SessionSidebarItem.svelte @@ -0,0 +1,164 @@ + + +
+ +
+ {#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..f79ced08 --- /dev/null +++ b/src/components/chat/session-sidebar/resize.ts @@ -0,0 +1,71 @@ +export type SidebarSide = "left" | "right"; +export const SIDEBAR_MIN = 180; +export const CHAT_RESERVED = 325; // 320 chat + 5 resizer + +/** 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; + setWidth: (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. */ +export function sessionResizer(node: HTMLElement, opts: SessionResizerOptions) { + 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(); + }, + }; +} 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/dataStore.svelte.ts b/src/stores/dataStore.svelte.ts index e43c443b..5d43eba2 100644 --- a/src/stores/dataStore.svelte.ts +++ b/src/stores/dataStore.svelte.ts @@ -29,6 +29,7 @@ import type { PrivacyMode, PromptFileReader, RecentNoteEntry, + SessionFlags, SkillUsageEntry, StaleGuidance, ToolConfig, @@ -230,6 +231,11 @@ export const DEFAULT_SETTINGS: PluginData = { showToolIODetails: false, chatOpenLocation: "tab", lastActiveChatId: null, + enableSessionSidebar: true, + sessionSidebarSide: "right", + sessionManagerSort: "last-updated", + sessionSidebarWidth: 240, + sessionFlags: {}, onboardingComplete: false, onboardingSplashSeen: false, dismissedRecommendations: [], @@ -1562,6 +1568,74 @@ 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(); + } + + // --- 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(); + } + 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 }> { diff --git a/src/stores/session-sidebar/logic.ts b/src/stores/session-sidebar/logic.ts new file mode 100644 index 00000000..f1aa1d76 --- /dev/null +++ b/src/stores/session-sidebar/logic.ts @@ -0,0 +1,54 @@ +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..4b90ce5b --- /dev/null +++ b/src/stores/session-sidebar/sessionSidebarStore.svelte.ts @@ -0,0 +1,51 @@ +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)) { + 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/types/plugin.ts b/src/types/plugin.ts index 1686ff57..872913bf 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -481,6 +481,13 @@ 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; +} + 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 +566,16 @@ 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; + /** 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..ab0f1650 100644 --- a/src/views/chat/Chat.svelte +++ b/src/views/chat/Chat.svelte @@ -2,7 +2,10 @@ import { QueryClientProvider } from "@tanstack/svelte-query"; import Input from "../../components/chat/Input.svelte"; import MessageContainer from "../../components/chat/MessageContainer.svelte"; +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 +23,29 @@ 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); +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", `${data.sessionSidebarWidth}px`); +}); + let messageContainer = $state | undefined>(); let input = $state | undefined>(); let lastSessionId: string | null = null; @@ -297,68 +323,146 @@ 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, + setWidth: (w) => (data.sessionSidebarWidth = w), + getContainerWidth: () => containerWidth, + side: data.sessionSidebarSide, + }} + >
{/if} - - {#if isDragging} - -
-
-
+
+ {#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} +
diff --git a/src/components/chat/session-sidebar/SessionSidebar.svelte b/src/components/chat/session-sidebar/SessionSidebar.svelte index 0b2eb6d7..122a852a 100644 --- a/src/components/chat/session-sidebar/SessionSidebar.svelte +++ b/src/components/chat/session-sidebar/SessionSidebar.svelte @@ -1,145 +1,14 @@
-
- - -
- -
- - -
- - {#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} -
+
From 8c0e15e56c2a553050f31d9909f50e03b91bc3b4 Mon Sep 17 00:00:00 2001 From: Digital-Meta <137065378+Digital-Meta@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:42:17 +0100 Subject: [PATCH 03/15] feat(sessions): touch overflow menu for session rows --- .../session-sidebar/SessionSidebarItem.svelte | 82 ++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/src/components/chat/session-sidebar/SessionSidebarItem.svelte b/src/components/chat/session-sidebar/SessionSidebarItem.svelte index c7ee310e..634c8058 100644 --- a/src/components/chat/session-sidebar/SessionSidebarItem.svelte +++ b/src/components/chat/session-sidebar/SessionSidebarItem.svelte @@ -1,4 +1,5 @@
@@ -58,6 +90,14 @@ const dateStr = $derived(new Date(row.updatedAt).toLocaleDateString());
+