Skip to content
Merged
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
40 changes: 28 additions & 12 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ See [History synchronization](docs/HISTORY.md).
## Ranger

Ranger is an **experimental** assistant that answers questions about your
workspaces and can manage them. Open it from the topbar, the mobile navigation
capsule, or its shortcut.
workspaces and can manage them. Open it from the topbar, the mobile **⋮** menu,
or its shortcut.

- **Setup:** connect a model provider in **Settings** and choose which
workspaces Ranger may access (none by default). See
Expand Down Expand Up @@ -174,21 +174,37 @@ Drafts stay in this browser and re-anchor to changed files on refresh.

- **Configuration > Appearance > Layout** selects Automatic, Mobile, or Desktop
(`?layout=mobile|desktop|auto` overrides it).
- The bottom bar holds **Message…**, **Direct**, **Tabs** (with the workspace's
tab count, red when another agent needs you) and **⋮**, a menu of views and tools: Terminal, Files,
Changes, Commits, Annotations, History, Ranger, and Workspaces.
- Touch scrolls without opening the keyboard. Long-press selects text for
**Copy**, **Add comment**, or link actions.
- Floating terminal shortcuts (a `2×8` grid plus side buttons) are customizable
under **Configuration > Behavior > Mobile terminal shortcuts** and can be
dragged to either edge.
- The **Type** button opens an input dock with two modes: **Composer** (edit a
draft with IME, dictation, and images; **Send** adds Enter) and **Direct**
(keys go straight to the terminal). Its tap raises the keyboard in either
mode; afterwards, sends, scrolling, and shortcut actions leave a dismissed
keyboard closed.
- The phone's Back gesture or button closes the tab sheet, then the input dock
or a mobile view such as Workspaces, Files, Changes or Ranger, one per
press, before leaving the page.
- The `>_` button in Composer offers slash commands for Claude Code, Codex, Pi,
Kimi Code, Grok Build, and Antigravity CLI.
- **Message…** is the editable draft in the bottom bar, with IME,
dictation, images, and multiline input. Tapping it raises the keyboard and
reveals **Attachment**, **Keyboard shortcuts**, and **Commands**, in that
order. Tools expand above the same field, without a separate Composer dock.
**Insert** adds text without Enter; **Send** adds Enter. The terminal button
beside Message activates **Direct** (keys go straight to the terminal).
File and image uploads in Direct paste their paths into the terminal without
Enter; in Message they insert into the draft. If the original Direct target
changes during an upload, its paths stay in that pane's draft with a warning.
While editing a multiline draft (including visual wraps), Message expands
and temporarily hides Direct, Tabs, and **⋮**. Leaving the field restores
the single-line preview and navigation; tapping Message resumes editing.
Drafts stay in the field when collapsed. Sends, scrolling, and shortcut
actions leave a dismissed keyboard closed.
Secondary pages (including Ranger settings, Files, and Workspaces) keep only
the bottom input/navigation row. Returning to Terminal restores the tools and
shortcuts as left, without changing the draft/caret or reopening the keyboard.
Ordinary Tabs and **⋮** popups keep the underlying page's input-tool visibility.
- The phone's Back gesture or button closes Tabs or **⋮**, then the visible input
tools or a mobile view such as Workspaces, Files, Changes or Ranger, one per
press, before leaving the page. Hidden input tools do not intercept a view's Back.
- The **Commands** button in the input toolbar offers slash commands for
Claude Code, Codex, Pi, Kimi Code, Grok Build, and Antigravity CLI.
- Install as a PWA for an app-style window; see
[installation steps](README.md#install-as-a-pwa). **A PWA is not offline
access**: Roamgate must stay reachable.
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-web-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fileURLToPath, URL } from "node:url";
const publicRoot = fileURLToPath(new URL("../server/public/", import.meta.url));
const maxFileCount = 160;
const maxTotalBytes = 14 * 1024 * 1024;
const maxInitialJsBytes = 804 * 1024;
const maxInitialJsBytes = 805 * 1024;
const maxInitialJsGzipBytes = 256 * 1024;
const maxInitialCssBytes = 196 * 1024;

Expand Down
79 changes: 79 additions & 0 deletions server/src/workspace/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,85 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>) {
}

describe("workspace file handlers", () => {
test("commit history reports a non-Git workspace without inventing an empty history", async () => {
const processes: string[][] = [];
const diagnostic =
"fatal: not a git repository (or any of the parent directories): .git";
const handlers = createFileHandlers({
herdr: {
call: async (method: string) => {
if (method === "workspace.get")
return { workspace: { cwd: "/preview" } };
if (method === "pane.list")
return {
panes: [
{ workspace_id: "w1", focused: true, cwd: "/preview" },
{ workspace_id: "other", cwd: "/unrelated-repo" },
],
};
throw new Error(`unexpected call ${method}`);
},
} as any,
sshHost: () => undefined,
shQuote,
runProcessWithCodeTimeout: async (argv) => {
processes.push(argv);
return { code: 128, stdout: "", stderr: diagnostic };
},
});
await expect(
handlers.readGitCommits({ workspace_id: "w1" }),
).rejects.toThrow(diagnostic);
expect(processes).toEqual([
["git", "-C", "/preview", "rev-parse", "--show-toplevel"],
]);
});

test("commit history uses the workspace checkout and renders an unborn HEAD as empty", async () => {
const processes: string[][] = [];
const handlers = createFileHandlers({
herdr: {
call: async (method: string) => {
if (method === "workspace.get")
return {
workspace: {
worktree: { checkout_path: "/checkout" },
cwd: "/preview",
},
};
throw new Error(`unexpected call ${method}`);
},
} as any,
sshHost: () => undefined,
shQuote,
runProcessWithCodeTimeout: async (argv) => {
processes.push(argv);
return processes.length === 1
? { code: 0, stdout: "/checkout\n", stderr: "" }
: { code: 1, stdout: "", stderr: "" };
},
});
await expect(
handlers.readGitCommits({ workspace_id: "w1" }),
).resolves.toEqual({
head: null,
commits: [],
has_more: false,
shallow: false,
});
expect(processes).toHaveLength(2);
expect(processes[0]).toEqual([
"git",
"-C",
"/checkout",
"rev-parse",
"--show-toplevel",
]);
expect(processes[1]?.at(-1)).toContain(
"git -C '/checkout' rev-parse -q --verify 'HEAD^{commit}'",
);
});

test("PR/MR reads accept only available worktrees listed for the source repository, locally and over SSH", async () => {
for (const host of [undefined, "user@server"]) {
const processes: string[][] = [];
Expand Down
Loading
Loading