Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/WORKSPACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,39 @@ proposal is checked against it. If the file changed underneath — an agent edit
it, a PR merged — the page says so and offers **Keep my version** (yours, on top
of the new one) or **Use theirs**. Nothing is overwritten silently.

### Shared drafts

Drafts live in one browser's localStorage. A `DraftProvider` backs them with something other people edit too, which is how the paid multiplayer service plugs in. The shell page imports it from a module:

```ts
createWorkspaceRoutes({ shell: { modules: ["/my-drafts.js"] } });
```

```ts
// /my-drafts.js
export const drafts = {
// Null leaves this page on localStorage drafts.
open({ path, ref, content, sha, backend }) {
return {
ready() {}, // false until content() holds the shared text
content() {}, // the whole file
baseSha() {}, // optional: the version the shared text started from, if older than `sha`
edit(next, base) {}, // apply the change from base to next, not from content() to next
subscribe(listener) {}, // call it on any change, including your own edit()
setActiveLine(line) {}, // the file line this person is in, or null
peers() {}, // [{ id, name, avatarUrl?, line }]
close() {},
};
},
};
```

If you render `<DevbarShell>` yourself, pass `drafts` as a prop. Modules run with the shell's access, so only list code you trust. One that fails to load logs an error and the shell mounts without it.

While a page is shared, the block editor writes on every keystroke, except for an empty block, half a slash command, or text about to split into two blocks. Those go out on blur. A draft already in localStorage goes in once as an edit, if it was based on the same version of the file. Discarding a shared draft discards it for everyone.

Return null for a local workspace on its checkout. It autosaves drafts to disk, so a shared draft would write to everyone's working tree.

## Pull requests

**Propose** on a page (or the **Changes** page, for several at once) opens one
Expand Down
427 changes: 427 additions & 0 deletions docs/superpowers/specs/2026-10-31-multiplayer-proposal.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import "@/toolbar/toolbar.css";
export { Devbar } from "@/toolbar/devbar";
export type { DevbarProps, DevbarPlugin } from "@/toolbar/toolbar";
export type { DevbarProps, DevbarPlugin, DevbarPluginContext } from "@/toolbar/toolbar";
export { init, mountShell } from "@/standalone";
export { DevbarShell } from "@/workspace/shell";
export type { DevbarShellProps } from "@/workspace/shell";
export type { DraftPeer, DraftProvider, DraftSession } from "@/workspace/drafts";
export { discoverLocalServer, isLocalPage, resolveProject } from "@/live/discovery";
export type { LocalConnection, LocalHandshake, LocalProject } from "@/live/discovery";
export { createLiveBridge, LIVE_METHODS } from "@/live/bridge";
Expand Down
11 changes: 11 additions & 0 deletions src/toolbar/toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -6798,6 +6798,17 @@ a.devbar-nt-list-row:hover {
width: 100%;
}

/* who else is in this block on a shared page */
.devbar-nt-peer {
font-size: 11px;
line-height: 16px;
padding: 0 6px;
border-radius: 4px;
white-space: nowrap;
color: var(--devbar-nt-text, inherit);
background: color-mix(in srgb, currentColor 10%, transparent);
}

/* block gutters: comment markers, blame, task issues */

.devbar-nt-gutter {
Expand Down
32 changes: 32 additions & 0 deletions src/toolbar/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ export type DevbarPlugin = {
barButton?: () => React.ReactNode;
onActivate?: () => void;
onDeactivate?: () => void;
/** Runs when the toolbar mounts, with read access to its annotations. Return a cleanup. */
setup?: (context: DevbarPluginContext) => void | (() => void);
};

export type DevbarPluginContext = {
getAnnotations: () => Annotation[];
/** Called after annotations change. Returns an unsubscribe. */
subscribe: (listener: () => void) => () => void;
};

export type DevbarProps = {
Expand Down Expand Up @@ -1304,6 +1312,30 @@ export function Devbar({
onTurnOff,
}: ToolbarProps): React.ReactNode {
const state = useDevbarState();
const annotationsForPlugins = useRef(state.annotations);
annotationsForPlugins.current = state.annotations;
const annotationListeners = useRef(new Set<() => void>());
useEffect(() => {
for (const listener of annotationListeners.current) listener();
}, [state.annotations]);
const pluginKeys = plugins.map((p) => p.key).join();
// Keyed by plugin, since hosts often pass a new array each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const context: DevbarPluginContext = {
getAnnotations: () => annotationsForPlugins.current,
subscribe: (listener) => {
annotationListeners.current.add(listener);
return () => {
annotationListeners.current.delete(listener);
};
},
};
const cleanups = plugins.map((p) => p.setup?.(context));
return () => {
for (const cleanup of cleanups) if (cleanup) cleanup();
};
}, [pluginKeys]);
// Local token management — when server is set but no token prop,
// allow entering a bearer token via the toolbar UI
const STORAGE_KEY = "devbar-local-token";
Expand Down
33 changes: 33 additions & 0 deletions src/workspace/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,39 @@ export function lineMap(oldText: string, newText: string): LineMap {
return { oldToNew, newToOld };
}

/**
* Where a line (0-based) went. A line that changed has no entry in the map, so it
* goes by the nearest surviving line above it, keeping its distance.
*/
export function carryLine(map: Int32Array, line: number): number {
for (let i = Math.min(line, map.length - 1); i >= 0; i--) {
const to = map[i] as number;
if (to >= 0) return to + (line - i);
}
return line;
}

