Skip to content

Commit 663e0a4

Browse files
fix(workspace): stop the TUI sending people to a picker that lists nothing (#1390)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 61b744c commit 663e0a4

4 files changed

Lines changed: 117 additions & 7 deletions

File tree

‎packages/opencode/src/altimate/workspace/api-client.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ export const HIDDEN_BINDING_MESSAGE =
101101
"This project is already linked to a workspace you can't see, most likely a teammate's private one. " +
102102
"Ask its owner to share it with you in the Altimate web app, or to unlink the project, then run `altimate-code link` again."
103103

104+
/** The other half of the same story, told to the person who creates the workspace.
105+
* A create from the CLI or the TUI is always `privacy: "private"`, and the server hides a
106+
* private workspace's binding from everyone else — so without this note the creator has no
107+
* way to know that the teammate who clones the repo next will be told it is unlinked, and
108+
* will hit `HIDDEN_BINDING_MESSAGE` with no idea who to ask. Lives here, beside that
109+
* message, because both the CLI command and the TUI plugin have to say it and neither can
110+
* import the other. */
111+
export const QUICK_WORKSPACE_PRIVATE_NOTE =
112+
"Only you can see this workspace. Share it from its page in the Altimate web app so teammates who clone this repo are attached to it too."
113+
104114
export function isHiddenBindingConflict(err: unknown): boolean {
105115
// A binding conflict always names the existing workspace's id; a 409 without one is some
106116
// other conflict and must not be explained as a teammate's private workspace.

‎packages/opencode/src/cli/cmd/link.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
ConflictError,
2121
HIDDEN_BINDING_MESSAGE,
2222
isHiddenBindingConflict,
23+
QUICK_WORKSPACE_PRIVATE_NOTE,
2324
ForbiddenError,
2425
NotConfiguredError,
2526
NotFoundError,
@@ -796,9 +797,6 @@ async function bindOrRebind(
796797
}
797798
}
798799

799-
export const QUICK_WORKSPACE_PRIVATE_NOTE =
800-
"Only you can see this workspace. Share it from its page in the Altimate web app so teammates who clone this repo are attached to it too."
801-
802800
/** What `link` says about this machine's saved memory after the bind. A seed that left
803801
* blocks behind used to print the same line as one that stored everything. */
804802
export function seedMessage(seed: SeedOutcome | null): string {

‎packages/opencode/src/plugin/tui/altimate/workspace.tsx‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
ConflictError,
3737
HIDDEN_BINDING_MESSAGE,
3838
isHiddenBindingConflict,
39+
QUICK_WORKSPACE_PRIVATE_NOTE,
3940
ForbiddenError,
4041
NotFoundError,
4142
PreconditionFailedError,
@@ -294,10 +295,14 @@ interface LinkedProps {
294295
function WorkspaceLinkedDialog(props: LinkedProps) {
295296
const title = () => {
296297
const suffix = props.manageUrl ? ` — ${props.manageUrl}` : ""
298+
// "Created" is the only verb that just made a NEW workspace, and every create from here
299+
// is private. The other two verbs bound an existing workspace whose privacy the user
300+
// already chose in the SaaS, so the note would be wrong for them.
301+
const privacy = props.verb === "Created" ? ` ${QUICK_WORKSPACE_PRIVATE_NOTE}` : ""
297302
// DialogSelect doesn't take a top-level description block, so the
298303
// memory-sync disclosure is packed into the title, matching the
299304
// AlreadyLinkedDialog convention above.
300-
return `${props.verb} workspace "${props.workspaceName}"${suffix} — Saved memory blocks will sync to this workspace if memory is enabled for it.`
305+
return `${props.verb} workspace "${props.workspaceName}"${suffix} — Saved memory blocks will sync to this workspace if memory is enabled for it.${privacy}`
301306
}
302307
const options = () => {
303308
if (props.manageUrl) {
@@ -548,7 +553,14 @@ export async function createAndBindInline(
548553
if (err instanceof ConflictError && !rebindFrom) {
549554
api.ui.toast({
550555
variant: "warning",
551-
message: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Use the palette's "Link this project to a workspace" to change.`,
556+
// A withheld name means the binding belongs to a workspace this user cannot see, so
557+
// the palette it would otherwise point at lists nothing to pick — the advice sent
558+
// them round a loop with no exit. The three other conflict toasts in this file already
559+
// branch here; this one did not.
560+
message: isHiddenBindingConflict(err)
561+
? HIDDEN_BINDING_MESSAGE
562+
: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Use the palette's "Link this project to a workspace" to change.`,
563+
duration: 15_000,
552564
})
553565
} else {
554566
api.ui.toast({
@@ -618,7 +630,11 @@ export async function createAndBindInline(
618630
log.warn("workspace post-create confirmation failed", { err: String(err) })
619631
api.ui.toast({
620632
variant: "info",
621-
message: `Workspace "${res.datamate.name}" created and linked.`,
633+
// The dialog that would have carried the note never rendered, and this toast is all
634+
// the user gets — so it says the whole thing rather than dropping the half that asks
635+
// them to act.
636+
message: `Workspace "${res.datamate.name}" created and linked. ${QUICK_WORKSPACE_PRIVATE_NOTE}`,
637+
duration: 15_000,
622638
})
623639
}
624640
}

‎packages/opencode/test/altimate/workspace/create-then-rebind.test.ts‎

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ const { createAndBindInline, bindOrRebindInline, runWorkspaceManage } = await im
4848
"@/plugin/tui/altimate/workspace"
4949
)
5050
const { recordApprovedBinding } = await import("@/altimate/workspace/state")
51-
const { HIDDEN_BINDING_MESSAGE } = await import("@/altimate/workspace/api-client")
51+
const { HIDDEN_BINDING_MESSAGE, QUICK_WORKSPACE_PRIVATE_NOTE } = await import(
52+
"@/altimate/workspace/api-client"
53+
)
5254

5355
const ORIGINAL_FETCH = globalThis.fetch
5456

@@ -293,6 +295,90 @@ describe("TUI: hidden-binding conflict toast", () => {
293295
expect(toasts.some((t) => t.message === HIDDEN_BINDING_MESSAGE)).toBe(true)
294296
expect(toasts.some((t) => /claimed this project while you were choosing/i.test(t.message))).toBe(false)
295297
})
298+
299+
test("the quick-create row says the same thing — it used to point at a picker with nothing in it", async () => {
300+
routes = [
301+
{
302+
match: /datamate-project-bindings/,
303+
method: "POST",
304+
status: 409,
305+
body: { detail: { message: "already linked", existing_datamate_id: 7 } }, // no existing_datamate_name
306+
},
307+
]
308+
const { api, toasts } = stubApi()
309+
// No `rebindFrom`: the project looks unlinked to this user, because the binding that
310+
// owns it belongs to a workspace they cannot see. This is Bob, a minute after cloning.
311+
await createAndBindInline(api, IDENTIFIER, "proj")
312+
expect(toasts.some((t) => t.message === HIDDEN_BINDING_MESSAGE)).toBe(true)
313+
// The advice that sent him round the loop: a picker scoped to workspaces he can see
314+
// lists none of them, so "use the palette to change" had nothing to offer.
315+
expect(toasts.some((t) => /Link this project to a workspace/.test(t.message))).toBe(false)
316+
})
317+
318+
test("a NAMED 409 is still a real race, and still names the workspace", async () => {
319+
routes = [
320+
{
321+
match: /datamate-project-bindings/,
322+
method: "POST",
323+
status: 409,
324+
body: { detail: { message: "already linked", existing_datamate_id: 7, existing_datamate_name: "team-ws" } },
325+
},
326+
]
327+
const { api, toasts } = stubApi()
328+
await createAndBindInline(api, IDENTIFIER, "proj")
329+
expect(toasts.some((t) => /already linked to "team-ws"/.test(t.message))).toBe(true)
330+
expect(toasts.some((t) => t.message === HIDDEN_BINDING_MESSAGE)).toBe(false)
331+
})
332+
})
333+
334+
// ─────────────────────────────────────────────────────────────────────────────
335+
// The other half of the dead end: the person who CREATED the workspace is the
336+
// only one who can open it up, and nothing on this surface told them so.
337+
// ─────────────────────────────────────────────────────────────────────────────
338+
describe("TUI: quick-create privacy note", () => {
339+
/** Renders the confirmation dialog instead of discarding it, so the title it
340+
* actually shows can be read back. */
341+
const dialogApi = () => {
342+
const cap: { title: string | null } = { title: null }
343+
const toasts: Array<{ variant?: string; message: string }> = []
344+
return {
345+
cap,
346+
toasts,
347+
api: {
348+
state: { path: { directory: "/tmp/proj" } },
349+
ui: {
350+
DialogSelect: (props: { title: string }) => {
351+
cap.title = props.title
352+
return null
353+
},
354+
dialog: { clear: () => {}, replace: (fn: () => unknown) => void fn() },
355+
toast: (t: { variant?: string; message: string }) => toasts.push(t),
356+
},
357+
} as never,
358+
}
359+
}
360+
361+
test("a create tells the creator the workspace is private and how to share it", async () => {
362+
routes = [
363+
{
364+
match: /datamate-project-bindings\/$/,
365+
method: "POST",
366+
status: 200,
367+
body: { datamate: { id: 7, name: "proj" }, binding: BINDING, manage_url: "https://x.test/w/7" },
368+
},
369+
]
370+
const d = dialogApi()
371+
await createAndBindInline(d.api, IDENTIFIER, "proj")
372+
expect(d.cap.title).toContain(QUICK_WORKSPACE_PRIVATE_NOTE)
373+
})
374+
375+
test("binding to an EXISTING workspace does not, because its privacy was never this flow's to describe", async () => {
376+
routes = [{ match: /\/bind$/, method: "POST", status: 200, body: { binding: BINDING } }]
377+
const d = dialogApi()
378+
await bindOrRebindInline(d.api, IDENTIFIER, 7, undefined)
379+
expect(d.cap.title).toBeTruthy()
380+
expect(d.cap.title).not.toContain(QUICK_WORKSPACE_PRIVATE_NOTE)
381+
})
296382
})
297383

298384
// ─────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)