feat(f8): wire SidecarRouter; authoring commands live folder-less (#19 precedent)

Spec §6.4: construct GlobalSidecarStore + SidecarRouter (CoauthorStore when root);
remove the no-root early-return + command stubs so F2/F3/F4 are real folder-less;
renderIfOpen gated by isAuthorable; editor-context menus widened to untitled;
export sidecarRouter on CowritingApi. activate now always returns the API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-11 13:13:26 -07:00
parent 7387366e28
commit 7892e2fe87
2 changed files with 39 additions and 50 deletions
+2 -2
View File
@@ -112,12 +112,12 @@
"editor/context": [
{
"command": "cowriting.editSelection",
"when": "editorHasSelection && resourceScheme == file",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
"group": "1_cowriting@1"
},
{
"command": "cowriting.createThread",
"when": "editorHasSelection && resourceScheme == file",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
"group": "1_cowriting@2"
}
],
+37 -48
View File
@@ -7,9 +7,11 @@ import { ProposalController } from "./proposalController";
import { buildFingerprint } from "./anchorer";
import { VersionGuard } from "./versionGuard";
import { BaselineStore } from "./baselineStore";
import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { isUnderRoot, selectionRejection } from "./workspacePath";
import { isAuthorable, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -20,6 +22,7 @@ export interface CowritingApi {
versionGuard: VersionGuard;
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
sidecarRouter: SidecarRouter;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -59,6 +62,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const diffViewController = new DiffViewController(baselineStore);
context.subscriptions.push(diffViewController);
// F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage
// home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed
// workspace-independently; the router falls back to it for any non-in-folder
// doc. When globalStorageUri is unavailable, file writes throw (authoring
// degrades to errors-on-write, the F6-equivalent edge) but untitled in-memory
// still works — PUC-5.
const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? "");
// --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT ---
// Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it
// is constructed regardless of an open folder and its command is always live.
@@ -69,64 +80,42 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
);
context.subscriptions.push(trackChangesPreviewController);
// --- F2: region-anchored threads (Feature #4) ---
// --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) ---
// The router routes per-document: in-workspace file: → the committable repo
// `.threads/` sidecar (CoauthorStore, INV-2); out-of-folder file: + untitled:
// → the global-storage sidecar (GlobalSidecarStore). Constructed even with NO
// folder open (everything then routes global) — the F6 #19 precedent, now
// extended to authoring (F2 threads, F3 attribution, F4 propose/accept).
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!root) {
// No folder open → nothing to anchor against, but every contributed
// command must still exist: leave them unregistered and the palette
// errors with "command not found" (#8). Register warning stubs instead;
// opening a folder reloads the window, re-running activate with a root.
const stub = () =>
void vscode.window.showWarningMessage(
"Cowriting: open a folder first — coauthoring anchors threads and attribution to workspace files.",
);
for (const command of [
"cowriting.createThread",
"cowriting.reply",
"cowriting.resolveThread",
"cowriting.reopenThread",
"cowriting.editSelection",
"cowriting.toggleAttribution",
"cowriting.applyAgentEdit",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
"cowriting.proposeAgentEdit",
]) {
context.subscriptions.push(vscode.commands.registerCommand(command, stub));
}
// F6 (toggleDiffView / pinDiffBaseline) is NOT stubbed here — it works
// without a folder (on untitled buffers and out-of-folder files); its real
// commands were registered above by DiffViewController.
return undefined;
}
const store = new CoauthorStore(root);
const coauthorStore = root ? new CoauthorStore(root) : null;
const sidecarRouter = new SidecarRouter(coauthorStore, globalSidecarStore, root);
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
// warning per doc across the three co-owning controllers.
const versionGuard = new VersionGuard(store);
const threadController = new ThreadController(store, root, versionGuard);
const versionGuard = new VersionGuard(sidecarRouter);
const threadController = new ThreadController(sidecarRouter, root, versionGuard);
context.subscriptions.push(threadController);
// --- F3: live attribution (Feature #6) ---
const attributionController = new AttributionController(store, root, versionGuard);
const attributionController = new AttributionController(sidecarRouter, root, versionGuard);
context.subscriptions.push(attributionController);
// --- F4: propose/accept (Feature #12) ---
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard);
context.subscriptions.push(proposalController);
// --- F6 machine-landing wiring (with-root only) ---
// The seam's single machine-landing signal advances the baseline (INV-18).
// The seam fires only on workspace files, so this wiring belongs here; the
// DiffViewController itself was constructed above (workspace-independent).
// --- F6 machine-landing wiring — now for ANY authorable doc ---
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
context.subscriptions.push(
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
);
// One SHARED sidecar watcher for both controllers; self-writes are
// suppressed centrally in the store (the sidecar is co-owned).
// One SHARED sidecar watcher for both controllers; self-writes are suppressed
// centrally in the repo store (only repo `.threads/` sidecars are watched —
// global artifacts live outside the workspace). Harmless when no folder is open.
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
const onSidecar = (uri: vscode.Uri) => {
if (store.consumeSelfWrite(uri.fsPath)) return;
if (sidecarRouter.consumeSelfWrite(uri.fsPath)) return;
threadController.handleExternalSidecarChange(uri);
attributionController.handleExternalSidecarChange(uri);
proposalController.handleExternalSidecarChange(uri);
@@ -188,15 +177,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.editSelection", async () => {
const editor = vscode.window.activeTextEditor;
// Each failure names its real reason (no editor / no selection / unsaved /
// outside the workspace folder) — not always "select some text" (#bug:
// an out-of-workspace file was rejected with the no-selection message).
// F8: authoring works on any file: or untitled: doc (the router decides
// where its artifact is stored). Each failure still names its real reason
// (no editor / no selection / a non-{file,untitled} read-only view) — not
// always "select some text" (#24's per-condition messaging).
const reason = selectionRejection({
hasEditor: !!editor,
selectionEmpty: editor?.selection.isEmpty ?? true,
scheme: editor?.document.uri.scheme ?? "",
fsPath: editor?.document.uri.fsPath ?? "",
root,
});
if (reason) {
void vscode.window.showWarningMessage(reason);
@@ -269,7 +257,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
// Render threads + attributions for already-open editors, and on future opens.
const renderIfOpen = (doc: vscode.TextDocument) => {
if (doc.uri.scheme === "file" && isUnderRoot(doc.uri.fsPath, root)) {
if (isAuthorable(doc.uri.scheme)) {
threadController.renderAll(doc);
attributionController.loadAll(doc);
proposalController.renderAll(doc);
@@ -285,6 +273,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
versionGuard,
diffViewController,
trackChangesPreviewController,
sidecarRouter,
};
}