Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5e57f30
feat(sessions): chat session sidebar (Claudian-style), rebased onto u…
Direct-Launch Sep 27, 2026
a38f7ee
refactor(sessions): extract shared SessionList from SessionSidebar
Direct-Launch Sep 27, 2026
8c0e15e
feat(sessions): touch overflow menu for session rows
Direct-Launch Sep 27, 2026
3edcdfd
feat(sessions): SessionSheetModal hosting shared list
Direct-Launch Sep 27, 2026
af62db4
feat(sessions): mobile Sessions trigger opens the sheet
Direct-Launch Sep 27, 2026
7009969
refactor(sessions): reactive active-row via ThreadPathStore; drop dea…
Direct-Launch Sep 27, 2026
fcae8b5
fix(sessions): persist sidebar width once per gesture, not per pointe…
Direct-Launch Sep 29, 2026
886d30b
feat(sessions): per-row status indicator in the session sidebar
Direct-Launch Sep 29, 2026
d5280f1
fix(sessions): capture the resize pointer so a release outside the st…
Direct-Launch Oct 1, 2026
cf5f48a
feat(sessions): cue when a chat run settles (sound + notification)
Direct-Launch Oct 1, 2026
7b4b2b5
fix(sessions): fire the settle cue when a run pair identity is rebuilt
Direct-Launch Oct 1, 2026
deb5193
feat(sessions): name the session in the settle notification, keep the…
Direct-Launch Oct 1, 2026
44a4254
fix(sessions): persist the settled status of a thread loaded for the …
Direct-Launch Oct 1, 2026
13ef649
feat(sessions): play a custom sound file on run settle
Direct-Launch Oct 2, 2026
b01af18
style(sessions): apply Biome formatting to the sidebar files
Direct-Launch Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,6 @@ main.js

# Local research artifacts — internal, not for publication
/competitor-analysis.html
/smart-second-brain-features.html
/smart-second-brain-features.html
# SDD scratch workspace
.superpowers/
952 changes: 952 additions & 0 deletions docs/superpowers/plans/2026-09-27-s2b-session-sidebar.md

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions docs/superpowers/specs/2026-09-27-s2b-session-sidebar-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# S2B Session Sidebar — Design Spec

**Date:** 2026-09-27
**Status:** Approved (design). Implementation pending plan.
**Target:** `Direct-Launch/smart-second-brain`, branch `feat/session-sidebar` → PR into fork `main`.

## Goal

Near 1:1 replication of the **Claudian** plugin's chat-sessions sidebar inside Smart Second Brain (S2B). An in-leaf, resizable panel that lists all chat sessions with Pinned/Archived sections, search, sort, and per-row actions (open, rename, delete, pin, archive, restore).

## Decisions (approved)

1. **Placement:** in-leaf dual-pane (true 1:1), not a separate Obsidian leaf.
2. **Scope:** full parity including Pinned + Archived sections.
3. **Excluded:** Claudian's linked-content grouping (S2B has no linked-content concept).

## What already exists in S2B (reuse — no new persistence)

- **Storage:** one gzipped-JSON `.chat` file per conversation in `targetFolder` (default `Chats/`). `ThreadData` codec at `src/agent/threadDataCodec.ts` (`THREAD_DATA_VERSION = 2`). `ThreadData.metadata: Record<string, unknown>` is a free-form, persisted bag.
- **Index:** `ThreadSnapshot { threadId, title?, metadata?, createdAt, updatedAt }` (`src/agent/memory/ThreadStore.ts`). `ObsidianChatManager` keeps `threadIndex` (rebuilt from folder), `listThreads()` returns snapshots sorted by `updatedAt` desc.
- **CRUD on `AgentManager`:** `getAllThreads()` / `listThreads()`, `createNewChat()`, `deleteThread()`, `renameThread()`, `openChatByThreadId(id)`, `openInChatLeaf(file)`, `openLatestChat()`.
- **Live sessions:** `SessionRegistry` (`src/stores/chatStore.svelte.ts`) — `SvelteMap<threadPath, ChatSession>`, derived running-set, LRU parking. Per-tab active thread via `src/views/chat/threadPathStore.svelte.ts` (`ThreadPathStore.current`).
- **View:** `ChatView extends FileView`, `VIEW_TYPE_CHAT = "smart-second-brain-chat"`, mounts `Chat.svelte` (`<div class="chat-root ...">` → `<MessageContainer>` + `<Input>`). `chatOpenLocation` = tab|left|right.
- **Existing session actions:** rename/delete via file-menu/command; `new-chat` command + ribbon; vault create/delete/rename events already rekey caches.

