feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR (#3135)

* feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR

Projectless-chat worktrees were hard-pinned to
<home>/.config/openchamber/chats: the UI joined the path client-side,
workspace checks allowed only the config root, and identification matched
the literal path segment. When the OpenCode server runs as a separate
user (UID-separated setups), that root is unreachable — every chat
session answered HTTP 500 (EACCES on the session directory).

The server now owns the chats root. OPENCHAMBER_CHATS_DIR relocates it
(default unchanged: <config root>/chats); /api/fs/home answers
{ home, chatsRoot }; fs workspace checks accept the managed chats root
next to the config root; the client resolves the root from the server
(per-runtime cached, warmed at bootstrap so sync classification sees it)
and falls back to the home join for older servers.

Refs #3130

* chore: trim added comments to local precedent

* fix: forward managedChatsRoot through feature-routes-runtime to registerFsRoutes

* fix(chats): await the root warm-up and keep the legacy chats root owned

Review feedback on #3135:

- bootstrapGlobal now awaits warmChatsRootDirectory, so synchronous
  session classification never sees an empty root cache (relocated
  sessions were grouped as project sessions when the session list
  outran /api/fs/home).
- managedProjectRoots keeps the legacy <config root>/chats entry next to
  OPENCHAMBER_CHATS_DIR, so memory ownership of existing chats survives
  relocation.

* fix(chats): distinguish chats-root fetch failure from older servers

* fix(sync): rehydrate managed chat sessions after the chats root warms

* fix(fs): pass managed roots through the symlink and git-dirs path checks after the main merge

* docs: drop changelog edits; changelog is the maintainer's release-time work

* fix(chats): keep legacy chat directories deletable while the root is relocated

* fix(chats): resolve roots before cleanup and initial session loads

* test(chats): type runtime spies against actual SDK contracts

---------

Signed-off-by: Steffen Mächtel <info@steffen-maechtel.de>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Steffen Mächtel
2026-09-05 19:24:52 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 1d6b15bc04
commit 3df97908fe
21 changed files with 602 additions and 133 deletions
+2 -2
View File
@@ -386,11 +386,11 @@ metadata and the next authoritative load reconciles it.
### Managed chat directories
Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under `~/.config/openchamber/chats/YYYY-MM-DD/session-<id>` before creating the OpenCode session. The shared `~/.config/openchamber/chats` root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion removes that managed directory and never removes project directories.
Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under the server-resolved managed chats root (`OPENCHAMBER_CHATS_DIR`, default `~/.config/openchamber/chats`) as `YYYY-MM-DD/session-<id>` before creating the OpenCode session. That root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion accepts only descendants of the configured root or the actual server home's legacy chats root. It rejects both shared roots themselves, dot segments, lookalike paths elsewhere, and a runtime switch during root resolution. It never removes project directories.
Typing the first character in a managed Chat draft starts one deduplicated directory preparation for that draft. Materialization consumes the prepared directory before `createSession`, removing filesystem creation from the usual submit path. Closing the draft, changing it to a project target, or completing preparation after the runtime/draft changed deletes the unclaimed directory. A create failure also deletes the consumed directory.
The global sessions store persists and hydrates one bounded, runtime-scoped startup snapshot containing only active managed chat sessions. Every global session surface, including the main sidebar and Electron Mini Chat switcher, sees that stale snapshot while the global list is unresolved or failed; the first authoritative global snapshot replaces it. Runtime reset to idle must hydrate rather than erase the destination runtime's snapshot; authoritative empty, archive, and delete updates do persist the resulting empty or reduced list.
The global sessions store persists and hydrates one bounded, runtime-scoped startup snapshot containing only active managed chat sessions. Every global session surface, including the main sidebar and Electron Mini Chat switcher, sees that stale snapshot while the global list is unresolved or failed; the first authoritative global snapshot replaces it. Full and directory-scoped global loads resolve the active server's chats roots before fetching or classifying sessions. The store hydrates its saved snapshot before leaving idle, preserving any newer mutations. Root lookup failure preserves the snapshot for a later retry; a failed global request retains the hydrated sessions. Old runtime completions cannot hydrate or fetch for the destination runtime. Persistence waits for root authority; before hydration it overlays explicit mutations onto the saved seed rather than replacing it with a partial list. Hydration happens once per runtime, so a later global load cannot undo an earlier directory refresh. Runtime reset to idle must hydrate rather than erase the destination runtime's snapshot; authoritative empty, archive, and delete updates do persist the resulting empty or reduced list.
VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively.
@@ -82,6 +82,7 @@ mock.module("@/lib/opencode/client", () => ({
opencodeClient: {
getDirectory: () => null,
getFilesystemHome: mock(async () => "/home/test"),
getFilesystemHomeInfo: async () => ({ home: "/home/test" }),
createDirectory: mock(async (path: string) => ({ success: true, path })),
setDirectory: mock(() => undefined),
},
+3
View File
@@ -3,6 +3,7 @@ import { retry } from "./retry"
import type { GlobalState, State } from "./types"
import { runtimeFetch } from "../lib/runtime-fetch"
import { emitSyncConfigChanged } from "./sync-refs"
import { warmChatsRootDirectory } from "../lib/chatDirectories"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -68,6 +69,8 @@ export async function bootstrapGlobal(
sdk: OpencodeClient,
set: (patch: Partial<GlobalState>) => void,
) {
// Sync chat classification needs the root before session lists load.
await warmChatsRootDirectory()
const results = await Promise.allSettled([
retry(() => sdk.path.get().then((x) => set({ path: unwrap(x, "path.get") }))),
retry(() => sdk.global.config.get().then((x) => set({ config: unwrap(x, "global.config.get") }))),
+7 -1
View File
@@ -1,3 +1,5 @@
import { opencodeClient } from '@/lib/opencode/client';
import { ensureChatsRootDirectory } from '@/lib/chatDirectories';
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { switchRuntimeEndpoint } from "@/lib/runtime-switch"
@@ -69,10 +71,14 @@ const session = (
time: { created: updated - 1, updated },
} as Session)
beforeEach(() => {
beforeEach(async () => {
storage = new TestStorage()
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage })
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-default.test", runtimeKey: "runtime-default" })
const originalHomeInfo = opencodeClient.getFilesystemHomeInfo
opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/home/user' })
await ensureChatsRootDirectory()
opencodeClient.getFilesystemHomeInfo = originalHomeInfo
})
afterEach(() => {
@@ -1,3 +1,4 @@
import { ensureChatsRootDirectory } from '@/lib/chatDirectories';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { opencodeClient } from '@/lib/opencode/client';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -1258,3 +1259,8 @@ describe('sendMessage effort record', () => {
expect(readRecord()).toBe(undefined);
});
});
const originalHomeInfo = opencodeClient.getFilesystemHomeInfo;
opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/Users/tester' });
await ensureChatsRootDirectory();
opencodeClient.getFilesystemHomeInfo = originalHomeInfo;