From 59ecb011c0316edbfbdb9eb4bb869157fbf86351 Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 9 Aug 2026 22:54:56 +0800 Subject: [PATCH 1/2] feat(desktop): remove threads through the protocol, not around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar archived and deleted session files through Tauri while the app-server served the same threads. That is not only a second reader — the app-server is the single owner and writer of thread storage, so a renderer deleting files behind it can pull the ground out from under an open writer or leave the index pointing at something gone. Three call sites, not the two THREE_WAY_REVIEW recorded. `window.deepcode. sessions.list()` has preferred the protocol since #231, but `Sidebar.tsx` bypassed the shim and called `listSessions()` directly — so the list was a second reader too, with its own row shape and its own sort. `thread/delete` is new. The protocol could list, fork and archive but not delete, which is why delete had nowhere to go. It sits under the existing `threadManagement` capability and mirrors `archive`: 404 on a thread that does not exist rather than silently succeeding, and a store that cannot delete says so instead of quietly archiving instead — being helpful about a destructive verb by doing a different one is the worst available answer. Deleting removes both representations. The protocol snapshot and the canonical session projection share an id and are two views of one thing, and the composite `list` reads both; removing one left the row reappearing on the next refresh as an empty session that could not be opened. `SessionManager.delete` takes the stream, the legacy stream, the meta sidecar, the writer lock and the per-session directory — the listing reads the sidecar, so leaving it behind is not a tidy half-delete. The Tauri commands stay as the fallback for a sidecar too old to serve the methods, matching what #231 established for `list`. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 18 +++++++ apps/desktop/src/components/Sidebar.tsx | 47 ++++++++++++------ apps/desktop/src/lib/protocol-agent.test.ts | 38 +++++++++++++++ apps/desktop/src/lib/protocol-agent.ts | 11 +++++ apps/desktop/src/lib/window-shim.ts | 25 ++++++++++ apps/desktop/src/preview-app.tsx | 7 +++ apps/desktop/src/types/global.d.ts | 4 ++ apps/server/src/server.ts | 2 + apps/server/src/store.test.ts | 54 +++++++++++++++++++++ apps/server/src/store.ts | 21 +++++++- docs/THREE_WAY_REVIEW.md | 8 ++- packages/core/src/sessions/manager.ts | 5 ++ packages/core/src/sessions/storage.ts | 26 ++++++++++ packages/protocol/src/runtime.test.ts | 25 ++++++++++ packages/protocol/src/runtime.ts | 31 ++++++++++++ packages/protocol/src/types.ts | 8 +-- 16 files changed, 309 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad8e1c..952ec3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,16 @@ 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 +52,14 @@ 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. - **`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 && (