## What to build

### A. Reactive index — `ThreadIndexStore`
`getAllThreads()` is a non-reactive snapshot. Add a Svelte `$state` store holding the current sorted `ThreadSnapshot[]`, rebuilt on:
- `.chat` file create/delete/rename (subscribe to the vault events already wired in `main.ts` / `ObsidianChatManager`).
- pin/archive metadata changes (see B).

The sidebar components subscribe to this store so all in-leaf copies stay in sync.

### B. Pinned / Archived — plugin-data-backed, no codec migration
**Storage location decided during planning:** `rebuildIndex()`/`listThreads()` only `stat` files and derive the title from the filename — they **never load per-thread `metadata`**. Storing flags in `ThreadData.metadata` would force gunzipping every `.chat` file to render the list. Instead, store flags in **plugin data**:
- `PluginData.sessionFlags: Record<threadPath, { pinned?: boolean; pinnedAt?: number; archived?: boolean }>` — O(1) whole-list read, migrated on rename, removed on delete. No codec touch.

Add to `AgentManager`:
- `setThreadPinned(id, boolean)` — delegates to the data store's flag helper.
- `setThreadArchived(id, boolean): boolean` — **guard: refuse to archive a session currently running** (check `SessionRegistry`), surface `new Notice(...)` and return `false`. Matches Claudian.

The sidebar store merges `getAllThreads()` snapshots with `getData().getSessionFlags(id)`.

### C. Sidebar UI (Svelte, in-leaf)
Mounted inside `Chat.svelte` as `[sidebar][resizer][chat surface]`, order flipped by `sessionSidebarSide`.

- **Reveal rule:** show panel when container width ≥ **600px**; below that render a **dropdown fallback** in the input row (Claudian parity).
- **Sections:** `Pinned` (max-height 50%, own scroll) + `Sessions`; an **Archived** toggle swaps the sessions section for archived items with a **Restore** action. Sort control: `last-updated` (default) | `created`.
- **Row:** title + relative date. Running spinner from `SessionRegistry`. Hover-revealed inline actions: **pin/unpin, archive, rename, delete** (archive→**restore** in archive view). Click row → `openChatByThreadId`; active row highlighted vs `ThreadPathStore.current`.
- **Search:** `type="search"` input, filters snapshot titles; empty states "No matching sessions" / "No conversations".
- **New:** `+ New` → `createNewChat()`.
- **Pagination:** `Load more (N remaining)` with a visible-count.
- **Metadata popover** (hover): **deferred to a fast-follow** — S2B stores no per-thread message count/usage (would need a per-thread gunzip), so the v1 diff stays focused on the approved core.
- Rename = inline edit; delete = confirm. Reuse existing `renameThread`/`deleteThread` + prompt/confirm helpers.

### D. Resize
CSS var `--s2b-session-sidebar-width`, default **240**, clamp **[180, containerWidth − 325]** (320 reserved for chat + 5 resizer). Pointer drag on `.s2b-session-resizer` (`role="separator"`); keyboard Arrow steps **16px**; direction flips with side. Body gets a `resizing` class during drag. Width **persisted** to plugin data (single global value).

### E. Settings
Add to the plugin data store: `enableSessionSidebar: boolean`, `sessionSidebarSide: "left" | "right"`, `sessionManagerSort: "last-updated" | "created"`, `sessionSidebarWidth: number`. Settings-tab controls for each.

### F. Styling
Port Claudian's CSS into `src/styles.css` under `s2b-`-prefixed classes, using Obsidian theme vars (`--background-modifier-hover`, `--interactive-accent`, `--text-*`, `--color-red`) so it themes automatically. Spinner + attention-pulse keyframes.

## Accepted trade-offs / risks

- **List duplication:** in-leaf placement means each open chat tab renders its own sidebar copy. Mitigated by the shared `ThreadIndexStore` (content stays in sync); DOM is repeated. Inherent to the 1:1 in-leaf choice.
- **Metadata-popover fidelity:** popover contents limited to what S2B tracks; may differ from Claudian's token usage.
- **Fork drift:** DL fork `main` is behind upstream (~2 weeks). Building on fork `main` per instruction; not merging upstream in this PR.

