diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad8e1c..6104501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 so anyone relying on the old behaviour must add rules — or start the server with an explicit `--mode`. `--sandbox` also applies now; it did not before. +### ✨ Added + +- **`thread/delete`** — the protocol could list, fork and archive threads but not + delete one, so the desktop deleted session files through Tauri instead. The + app-server is the single owner of thread storage; a client removing files + behind it can pull the ground out from under an open writer. Served under the + existing `threadManagement` capability, with the local writer kept as the + fallback for a sidecar too old to know the method. + ### 🔒 Security - **A sub-agent did not inherit the file contract.** The `Task` delegation @@ -42,6 +51,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 🐛 Fixed +- **The desktop sidebar was a second reader of the session directory.** Archive + and delete went through Tauri while the protocol served the same threads, and + the list did too — `window.deepcode.sessions.list()` had preferred the + protocol since #231, but `Sidebar.tsx` bypassed the shim and called + `listSessions()` directly. All three now go through the owner, and deleting a + thread removes both its protocol snapshot and its canonical session + projection: `list` reads both, so removing one left the row reappearing on the + next refresh as an empty session that could not be opened. +- `deleteSession` refuses a session id that is not a single path segment, before + removing anything. It ends in a recursive delete of `/`, and `..` — + the id that resolves to the directory _above_ the sessions root — is spelled + entirely in characters an id may legitimately contain, so the character-class + check both it and the thread store relied on admitted it. Every in-tree caller + validates first; a delete this destructive should not depend on that. - **`Grep` over a single file no longer prefixes every line with a colon.** ripgrep omits the filename when the search path is one _file_ — there is nothing to disambiguate — so its `--null` output carries no NUL, and rejoining diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 0955756..744ffcc 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -6,13 +6,7 @@ import { useCallback, useEffect, useState, type JSX } from 'react'; import { projectName } from '../lib/project.js'; -import { - listSessions, - sessionArchive, - sessionDelete, - sessionSetTitle, - type SessionMeta, -} from '../lib/tauri-api.js'; +import { sessionSetTitle } from '../lib/tauri-api.js'; import { BrandMark } from './BrandMark.js'; interface SidebarProps { @@ -28,6 +22,20 @@ interface SidebarProps { onSessionRemoved?: (id: string) => void; } +/** + * One row, as the app-server describes it. + * + * The sidebar used to read the session directory through Tauri while + * `window.deepcode.sessions.list()` read the same directory through the + * protocol — two readers of one directory, each with its own row shape and its + * own sort. This is the one the owner of that storage returns. + */ +interface SessionRow { + id: string; + title?: string; + updatedAtSecs: number; +} + type Bucket = 'Today' | 'Yesterday' | 'Earlier'; function bucketFor(updatedAtSecs: number, nowSecs: number): Bucket { @@ -53,7 +61,7 @@ export function Sidebar({ onSwitchProject, onSessionRemoved, }: SidebarProps): JSX.Element { - const [sessions, setSessions] = useState([]); + const [sessions, setSessions] = useState([]); const [now, setNow] = useState(Math.floor(Date.now() / 1000)); // Inline rename: which session is being edited + its draft title. const [editingId, setEditingId] = useState(null); @@ -61,8 +69,17 @@ export function Sidebar({ const [query, setQuery] = useState(''); const reload = useCallback(() => { - void listSessions() - .then(setSessions) + void window.deepcode.sessions + .list() + .then((rows) => + setSessions( + rows.map((row) => ({ + id: row.id, + title: row.title, + updatedAtSecs: Math.floor(new Date(row.updatedAt).getTime() / 1000), + })), + ), + ) .catch(() => setSessions([])); }, []); @@ -92,7 +109,7 @@ export function Sidebar({ async function handleArchive(id: string): Promise { try { - await sessionArchive(id); + await window.deepcode.sessions.archive({ id }); if (id === activeSessionId) onSessionRemoved?.(id); reload(); } catch { @@ -105,7 +122,7 @@ export function Sidebar({ return; } try { - await sessionDelete(id); + await window.deepcode.sessions.delete({ id }); if (id === activeSessionId) onSessionRemoved?.(id); reload(); } catch { @@ -119,13 +136,13 @@ export function Sidebar({ (s) => (s.title || '').toLowerCase().includes(q) || s.id.toLowerCase().includes(q), ) : sessions; - const grouped: Record = { + const grouped: Record = { Today: [], Yesterday: [], Earlier: [], }; for (const s of visible) { - grouped[bucketFor(s.updated_at_secs, now)].push(s); + grouped[bucketFor(s.updatedAtSecs, now)].push(s); } return ( @@ -227,7 +244,7 @@ export function Sidebar({ ) : ( {s.title?.trim() ? s.title : shortTitle(s.id)} )} - {relTime(s.updated_at_secs, now)} + {relTime(s.updatedAtSecs, now)} {editingId !== s.id && (