From bc77cee0bd2295504361b180b3b3a771e68b9216 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 13:06:58 -0700 Subject: [PATCH] feat(f8): isAuthorable + widened selectionRejection (file|untitled) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §6.4: replace the scheme!=file / !isUnderRoot rejections with a single !isAuthorable branch. isUnderRoot retained as a routing input. Per-condition messaging (#24) kept for no-editor / empty-selection / non-{file,untitled}. Co-Authored-By: Claude Opus 4.8 --- src/workspacePath.ts | 32 +++++++++++++++----------- test/workspacePath.test.ts | 47 +++++++++++++++++++++----------------- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/src/workspacePath.ts b/src/workspacePath.ts index 5c61292..9a0e608 100644 --- a/src/workspacePath.ts +++ b/src/workspacePath.ts @@ -20,6 +20,17 @@ export function isUnderRoot(fsPath: string, root: string): boolean { return fsPath === root || fsPath.startsWith(root + path.sep); } +/** + * Documents Cowriting can author on: a saved file OR an unsaved buffer (F8 §6.2). + * F8 widened membership from "saved file under the workspace folder" to any + * `file:`/`untitled:` doc — the SidecarRouter then routes where its artifact is + * stored (repo `.threads/` in-workspace, global storage otherwise). `isUnderRoot` + * (above) is retained as that ROUTING input, no longer an authoring gate. + */ +export function isAuthorable(scheme: string): boolean { + return scheme === "file" || scheme === "untitled"; +} + export interface SelectionContext { /** Is there an active text editor at all? */ hasEditor: boolean; @@ -27,17 +38,15 @@ export interface SelectionContext { selectionEmpty: boolean; /** The active document's URI scheme (`file`, `untitled`, …). */ scheme: string; - /** The active document's filesystem path (empty if none). */ - fsPath: string; - /** The workspace folder Cowriting is anchored to. */ - root: string; } /** - * Why a selection can't be sent to Claude — or `null` if it can. Each condition - * gets its OWN message so the user learns the real reason instead of always - * being told to "select some text" (the reported bug: an out-of-workspace file - * was rejected with the no-selection message even though text was selected). + * Why a selection can't be sent to Claude — or `null` if it can. F8 widened the + * membership: any `file:` or `untitled:` document is authorable (in-workspace + * files persist to the repo `.threads/` sidecar, out-of-workspace/untitled to + * global storage — the router decides). Only a non-{file,untitled} scheme (a + * read-only `git:`/`output:` view), a missing editor, or an empty selection is + * refused — each with its OWN message (#24's per-condition messaging). */ export function selectionRejection(ctx: SelectionContext): string | null { if (!ctx.hasEditor) { @@ -46,11 +55,8 @@ export function selectionRejection(ctx: SelectionContext): string | null { if (ctx.selectionEmpty) { return "Cowriting: select some text to send to Claude first."; } - if (ctx.scheme !== "file") { - return "Cowriting: save this document to a file first — Cowriting anchors edits to a saved workspace file."; - } - if (!isUnderRoot(ctx.fsPath, ctx.root)) { - return "Cowriting: this file is outside your workspace folder — open a file inside it to ask Claude to edit (Cowriting anchors edits and attribution to files in the workspace)."; + if (!isAuthorable(ctx.scheme)) { + return "Cowriting: this kind of document can't be edited — Cowriting authors on a file or an untitled buffer, not a read-only view."; } return null; } diff --git a/test/workspacePath.test.ts b/test/workspacePath.test.ts index 55b3a74..59e11e9 100644 --- a/test/workspacePath.test.ts +++ b/test/workspacePath.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isUnderRoot, selectionRejection } from "../src/workspacePath"; +import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath"; describe("isUnderRoot", () => { const root = "/a/vscode-cowriting-plugin/sandbox"; @@ -24,32 +24,37 @@ describe("isUnderRoot", () => { }); }); -describe("selectionRejection", () => { - const root = "/ws"; - const ok = { hasEditor: true, selectionEmpty: false, scheme: "file", fsPath: "/ws/a.md", root }; - - it("returns null when everything is valid", () => { - expect(selectionRejection(ok)).toBeNull(); +describe("isAuthorable", () => { + it("accepts file: and untitled: schemes", () => { + expect(isAuthorable("file")).toBe(true); + expect(isAuthorable("untitled")).toBe(true); }); + it("rejects read-only / virtual schemes", () => { + expect(isAuthorable("git")).toBe(false); + expect(isAuthorable("output")).toBe(false); + expect(isAuthorable("cowriting-baseline")).toBe(false); + expect(isAuthorable("")).toBe(false); + }); +}); +describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)", () => { + const ok = { hasEditor: true, selectionEmpty: false, scheme: "file" }; + + it("accepts an in-folder / out-of-folder file (file: scheme)", () => { + expect(selectionRejection({ ...ok, scheme: "file" })).toBeNull(); + }); + it("accepts an untitled buffer (no longer rejected)", () => { + expect(selectionRejection({ ...ok, scheme: "untitled" })).toBeNull(); + }); it("names the missing editor", () => { - const msg = selectionRejection({ ...ok, hasEditor: false }); - expect(msg).toMatch(/focus a text editor/i); + expect(selectionRejection({ ...ok, hasEditor: false })).toMatch(/focus a text editor/i); }); - it("names the empty selection (only when there IS an editor)", () => { - const msg = selectionRejection({ ...ok, selectionEmpty: true }); - expect(msg).toMatch(/select some text/i); + expect(selectionRejection({ ...ok, selectionEmpty: true })).toMatch(/select some text/i); }); - - it("names an unsaved/non-file document", () => { - const msg = selectionRejection({ ...ok, scheme: "untitled" }); - expect(msg).toMatch(/save this document/i); - }); - - it("names an out-of-workspace file (the reported bug — NOT a selection problem)", () => { - const msg = selectionRejection({ ...ok, fsPath: "/elsewhere/a.md" }); - expect(msg).toMatch(/outside your workspace folder/i); + it("rejects a non-{file,untitled} scheme with its own message (not 'select some text')", () => { + const msg = selectionRejection({ ...ok, scheme: "git" }); + expect(msg).toMatch(/can.?t be edited|read-only|not a file/i); expect(msg).not.toMatch(/select some text/i); }); });