## Testing

Vitest (`vitest run`):
- `ThreadIndexStore` rebuilds on create/delete/rename and pin/archive.
- sort + title-filter logic (pure functions, extracted).
- pin/archive metadata **round-trip through the codec**.
- archive-guard-when-running returns error / no-op.

`npm run check` (svelte-check) + `npm run lint` (biome) must pass.

## Out of scope (fast-follow candidates)

- Linked-content grouping.
- Cross-device pin/archive reconciliation beyond what metadata sync gives for free.
20 changes: 20 additions & 0 deletions src/agent/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createAiTransportContext, runWithAiTransportContext } from "../lib/aiTr
import { invalidateProviderState } from "../lib/query";
import type SecondBrainPlugin from "../main";
import type { ChatModel } from "../stores/chatTimeline";
import { getSessionRegistry } from "../stores/chatStore.svelte";
import { getData } from "../stores/dataStore.svelte";
import { getPendingChangesStore } from "../stores/pendingChangesStore.svelte";
import { BUILT_IN_TOOL_IDS, type BuiltInToolId, type AgentConfig, type SkillMetadata } from "../types/plugin";
Expand Down Expand Up @@ -344,6 +345,25 @@ export class AgentManager {
this.chatManager = new ObsidianChatManager(plugin);
}

/** Pin/unpin a session (sidebar action). */
setThreadPinned(threadId: string, pinned: boolean): void {
getData().setSessionPinned(threadId, pinned);
}

/**
* Archive/restore a session (sidebar action). Archiving a running session is refused —
* matches Claudian: a running session must be stopped before it can be archived.
* Restoring (archived=false) is always allowed.
*/
setThreadArchived(threadId: string, archived: boolean): boolean {
if (archived && getSessionRegistry()?.sessionFor(threadId)?.isRunning) {
new Notice("Cannot archive a running session");
return false;
}
getData().setSessionArchived(threadId, archived);
return true;
}