/**
* Where a character offset in `prev` lands in `next`. Text inserted exactly at the
* offset stays after it, so someone else typing at your caret doesn't move it.
*/
export function mapOffset(prev: string, next: string, offset: number): number {
if (prev === next) return offset;
const a = prev.split("");
let old = 0;
let delta = 0;
for (const op of diffSequences(a, next.split(""))) {
if (op.type === "insert") {
if (old < offset) delta++;
continue;
}
if (op.a >= offset) break;
if (op.type === "delete") delta--;
old = op.a + 1;
}
return Math.max(0, Math.min(next.length, offset + delta));
}

/**
* Carry lines `start..end` (1-based, inclusive) across a map. Every line must
* have survived unchanged and still be together; otherwise null — the lines
Expand Down
52 changes: 52 additions & 0 deletions src/workspace/drafts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* The seam for shared drafts. With no provider, drafts live in localStorage. A
* provider can back a page's draft with something other people edit too. The
* shell reads and writes the file as one string, so how a provider stores or
* merges it is its own business.
*/

export type DraftPeer = {
id: string;
name: string;
avatarUrl?: string;
/** The file line they're in, 1-based. Null when they're only looking. */
line: number | null;
};

export type DraftSession = {
/** False until `content` holds the shared text. The page shows the saved file until then. */
ready(): boolean;
content(): string;
/**
* The file version the shared text was started from. It can be older than the file
* this person opened, and proposing has to detect that rather than overwrite the
* newer file. Default: the opened file's sha.
*/
baseSha?(): string | undefined;
/**
* The whole file after this person's edit, and the text they made it from. Someone
* else's edit can arrive before the page has rendered it, so `base` can be behind
* `content()`. Apply the change from `base` to `next`, not the difference from `content()`,
* or that edit gets undone.
*/
edit(next: string, base: string): void;
/** Fires on anything `content` or `peers` would now answer differently. */
subscribe(listener: () => void): () => void;
setActiveLine(line: number | null): void;
peers(): DraftPeer[];
close(): void;
};

export type DraftProvider = {
/** Null leaves this page on localStorage drafts. */
open(page: {
path: string;
kind: string;
/** The branch being edited. */
ref: string | undefined;
/** The saved file. */
content: string;
sha: string;
backend: "local" | "github";
}): DraftSession | null;
};
7 changes: 7 additions & 0 deletions src/workspace/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ export type ShellOptions = {
agent?: { server: string; project: string };
/** Shown in the page title. */
title?: string;
/**
* ES modules the shell page imports before mounting. A module exporting `drafts`
* (a `DraftProvider`) backs the shell's drafts. They run with the shell's access,
* so only list code you trust.
*/
modules?: string[];
/** The browser bundle. Default: the one inlined in the package. */
script?: () => Promise<string>;
};
Expand Down Expand Up @@ -714,6 +720,7 @@ export function createWorkspaceHandler(options: WorkspaceHandlerOptions): Worksp
app,
...(shell.agent ? { agent: shell.agent } : {}),
...(shell.title ? { title: shell.title } : {}),
...(shell.modules?.length ? { modules: shell.modules } : {}),
...(theme === "light" || theme === "dark" ? { theme } : {}),
},
`${mount}/shell.js`,
Expand Down
17 changes: 16 additions & 1 deletion src/workspace/server/shell-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type ShellPageConfig = {
title?: string;
/** The theme the app's toolbar was in when it opened the shell. Absent: follow the OS. */
theme?: "light" | "dark";
/** ES modules to import before mounting. A module's `drafts` export becomes the shell's provider. */
modules?: string[];
};

function escapeHtml(text: string): string {
Expand All @@ -36,6 +38,19 @@ function scriptJson(value: unknown): string {
.replace(separators, (c) => `\\u${c.charCodeAt(0).toString(16)}`);
}

function mountScript(config: ShellPageConfig): string {
const { modules, ...props } = config;
const element = `document.getElementById("devbar-shell")`;
if (!modules?.length) return `window.Devbar.shell(${element}, ${scriptJson(props)});`;
// A module that fails to load costs the feature, not the page.
return `Promise.all(${scriptJson(modules)}.map(function (url) {
return import(url).catch(function (err) { console.error("[devbar] shell module failed: " + url, err); return {}; });
})).then(function (loaded) {
var found = loaded.filter(function (m) { return m.drafts; })[0];
window.Devbar.shell(${element}, Object.assign(${scriptJson(props)}, found ? { drafts: found.drafts } : {}));
});`;
}

export function shellHtml(config: ShellPageConfig, scriptUrl: string): string {
const title = config.title ? `devbar · ${config.title}` : "devbar · Workspace";
// Painted before the bundle loads, so a chosen theme never flashes the OS one.
Expand All @@ -60,7 +75,7 @@ ${background}
<body>
<div id="devbar-shell"></div>
<script src="${escapeHtml(scriptUrl)}"></script>
<script>window.Devbar.shell(document.getElementById("devbar-shell"), ${scriptJson(config)});</script>
<script>${mountScript(config)}</script>
</body>
</html>
`;
Expand Down
4 changes: 4 additions & 0 deletions src/workspace/shell.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { DraftProvider } from "./drafts";
import { useCallback, useEffect, useRef, useState } from "react";
import type React from "react";
import { ExternalIcon } from "@/toolbar/icons";
Expand Down Expand Up @@ -38,6 +39,8 @@ export type DevbarShellProps = {
agent?: { server: string; project: string } | false;
user?: WorkspaceUser;
theme?: DevbarTheme;
/** Back drafts with something other than localStorage. See `DraftProvider`. */
drafts?: DraftProvider;
};

type View = "app" | "page" | "changes" | "review";
Expand Down Expand Up @@ -108,6 +111,7 @@ export function DevbarShell(props: DevbarShellProps): React.ReactNode {
app: props.app,
agent: props.agent,
user: props.user,
...(props.drafts ? { drafts: props.drafts } : {}),
});
const narrow = useNarrow();
const [view, setView] = useState<View>("app");
Expand Down
Loading
Loading