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
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,47 @@ pin and a line to read before you move.

## [Unreleased]

### Added

- **Send a card to another Paseo host.** The launch dialog gains a host chip, next to the isolation,
whenever more than one host is connected. The board's own host still launches through the daemon;
any other online host is reached with `getPaseoClient` and given the same workspace, agent and
timeline row, through one `launchCard` now shared by both paths. The project there is the one
keyed by the card's repository, and the agent opens on that host. On Paseo before 0.9.0, which
has no `useHosts`, the chip is simply absent.
- **Choose where a worktree starts.** A New worktree send gains a base chip: any of the
repository's branches, read from GitHub, or — on a pull request — the pull request's own branch,
which the daemon checks out instead of branching off the default. The base is requested as the
remote-tracking ref, which the daemon fetches first, so the agent starts at the remote tip rather
than at a local default branch nobody has pulled; on the board's own host the plugin also fetches
the branch itself, from the remote that points at the card's repository (a fork's `upstream`).
- **Send to a checkout that already exists.** Existing checkout, a third isolation beside Local and
New worktree, joins a worktree or branch already on the host as it stands — no new worktree, no
new branch. It lists every `git worktree list` entry on the board's own host and the checkouts
Paseo has a workspace on elsewhere, preselects the one on the pull request's head branch, and
adds the agent to the workspace already open there rather than opening a second one.
- **Check a branch out without forking it.** A New worktree started from a branch gains a Same
branch / New branch chip: Same branch checks that branch itself out (the daemon's
checkout-branch), creating the local branch from the card's repository when there is none yet.
- **The last host and base are remembered**, the host with the other launch defaults and the base
per repository, separately for pull requests and for issues and discussions.

### Fixed

- A worktree send no longer forks a branch behind your back. The daemon answers a checkout of a
branch some worktree already holds by cutting `<branch>-1` from it, and a pull request whose head
branch is already local by checking it out as `<head>-1`. The dialog now refuses the first, naming
the checkout to choose under Existing checkout, and on the board's own host the second checks out
the local branch itself.

- The sidebar item, settings screen, command and timeline row used the `Github` Lucide icon, which
newer Paseo apps no longer ship — the plugin failed to load with "Unknown Lucide icon: Github".
They use `GitBranch`.

### Changed

- The `@getpaseo/*` development dependencies move to 0.11.1, for the multi-host client API.

## [1.0.1] — 2026-09-12

### Changed
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ date that answers "what has actually moved". Each card reports the date the list
rendered from Markdown, with images proxied through the daemon. From there: approve, merge with any
method the repository allows, edit labels, open on GitHub — or **send to chat**, which starts a
Paseo agent with a prompt template of your choosing, in a workspace of your choosing, with the
issue or pull request pinned to the top of the conversation.
issue or pull request pinned to the top of the conversation. The agent can run on any connected
Paseo host that has the repository as a project. A new worktree can start from any branch — as a
new branch cut from it, or that branch itself checked out with no new branch on top — or, for a pull
request, from the pull request's own branch. Or skip the new worktree entirely: **Existing
checkout** joins a worktree or branch already on the host, whoever made it, and preselects the one
on the pull request's branch. The last host and, per repository, the last base are what the next
card opens on.