/** Get the singleton registry instance */
private get registry(): ProviderRegistry {
return getRegistry();
Expand Down
229 changes: 229 additions & 0 deletions src/components/chat/session-sidebar/SessionList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<script lang="ts">
import { getSessionRegistry } from "../../../stores/chatStore.svelte";
import { getData } from "../../../stores/dataStore.svelte";
import { getPlugin } from "../../../stores/state.svelte";
import { getSessionSidebarStore } from "../../../stores/session-sidebar/sessionSidebarStore.svelte";
import { filterRows, partition, sortRows } from "../../../stores/session-sidebar/logic";
import { deriveRowStatus, type SessionStatus } from "../../../stores/session-sidebar/status";
import { icon } from "../../../utils/utils";
import { confirmDelete } from "../../modal/ConfirmModal";
import { promptText } from "../../modal/PromptModal";
import type { ThreadPathStore } from "../../../views/chat/threadPathStore.svelte";
import SessionSidebarItem from "./SessionSidebarItem.svelte";

interface Props {
threadPathStore: ThreadPathStore;
onOpenSession?: () => void;
}
const { threadPathStore, onOpenSession }: Props = $props();
const threadPath = $derived(threadPathStore.current);

const plugin = getPlugin();
const store = getSessionSidebarStore();
const registry = getSessionRegistry();
const data = getData();

let query = $state("");
let showArchived = $state(false);
let visibleCount = $state(50);

const filtered = $derived(filterRows(sortRows(store.rows, data.sessionManagerSort), query));
const sections = $derived(partition(filtered));
const listRows = $derived(showArchived ? sections.archived : sections.active);
const visibleRows = $derived(listRows.slice(0, visibleCount));
const remaining = $derived(Math.max(0, listRows.length - visibleCount));

/** Status for a row, from the live session when there is one and from the flag
* the settle handler persisted when there is not. See `deriveRowStatus`. */
function statusFor(id: string): SessionStatus {
const session = registry?.sessionFor(id);
return deriveRowStatus(
session?.isRunning ?? false,
session?.lastAssistantState,
data.getSessionFlags(id).lastStatus,
);
}

async function newChat() {
await plugin.agentManager.createNewChat();
}

async function open(id: string) {
await plugin.agentManager.openChatByThreadId(id);
onOpenSession?.();
}

async function del(id: string, title: string) {
if (await confirmDelete(plugin.app, title)) await plugin.agentManager.deleteThread(id);
}

async function rename(id: string, title: string) {
const next = await promptText(plugin.app, "Rename chat", title, "Rename");
if (next && next !== title) await plugin.agentManager.renameThread(id, next);
}

function togglePin(id: string, pinned: boolean) {
plugin.agentManager.setThreadPinned(id, !pinned);
void store.refresh();
}

function toggleArchive(id: string, archived: boolean) {
const ok = plugin.agentManager.setThreadArchived(id, !archived);
if (ok) void store.refresh();
}
</script>

<div class="s2b-session-header">
<button class="s2b-session-new" onclick={newChat} type="button">
<span use:icon={"plus"}></span>
New chat
</button>
<button
class="s2b-session-archive-toggle"
aria-label={showArchived ? "Show sessions" : "Show archived"}
class:s2b-session-archive-toggle-active={showArchived}
onclick={() => (showArchived = !showArchived)}
type="button"
>
<span use:icon={"archive"}></span>
</button>
</div>

<div class="s2b-session-controls">
<input
class="s2b-session-search"
type="search"
placeholder={showArchived ? "Search archived sessions" : "Search sessions"}
aria-label={showArchived ? "Search archived sessions" : "Search sessions"}
bind:value={query}
autocomplete="off"
/>
<select
class="s2b-session-sort dropdown"
aria-label="Sort sessions"
bind:value={data.sessionManagerSort}
>
<option value="last-updated">Last updated</option>
<option value="created">Created</option>
</select>
</div>

{#if !showArchived && sections.pinned.length}
<div class="s2b-session-section s2b-session-section-pinned">
<div class="s2b-session-section-label">Pinned</div>
{#each sections.pinned as row (row.threadId)}
<SessionSidebarItem
{row}
active={row.threadId === threadPath}
status={statusFor(row.threadId)}
archivedView={false}
onOpen={() => 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}
</div>
{/if}

<div class="s2b-session-section s2b-session-section-list">
<div class="s2b-session-section-label">{showArchived ? "Archived" : "Sessions"}</div>
{#if visibleRows.length === 0}
<div class="s2b-session-empty">{query ? "No matching sessions" : "No conversations"}</div>
{/if}
{#each visibleRows as row (row.threadId)}
<SessionSidebarItem
{row}
active={row.threadId === threadPath}
status={statusFor(row.threadId)}
archivedView={showArchived}
onOpen={() => 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}
<button class="s2b-session-load-more" onclick={() => (visibleCount += 50)} type="button">
Load more ({remaining} remaining)
</button>
{/if}
</div>

<style>
.s2b-session-header {
display: flex;
align-items: center;
gap: var(--size-2-2);
padding: var(--size-4-2);
flex: 0 0 auto;
}

.s2b-session-new {
display: flex;
align-items: center;
gap: var(--size-2-2);
flex: 1 1 auto;
justify-content: center;
}

.s2b-session-archive-toggle {
flex: 0 0 auto;
}

.s2b-session-archive-toggle-active {
color: var(--text-accent);
}

.s2b-session-controls {
display: flex;
gap: var(--size-2-2);
padding: 0 var(--size-4-2) var(--size-4-2);
flex: 0 0 auto;
}

.s2b-session-search {
flex: 1 1 auto;
min-width: 0;
}

.s2b-session-sort {
flex: 0 0 auto;
}

.s2b-session-section {
display: flex;
flex-direction: column;
overflow: hidden;
}

.s2b-session-section-pinned {
flex: 0 1 auto;
max-height: 50%;
overflow-y: auto;
}

.s2b-session-section-list {
flex: 1 1 auto;
overflow-y: auto;
}

.s2b-session-section-label {
padding: var(--size-2-1) var(--size-4-2);
color: var(--text-faint);
font-size: var(--font-ui-smaller);
text-transform: uppercase;
}

.s2b-session-empty {
padding: var(--size-4-2);
color: var(--text-faint);
font-size: var(--font-ui-small);
}

.s2b-session-load-more {
margin: var(--size-2-2) var(--size-4-2);
}
</style>
35 changes: 35 additions & 0 deletions src/components/chat/session-sidebar/SessionSheetModal.ts
Original file line number Diff line number Diff line change
@@ -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",
},
);
}
}
Loading
Loading