![One pull request open in the detail panel: title, author, branches, checks, the Approve, Merge,
Send to chat and Open on GitHub actions, and the rendered body.](images/detail-panel.png)
Expand Down Expand Up @@ -86,6 +92,13 @@ inputs.paseo-github.url = "github:alysnnix/paseo-github-integration";
- **GitHub search backs the sweep**, so its rules apply: results are capped per query, and a
watched owner with thousands of open items shows the most recently updated slice rather than all
of them.
- **Another host is matched by `origin` alone.** On the board's own host a fork is found through
its `upstream` remote too; on any other host the project's `origin` has to be the card's
repository, and a base branch has to have been fetched there at least once. There, too,
Existing checkout lists only the checkouts Paseo has a workspace open on — a worktree another tool
made appears once Paseo has opened it — and a pull request whose branch is already local but not
checked out is checked out as Paseo's `<branch>-1` copy; choose the branch with Same branch
instead.
- **One GitHub account at a time**, the one `gh` is authenticated as. There is no account switcher.
- **Images hosted outside GitHub are not loaded**, deliberately: they would report your IP address
to whoever wrote the comment. They render as a link you can open yourself.
Expand Down
3 changes: 2 additions & 1 deletion client/board/github-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ export function GitHubBoard(props: PluginSurfaceProps) {
key={sendTarget.item.id}
item={sendTarget.item}
initialPrompt={sendTarget.prompt}
hostLabel={props.host.label}
pullRequest={sendTarget.pullRequest}
boardHost={props.host}
styles={styles}
accentColor={props.theme.colors.accent}
onCancel={() => setSendTarget(null)}
Expand Down
29 changes: 22 additions & 7 deletions client/board/use-board-overlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import type { Styles } from "../theme/use-styles";
import { LABEL_MENU_MAX_HEIGHT, LABEL_MENU_WIDTH, MENU_MARGIN } from "./label-menu";
import type { LabelMenuTarget } from "./label-menu";

/** The card the launch dialog is open on, its rendered prompt, and whether it is a pull request. */
export interface SendTarget {
item: BoardItem;
prompt: string;
pullRequest: boolean;
}

/**
* What `useBoardOverlays` needs from the other board hooks, threaded through
* rather than reached for.
Expand All @@ -38,8 +45,8 @@ export interface UseBoardOverlaysResult {
labelTarget: LabelMenuTarget | null;
setLabelTarget: (next: LabelMenuTarget | null) => void;
applyItemLabels: (itemId: string, labels: string[]) => void;
sendTarget: { item: BoardItem; prompt: string } | null;
setSendTarget: (next: { item: BoardItem; prompt: string } | null) => void;
sendTarget: SendTarget | null;
setSendTarget: (next: SendTarget | null) => void;
handleLaunched: (result: LaunchResult) => void;
rootRef: React.RefObject<View | null>;
showSettings: boolean;
Expand All @@ -65,7 +72,7 @@ export function useBoardOverlays(
/** The surface shows one of two things; plugins cannot route between surfaces. */
const [showSettings, setShowSettings] = useState(false);
/** The card the launch dialog is open on, with its prompt already rendered. */
const [sendTarget, setSendTarget] = useState<{ item: BoardItem; prompt: string } | null>(null);
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
/** The card the label menu is open on, and where on this surface to draw it. */
const [labelTarget, setLabelTarget] = useState<LabelMenuTarget | null>(null);
/** The card the detail panel is open on. Its column picks the send template. */
Expand Down Expand Up @@ -211,7 +218,11 @@ export function useBoardOverlays(
const projectId = board?.repositoryProjects[item.repository] ?? null;
const template =
promptValues === null ? item.url : templateFor(promptValues, type, projectId);
setSendTarget({ item, prompt: renderTemplate(template, item) });
setSendTarget({
item,
prompt: renderTemplate(template, item),
pullRequest: type === "draft-prs" || type === "open-prs",
});
},
[board, promptValues],
);
Expand All @@ -237,12 +248,16 @@ export function useBoardOverlays(
const handleLaunched = useCallback(
(result: LaunchResult) => {
setSendTarget(null);
toast.show(`Created “${result.workspaceName}” in ${result.projectName}. Opening it…`, {
const where =
result.serverId === props.host.id ? result.projectName : `${result.projectName} on ${result.hostLabel}`;
toast.show(`Created “${result.workspaceName}” in ${where}. Opening it…`, {
variant: "success",
});
props.navigation?.openAgent({ agentId: result.agentId });
// `serverId` routes to the host the agent actually runs on, which is not
// this surface's own when the dialog sent the card elsewhere.
props.navigation?.openAgent({ agentId: result.agentId, serverId: result.serverId });
},
[props.navigation, toast],
[props.host.id, props.navigation, toast],
);

const renderRow = useCallback(
Expand Down
99 changes: 84 additions & 15 deletions client/launch/isolation-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,115 @@ import { View } from "react-native";
import type { Isolation } from "../../shared/board";
import type { Styles } from "../theme/use-styles";
import { ChoicePopover, ControlChip, ISOLATION_CHOICES } from "./pickers";
import type { Choice, PickerId } from "./pickers";
import { BRANCH_MODE_CHOICES } from "./use-base-branch";
import type { SendProject } from "./use-send-dialog";

/** One chip and the menu it opens, as the row below lays them out. */
export interface ChipMenu {
choices: readonly Choice[];
selectedId: string | null;
label: string;
onSelect: (id: string) => void;
}

/**
* Where the workspace is cut: the dialog's first control row, and one of its
* four popovers. A non-git project has no worktree to cut, so the chip reads
* Local and the press is disabled rather than offering a choice that fails.
* Where the workspace is cut: the dialog's first control row. It holds the host
* the card is sent to — shown only when more than one is connected, so a
* single-host setup sees the dialog it always had — the isolation, and what
* that isolation needs next: for a new worktree, the branch it starts from and,
* when that is a branch, whether a new branch is cut from it or it is checked
* out itself; for an existing checkout, which one. A non-git project has
* neither to offer, so that chip reads Local and the press is disabled rather
* than offering a choice that fails.
*
* Its own row, out-stacking the scrim so its popover stays clickable — the
* row raises above the second control row's while its own popover is open.
* row raises above the second control row's while one of its popovers is open.
*/
export function IsolationControl({
styles,
project,
isolation,
open,
onToggle,
onSelect,
picker,
onTogglePicker,
onSelectIsolation,
host,
base,
branchMode,
checkout,
}: {
styles: Styles;
project: SendProject | null;
isolation: Isolation;
open: boolean;
onToggle: () => void;
onSelect: (id: string) => void;
picker: PickerId | null;
onTogglePicker: (id: PickerId) => void;
onSelectIsolation: (id: string) => void;
host: ChipMenu;
base: ChipMenu;
/** Null unless the worktree starts from a branch. */
branchMode: ChipMenu | null;
checkout: ChipMenu;
}) {
const chips: { id: PickerId; menu: ChipMenu; shown: boolean }[] = [
{ id: "base", menu: base, shown: isolation === "worktree" },
{
id: "branch-mode",
menu: branchMode ?? { choices: BRANCH_MODE_CHOICES, selectedId: null, label: "", onSelect: () => {} },
shown: isolation === "worktree" && branchMode !== null,
},
{ id: "checkout", menu: checkout, shown: isolation === "existing" },
];
const raised = picker === "host" || picker === "isolation" || chips.some((chip) => chip.id === picker);
return (
<View style={[styles.controlRow, open ? styles.controlRowRaised : null]}>
<View style={[styles.controlRow, raised ? styles.controlRowRaised : null]}>
{host.choices.length > 1 ? (
<ControlChip styles={styles} label={host.label} onPress={() => onTogglePicker("host")} />
) : null}
<ControlChip
styles={styles}
label={isolation === "worktree" ? "New worktree" : "Local"}
label={ISOLATION_CHOICES.find((choice) => choice.id === isolation)?.label ?? "Local"}
disabled={project === null || !project.supportsWorktree}
onPress={onToggle}
onPress={() => onTogglePicker("isolation")}
/>
{open ? (
{picker === "isolation" ? (
<ChoicePopover
styles={styles}
direction="down"
options={ISOLATION_CHOICES}
selectedId={isolation}
onSelect={onSelect}
onSelect={onSelectIsolation}
/>
) : null}
{chips.map((chip) =>
chip.shown ? (
<ControlChip
key={chip.id}
styles={styles}
label={chip.menu.label}
disabled={chip.menu.choices.length === 0}
onPress={() => onTogglePicker(chip.id)}
/>
) : null,
)}
{chips.map((chip) =>
chip.shown && picker === chip.id ? (
<ChoicePopover
key={`${chip.id}-menu`}
styles={styles}
direction="down"
options={chip.menu.choices}
selectedId={chip.menu.selectedId}
onSelect={chip.menu.onSelect}
/>
) : null,
)}
{picker === "host" ? (
<ChoicePopover
styles={styles}
direction="down"
options={host.choices}
selectedId={host.selectedId}
onSelect={host.onSelect}
/>
) : null}
</View>
Expand Down
17 changes: 15 additions & 2 deletions client/launch/pickers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,27 @@ export interface ProviderChoice extends Choice {
}

/** Which control's popover is open; only ever one at a time. */
export type PickerId = "isolation" | "model" | "thinking" | "mode";
export type PickerId =
| "host"
| "isolation"
| "base"
| "branch-mode"
| "checkout"
| "model"
| "thinking"
| "mode";

export const ISOLATION_CHOICES: readonly Choice[] = [
{ id: "local", label: "Local", description: "Work in the project's own checkout." },
{
id: "worktree",
label: "New worktree",
description: "Cut a fresh git worktree on its own branch.",
description: "Cut a fresh git worktree, on a new branch or an existing one.",
},
{
id: "existing",
label: "Existing checkout",
description: "Join a worktree or branch already on the host, as it stands.",
},
];

Expand Down
37 changes: 26 additions & 11 deletions client/launch/send-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ export { readProviders, resolveConfiguration, toChoice };
* card: where the workspace is cut, which agent runs it, how hard it thinks,
* which permission mode it runs under, and the first message.
*
* The host is not a choice here. A plugin surface is bound to the daemon that
* contributed it — `usePaseo()` is that daemon's client and nothing reaches
* another one — and the board itself is that host's `gh`. Switching hosts is the
* surface header's job, so the dialog names the host rather than offering it.
* The host is a choice too, when more than one is connected. The board itself
* stays bound to the daemon that contributed it — the cards are that host's
* `gh` — but the agent can run on any online host that has the repository as a
* project; `useLaunchTarget` resolves which, and where the project is there.
*
* The dialog's own state — the project, the provider snapshot, the settled
* configuration, which popover is open, the launch call — lives in
Expand All @@ -38,7 +38,8 @@ export { readProviders, resolveConfiguration, toChoice };
export function SendDialog({
item,
initialPrompt,
hostLabel,
pullRequest,
boardHost,
styles,
accentColor,
onCancel,
Expand All @@ -47,19 +48,29 @@ export function SendDialog({
item: BoardItem;
/** The card's prompt template, already rendered. The user may rewrite it. */
initialPrompt: string;
hostLabel: string;
/** Whether the card is a pull request, which can start on its own branch. */
pullRequest: boolean;
/** The host the board belongs to, and the dialog's default target. */
boardHost: { id: string; label: string };
styles: Styles;
accentColor: string;
onCancel: () => void;
onLaunched: (result: LaunchResult) => void;
}) {
const dialog = useSendDialogState({ item, initialPrompt, onCancel, onLaunched });
const dialog = useSendDialogState({
item,
pullRequest,
boardHost,
initialPrompt,
onCancel,
onLaunched,
});

return (
<Modal title="New workspace" open onOpenChange={dialog.requestClose}>
<Modal.Content contentContainerStyle={styles.dialogBody}>
<Text style={styles.subtle} numberOfLines={1}>
{hostLabel}
{dialog.hostLabel}
{dialog.project === null ? "" : ` · ${dialog.project.name}`}
</Text>
<Text style={styles.modalBody} numberOfLines={2}>
Expand All @@ -70,9 +81,13 @@ export function SendDialog({
styles={styles}
project={dialog.project}
isolation={dialog.isolation}
open={dialog.picker === "isolation"}
onToggle={() => dialog.togglePicker("isolation")}
onSelect={dialog.selectIsolation}
picker={dialog.picker}
onTogglePicker={dialog.togglePicker}
onSelectIsolation={dialog.selectIsolation}
host={dialog.host}
base={dialog.base}
branchMode={dialog.branchMode}
checkout={dialog.checkout}
/>

{/* The host's input, not React Native's: it registers focus with the
Expand Down
Loading