Compare commits

...
Author SHA1 Message Date
Mohamed Boudra d13438ba50 fix(diff): identify worktree files by content, not stat metadata (#9584)
* fix(diff): identify worktree files by content

Stat-only identity changes re-sent unchanged diff bodies after a touch or
git add. Worktree tracked and untracked files now share one content identity
built from the comparison-side blob, working-tree content, and Git
interpretation. Stat metadata no longer forces body delivery, while real
content changes still change the identity.

Closes #9563

* fix(diff): align untracked patches and comparison modes

Read untracked bodies with --no-textconv so they follow the same patch
rules as tracked bodies. Include the comparison-side mode in worktree
identity so a base file type change cannot keep stale hunks. Use object
parameters for the identity inputs.
2026-11-13 19:59:48 +07:00
Mohamed Boudra cf9eaa8d29 Forge links use the daemon-resolved host and parent repository (#9576)
* fix(forge): build web links from the host and repository the daemon resolves

The app built forge links by parsing the origin remote itself, so an SSH
config alias (git@github.com-work:...) produced https://github.com-work/...
and a fork checkout sent #123 to the fork instead of the repository that owns
the numbers.

The daemon now publishes `forgeWeb` { host, referenceRepository? } on the
workspace descriptor and on checkout status, and the app builds every forge URL
from it. The host is the ssh -G resolved cloud host. referenceRepository is the
fork's parent, read through an optional ForgeService.getReferenceRepository
(GitHub, GitLab, Gitea family). Branch and file links stay on the origin
repository because a feature branch usually exists only on the fork.

Old daemons omit the field and the app derives links from the remote as before
(tagged COMPAT(forgeWeb)).

* fix(forge): keep the published web location tied to its remote and retry failed parent lookups

- A web location resolved for one origin remote is no longer published after
  `git remote set-url`; it returns when the next forge refresh resolves the new remote.
- The GitHub parent lookup rejects on a gh failure instead of returning null, so a
  network error is retried rather than cached as "this repository has no parent".
- The pull request status poll retries a due lookup, so an idle workspace recovers
  without waiting for a forced refresh.
- Workspace snapshot fixtures now expect `forgeWeb` for github.com remotes.

* fix(forge): look up a fork's parent once per remote, on any checkout

- A successful parent lookup is kept for the life of the remote instead of being repeated by every
  forced refresh (each push, fetch and branch switch).
- The lookup no longer waits on the pull request status's auth state, which is "no_remote" on a
  detached HEAD; a failed or signed-out lookup is retried on the next forced refresh or poll.

* fix(forge): resolve the new remote's web location when origin changes

`git remote set-url` reaches the daemon as a git-only refresh, which stopped publishing the old
remote's forgeWeb but never resolved the new one, so links fell back to parsing the remote.
A git refresh that finds the remote changed since the last forge refresh now runs a forced forge
refresh, which also probes a newly seen SSH alias.

* fix(forge): keep the alias-resolved host when a failed parent lookup is retried

The poll's retry resolved the forge with the synchronous resolver, which reports the raw SSH alias
as the host. A lookup that settled on retry then published and kept `github.com-work` as the web
host. The retry now uses the async resolver, like the forge refresh, so the host means the same
thing on both paths.

* fix(forge): treat a Gitea or GitLab API error body as a failed parent lookup

`tea api` exits 0 on HTTP errors, so an error body parsed as a repository without a parent and was
cached as "not a fork". The repository schemas now require the repository's own name, so an error
body rejects and the lookup is retried. The shorthand provider also keys its context on the
forgeWeb strings, so unrelated workspace updates no longer re-render every Markdown message.

* fix(forge): keep the parent lookup off the workspace refresh path

* fix(forge): keep a parent answer that lands during a refresh

* test(forge): make the parent lookup tests Windows-safe

* fix(forge): drain the background parent lookup on dispose

* test(forge): retry temp cleanup after the poll lookup on Windows
2026-11-13 19:59:42 +07:00
Mohamed Boudra ec52097924 fix(usage): keep failed usage visible in sidebar (#9594)
Retain the last successful percentages in the sidebar after a failed refresh, with a red warning that opens the existing error detail. Share snapshot selection between cards, default pins, and summary rendering so the surfaces no longer disagree about retained usage.
2026-11-13 19:59:28 +07:00
Jack Tomaszewski a30b784eb2 feat(plugins): let plugins add items to sidebar workspace rows (#7645)
Plugins can register workspace-row badge render slots, control them from a separated Show menu group, and use the shared WorkspaceRowBadge UI.

Co-authored-by: Jacek Tomaszewski <jacek@fullstack.house>
2026-11-13 17:12:39 +07:00
Mohamed BoudraandJacek Tomaszewski af6ca46ae3 fix(plugin)!: drop the always-empty timeline from agent.turn.after (#9573)
agent.turn.after no longer declares timeline; plugins read the turn with
timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true }) filtered by event.turnId.
Plugins built against the old SDK keep receiving timeline: [] behind COMPAT(pluginTurnTimeline).

Closes #9315

Co-authored-by: Jacek Tomaszewski <jacek@fullstack.house>
2026-11-13 15:22:53 +07:00
Mohamed Boudra 8dc979ec2f Shift+Tab in the iPad composer no longer inserts a tab (#9561)
* Shift+Tab in the iPad composer no longer inserts a tab

On iPad with a hardware keyboard, Shift+Tab in the composer cycled the
agent mode and the composer also inserted a tab. The native window
reports presses to JavaScript after the text control has handled them,
so the shortcut pipeline cannot prevent the default.

The hardware keyboard source now claims, up front, the Shift and Option
shortcuts whose binding is active in the current focus scope, and
recomputes the claims when the focus surface or command center changes.
iOS registers them as key commands so the text control never sees them.
The window still emits the press, so the shortcut runs once.

Closes #9468

* Claim every step of a rebound chord shortcut

A binding rebound to a chord such as Mod+J Shift+Tab only claimed its
first step, so finishing the chord in the iPad composer still typed a tab.

* Claim only the first step of a chord shortcut

Claiming every step of a chord held keys like Shift+A for as long as the
chord's binding was active, so the composer could no longer type a
capital A. A later step is a shortcut only while its chord is in progress.

* Expect no Option claims now that iPad tab switching uses Cmd+Shift

Main moved tab switching on Apple platforms with desktop bindings to
Cmd+Shift+[ / ], which text controls do not type, so the default claims
are only Shift+Tab while the composer is focused.
2026-11-13 14:12:01 +07:00
Papa Goose 70aeb7b45b fix(pi,omp): treat null optional tool arguments as omitted (#9506)
Models commonly send null for optional tool arguments, such as read offset/limit
(captured in #7011) and bash timeout. Pi and OMP treat null as omitted, but the
Paseo mappers rejected it, so these calls fell back to unknown tool cards that
show raw JSON input and output instead of Read/Shell cards.
2026-11-13 07:00:29 +00:00
110 changed files with 3185 additions and 268 deletions
+32
View File
@@ -113,6 +113,38 @@ host that is either a known cloud host or one the CLI is already authenticated
to. Adapter probes must not make anonymous HTTP requests to remote-derived
hosts, and adapters must not route credentials to an unauthenticated host.
### Web links: `forgeWeb`
The daemon publishes `forgeWeb` on the workspace descriptor and on
`checkout_status_response`. The app builds every forge URL from it instead of
parsing `origin` itself.
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
hosts pass through.
- `referenceRepository` is the repository that owns issue and change-request
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
keeps the repository you wrote.
- Branch and file links stay on the `origin` repository. A feature branch usually
exists only on the fork.
An adapter opts into `referenceRepository` with the optional
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
`parent` from `tea api`. The lookup always names the `origin` repository
explicitly, so it does not depend on `gh`'s default-repository choice, and it
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
successful answer for as long as the remote stays the same. The first snapshot
publishes `{ host }` immediately and the reference repository follows in a later
update, so a slow or signed-out forge CLI never holds back the checkout's git
state or pull request status. A failed lookup (signed out, network down) leaves
links on `origin` and is retried by the next refresh or pull request status poll
once a minute has passed.
Daemons older than v0.16.0 omit the field; the app then derives the host from the
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
`packages/app/src/git/forge-url.ts`.
## App
Each app forge splits into two modules so pure logic never imports the client
+10 -3
View File
@@ -20,9 +20,16 @@ settings the native composer does not implement.
## Native sources
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
that must bypass the focused text control: composer Return and the top overlay's
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
after the focused text control has already handled the press, so the pipeline's
`preventDefault` cannot stop a key from typing. A key the text control would type
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
active in the current focus scope, such as Shift+Tab in the composer.
`sources/native.ts` computes the claims from the published bindings and recomputes
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
text controls do not type them. Android takes no claims: a focused `EditText`
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
press is still observed by the window.
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
+15
View File
@@ -624,6 +624,21 @@ one section's order from built-ins, plugin groups, and the section's preference
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute workspace row badge items
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
items after its own.
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
the row adds no separator around plugin items; a plugin renders its slot.
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
their own group below a separator. A missing key is visible.
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
stay off them.
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
@@ -0,0 +1,148 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
closeSidebarDisplayPreferences,
openMobileAgentSidebar,
openSidebarDisplayPage,
} from "../support/helpers/sidebar";
const PLUGIN_ID = "row-badges-e2e";
const TITLE = "Badge host workspace";
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
function Review() {
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
}
function Docs() {
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
}
function IconOnly() {
return <WorkspaceRowBadge icon="Star" />;
}
function EmptyLabel() {
return <WorkspaceRowBadge icon="Circle" label="" />;
}
function Quiet() {
return null;
}
function Broken() {
throw new Error("badge failed");
}
export default function contribute(client) {
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
return () => {};
}
`;
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
const LABELS = { review: "In review", docs: "Docs" } as const;
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
return badge(page, itemId).locator("xpath=../..");
}
function badge(page: Page, itemId: keyof typeof LABELS) {
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
}
async function toggleShowItem(page: Page, label: string, touch: boolean) {
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: label, exact: true }).click();
if (touch) {
await page
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
.click({ position: { x: 5, y: 5 } });
} else {
await closeSidebarDisplayPreferences(page);
}
}
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
page,
}, info) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoAppShell(page);
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
await expect(badge(page, "review")).toBeVisible();
await expect(badge(page, "review")).toContainText("In review");
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
// An icon-only badge is named by its item's title.
await expect(
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
).toBeVisible();
await expect(
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
).toBeVisible();
await expect(page.getByText(TITLE).first()).toBeVisible();
await page.screenshot({ path: info.outputPath("wide.png") });
});
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
await openSidebarDisplayPage(page, "sidebar-display-show");
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
}
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
await page.screenshot({ path: info.outputPath("show-menu.png") });
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
await closeSidebarDisplayPreferences(page);
await expect(badge(page, "review")).toHaveCount(0);
// The first badge is gone, so the next one leads the line without a "·".
await expect(badgeLine(page, "docs")).toHaveText("Docs");
await toggleShowItem(page, "Review state", false);
await expect(badge(page, "review")).toBeVisible();
});
await test.step("the badge draws on a compact layout", async () => {
await page.setViewportSize({ width: 390, height: 844 });
await openMobileAgentSidebar(page);
await expect(badge(page, "review")).toBeVisible();
await page.screenshot({ path: info.outputPath("compact.png") });
await toggleShowItem(page, "Review state", true);
await expect(badge(page, "review")).toHaveCount(0);
await toggleShowItem(page, "Review state", true);
await expect(badge(page, "review")).toBeVisible();
});
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
});
+22 -2
View File
@@ -25,6 +25,7 @@ import {
} from "../support/helpers/usage-login";
import {
claudeAndCodexReports,
closeUsage,
expectPinnedUsage,
openCompactSidebar,
openUsageFromIcon,
@@ -79,12 +80,17 @@ function twoHoursAgo(): string {
}
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
async function qaScreenshot(page: Page, name: string) {
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
const file = path.join(directory, `${name}.png`);
if (target === "summary") {
await usageItem(page).screenshot({ path: file });
return;
}
await page.screenshot({ path: file });
}
function hostFilter(page: Page) {
@@ -628,6 +634,15 @@ for (const compact of [false, true]) {
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await expectPinnedUsage(page, ["42% Weekly"]);
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
await closeUsage(page);
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
await openUsageFromItem(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
@@ -639,6 +654,8 @@ for (const compact of [false, true]) {
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: No active quota");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await expectPinnedUsage(page, ["42% Weekly"]);
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
await fixture.setReport({
status: "available",
@@ -647,6 +664,9 @@ for (const compact of [false, true]) {
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43% used");
await expectPinnedUsage(page, ["43% Weekly"]);
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
await advanceUsageClock(page, 60_000);
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
@@ -8,6 +8,7 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
private weak var activeModule: PaseoHardwareKeyboardModule?
private var isHardwareSubmitEnabled = false
private var overlayKeys: Set<String> = []
private var claimedShortcuts: [ClaimedShortcut] = []
/// UIKit spells non-character keys as sentinel input strings rather than as the
/// character they would type. JS sends the DOM `code` and the mapping lives
@@ -19,6 +20,42 @@ private let namedKeyInputs: [String: String] = [
"Enter": "\r",
]
/// A shortcut the window takes from the focused text control: Shift+Tab would
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
private struct ClaimedShortcut {
let input: String
let modifierFlags: UIKeyModifierFlags
}
struct ClaimedShortcutRecord: Record {
@Field var code: String = ""
@Field var shift: Bool = false
@Field var alt: Bool = false
}
private let claimableKeyInputs: [String: String] = [
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
]
/// UIKit matches a key command against the unmodified character, so
/// Option+Shift+[ is registered as "[" with both modifier flags.
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
let code = record.code
let input: String?
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
else { input = claimableKeyInputs[code] }
guard let input else { return nil }
var modifierFlags: UIKeyModifierFlags = []
if record.shift { modifierFlags.insert(.shift) }
if record.alt { modifierFlags.insert(.alternate) }
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
}
/// Whether the first responder is mid-IME-composition.
///
/// A committing key has to stay out of the way while a CJK candidate is being
@@ -62,6 +99,14 @@ public class PaseoHardwareKeyboardModule: Module {
}
}
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
let claimed = shortcuts.compactMap(claimedShortcut)
DispatchQueue.main.async {
claimedShortcuts = claimed
UIMenuSystem.main.setNeedsRebuild()
}
}
OnDestroy {
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
self.keyboardObservers = []
@@ -69,6 +114,7 @@ public class PaseoHardwareKeyboardModule: Module {
activeModule = nil
isHardwareSubmitEnabled = false
overlayKeys = []
claimedShortcuts = []
}
}
}
@@ -139,7 +185,10 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
{
commands.append(makeSubmitCommand())
}
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
if !isComposingText() {
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
}
return commands
}
@@ -172,6 +221,18 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
return command
}
/// The window's press stream still emits the shortcut; the command only keeps
/// the key away from the text control.
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
let command = UIKeyCommand(
input: shortcut.input,
modifierFlags: shortcut.modifierFlags,
action: #selector(handleHardwareKeyboardShortcut(_:))
)
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
return command
}
@objc
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
guard canSubmitCurrentTextInput() else {
@@ -58,6 +58,8 @@ import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
import { useWorkspaceLabelProjection } from "@/workspace-labels";
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
import { PluginIconView } from "@/plugins/icons";
import type { PluginWorkspaceRowBadgeItemGroup } from "@/plugins/workspace-row-badge-items";
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -65,6 +67,7 @@ const ThemedSettings2 = withUnistyles(Settings2);
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircle = withUnistyles(Circle);
const ThemedPluginIconView = withUnistyles(PluginIconView);
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
const OPTION_ICON_SIZE = 14;
@@ -607,9 +610,9 @@ function OptionList<Value extends string>({
}
/**
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
* one already showing empties the slot and gives the width back to the title.
* Three groups: Paseo's own row items, plugin badge items, then the one thing the slot to the
* right of the title holds. Picking the trailing choice already showing empties the slot and
* gives the width back to the title.
*
* CI is the one item above the separator with three answers rather than two, so it opens a page
* instead of ticking, and it goes last: a row that navigates does not belong in the middle of a
@@ -632,6 +635,12 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
/>
))}
<ChecksSubTrigger />
{preferences.pluginRowBadgeItems.length > 0 ? (
<MenuSeparator testID="sidebar-plugin-row-badge-items-separator" />
) : null}
{preferences.pluginRowBadgeItems.map((item) => (
<PluginRowBadgeItemOption key={item.key} item={item} preferences={preferences} />
))}
<MenuSeparator />
{TRAILING_CHOICES.map((choice) => (
<OptionItem
@@ -649,6 +658,37 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
);
}
/** A plugin's workspace row badge item, toggled like the row's own. */
function PluginRowBadgeItemOption({
item,
preferences,
}: {
item: PluginWorkspaceRowBadgeItemGroup;
preferences: Preferences;
}): ReactElement {
const handleSelect = useCallback(
() => preferences.togglePluginRowBadgeItem(item.key),
[item.key, preferences],
);
const leading = useMemo(
() => (
<ThemedPluginIconView icon={item.icon} size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />
),
[item.icon],
);
return (
<MenuItem
selected={preferences.isPluginRowBadgeItemVisible(item.key)}
leading={leading}
closeOnSelect={false}
onSelect={handleSelect}
testID={`sidebar-row-badge-item-plugin-${item.pluginId}-${item.itemId}`}
>
{item.title}
</MenuItem>
);
}
/**
* No value on the row, only the chevron. The three answers are all long enough that the value
* fills the row and stops reading as an answer sitting at the right edge — it turns into a second
@@ -10,6 +10,12 @@ import {
type SidebarGroupMode,
type SidebarLabelFilter,
} from "@/stores/sidebar-view-store";
import { useInstalledPlugins } from "@/plugins/registry";
import {
groupPluginWorkspaceRowBadgeItems,
isWorkspaceRowBadgeItemVisible,
type PluginWorkspaceRowBadgeItemGroup,
} from "@/plugins/workspace-row-badge-items";
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
@@ -27,6 +33,10 @@ export interface SidebarDisplayPreferences {
setTitleSource: (source: WorkspaceTitleSource) => void;
rowItems: SidebarRowItems;
toggleRowItem: (item: SidebarRowItem) => void;
/** Workspace row badge items contributed by installed plugins, one per plugin item across hosts. */
pluginRowBadgeItems: readonly PluginWorkspaceRowBadgeItemGroup[];
isPluginRowBadgeItemVisible: (key: string) => boolean;
togglePluginRowBadgeItem: (key: string) => void;
checksDisplay: SidebarChecksDisplay;
setChecksDisplay: (display: SidebarChecksDisplay) => void;
trailing: SidebarWorkspaceTrailing;
@@ -75,9 +85,30 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
sidebarWorkspaceTrailing,
sidebarRowItems,
sidebarChecksDisplay,
sidebarPluginRowBadgeItems,
},
updateSettings,
} = useAppSettings();
const installedPlugins = useInstalledPlugins();
const pluginRowBadgeItems = useMemo(
() => groupPluginWorkspaceRowBadgeItems(installedPlugins),
[installedPlugins],
);
const isPluginRowBadgeItemVisible = useCallback(
(key: string) => isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
[sidebarPluginRowBadgeItems],
);
const togglePluginRowBadgeItem = useCallback(
(key: string) => {
void updateSettings({
sidebarPluginRowBadgeItems: {
...sidebarPluginRowBadgeItems,
[key]: !isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
},
});
},
[updateSettings, sidebarPluginRowBadgeItems],
);
const setTitleSource = useCallback(
(source: WorkspaceTitleSource) => {
@@ -123,6 +154,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
rowItems: sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
checksDisplay: sidebarChecksDisplay,
setChecksDisplay,
trailing: sidebarWorkspaceTrailing,
@@ -148,6 +182,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
sidebarChecksDisplay,
setChecksDisplay,
sidebarWorkspaceTrailing,
@@ -186,6 +186,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
prHint={workspace.prHint}
serviceSummary={serviceSummary}
labels={labels}
workspace={workspace}
/>
</View>
</View>
@@ -16,6 +16,10 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import {
PluginWorkspaceRowBadgeItems,
usePluginWorkspaceRowBadgeItems,
} from "@/plugins/workspace-row-badge-items";
import { CheckIndicator } from "./check-indicator";
import type { CheckSummary, CheckSummaryState } from "./check-summary";
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
@@ -59,6 +63,9 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
* state has its own colors — a label is a name a person chose, so it carries its own identity
* color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are
* read first, and it stays the same height as the rest of the line.
*
* Plugin items follow everything the row knows itself. A plugin renders its slot as it likes:
* the row adds no separator, since only the plugin knows whether it has anything to say.
*/
export function WorkspaceMetaRow({
currentBranch,
@@ -67,6 +74,7 @@ export function WorkspaceMetaRow({
prHint,
serviceSummary,
labels = EMPTY_LABELS,
workspace = null,
}: {
currentBranch: string | null;
projectName: string | null;
@@ -74,6 +82,8 @@ export function WorkspaceMetaRow({
prHint: PrHint | null;
serviceSummary: WorkspaceServiceSummary | null;
labels?: readonly WorkspaceLabelDefinition[];
/** The real workspace behind the row; previews leave it out and get no plugin items. */
workspace?: { serverId: string; workspaceId: string } | null;
}) {
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
const items = selectMetaRowItems({
@@ -86,8 +96,9 @@ export function WorkspaceMetaRow({
visible: rowItems,
checksDisplay,
});
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
if (items.length === 0) return null;
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
return (
<View style={styles.row}>
@@ -97,6 +108,9 @@ export function WorkspaceMetaRow({
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
</Fragment>
))}
{workspace ? (
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
) : null}
</View>
);
}
+16 -1
View File
@@ -1,3 +1,4 @@
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
@@ -9,13 +10,27 @@ const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
export function ForgeShorthandProvider({
forge,
remoteUrl,
forgeWeb,
children,
}: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
forgeWeb: ForgeWeb | null | undefined;
children: ReactNode;
}) {
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
// Keyed on the strings: the descriptor hands over a new `forgeWeb` object whenever any
// other workspace field changes, and a new context value re-renders every Markdown message.
const webHost = forgeWeb?.host;
const referenceRepository = forgeWeb?.referenceRepository;
const value = useMemo(
() =>
createForgeShorthand({
forge,
remoteUrl,
forgeWeb: webHost ? { host: webHost, referenceRepository } : null,
}),
[forge, remoteUrl, webHost, referenceRepository],
);
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
}
@@ -22,6 +22,17 @@ describe("createForgeShorthand", () => {
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
});
it("links numbers to the repository the daemon says owns them, but keeps explicit references", () => {
const fork = createForgeShorthand({
forge: "github",
remoteUrl: "git@github.com-work:fork-owner/repo.git",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
})!;
expect(fork.resolve("#123")).toBe("https://github.com/main-owner/repo/issues/123");
expect(fork.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
});
it("only recognizes the sigils the forge declares", () => {
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
expect(github.resolve("!2")).toBeNull();
@@ -1,3 +1,4 @@
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { forgeFromRemoteUrl } from "@/git/forge";
import { getClientForgeLogicModule } from "@/git/forges";
@@ -26,12 +27,15 @@ const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
export function createForgeShorthand(input: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
forgeWeb?: ForgeWeb | null;
}): ForgeShorthand | null {
const remoteUrl = input.remoteUrl;
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
if (!forge || !remoteUrl) return null;
const sigils = forgeSigils(forge).filter(
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
(sigil) =>
buildForgeReferenceUrl(forge, { remoteUrl, forgeWeb: input.forgeWeb, sigil, number: 1 }) !==
null,
);
if (sigils.length === 0) return null;
return {
@@ -41,6 +45,7 @@ export function createForgeShorthand(input: {
if (!match) return null;
return buildForgeReferenceUrl(forge, {
remoteUrl,
forgeWeb: input.forgeWeb,
repo: match[1] ?? null,
sigil: match[2] as ForgeReferenceSigil,
number: Number(match[3]),
+78
View File
@@ -326,3 +326,81 @@ describe("hasForgeWebUrls", () => {
expect(hasForgeWebUrls("bitbucket")).toBe(false);
});
});
describe("links resolved by the daemon (forgeWeb)", () => {
const sshAliasRemote = "git@github.com-work:acme/repo.git";
it("links #N on the web host the daemon resolved for an SSH config alias", () => {
expect(
buildForgeReferenceUrl("github", {
remoteUrl: sshAliasRemote,
forgeWeb: { host: "github.com" },
sigil: "#",
number: 1,
}),
).toBe("https://github.com/acme/repo/issues/1");
});
it("opens the branch and a file on the resolved web host for an SSH config alias", () => {
const forgeWeb = { host: "github.com" };
expect(
buildForgeBranchTreeUrl("github", { remoteUrl: sshAliasRemote, forgeWeb, branch: "main" }),
).toBe("https://github.com/acme/repo/tree/main");
expect(
buildForgeBlobUrl("github", {
remoteUrl: sshAliasRemote,
forgeWeb,
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("links #N to the repository that owns the numbers, such as a fork's parent", () => {
expect(
buildForgeReferenceUrl("github", {
remoteUrl: "git@github.com:fork-owner/repo.git",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
sigil: "#",
number: 123,
}),
).toBe("https://github.com/main-owner/repo/issues/123");
});
it("keeps an explicit owner/repo#N reference on the named repository", () => {
expect(
buildForgeReferenceUrl("github", {
remoteUrl: "git@github.com:fork-owner/repo.git",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
repo: "other/tools",
sigil: "#",
number: 7,
}),
).toBe("https://github.com/other/tools/issues/7");
});
it("keeps the branch and file links on the origin repository for a fork", () => {
const input = {
remoteUrl: "git@github.com:fork-owner/repo.git",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
branch: "feature/only-on-fork",
};
expect(buildForgeBranchTreeUrl("github", input)).toBe(
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
);
expect(buildForgeBlobUrl("github", { ...input, path: "a.ts" })).toBe(
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/a.ts",
);
});
it("keeps the scheme and port of a self-hosted http remote", () => {
expect(
buildForgeReferenceUrl("gitea", {
remoteUrl: "http://forge.test:3000/team/repo.git",
forgeWeb: { host: "forge.test", referenceRepository: "upstream/repo" },
sigil: "#",
number: 12,
}),
).toBe("http://forge.test:3000/upstream/repo/issues/12");
});
});
+32 -20
View File
@@ -6,17 +6,23 @@
* format); an unknown forge has no grammar and yields null, so the action is
* simply absent rather than wrong.
*
* URL grammar lives on each client forge module. The repo identity and host
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
* URL grammar lives on each client forge module. The daemon resolves the web
* host (SSH config aliases, cloud SSH hosts) and the repository that owns
* issue and change-request numbers (a fork's parent) and publishes them as
* `forgeWeb`; branch and file links stay on the origin repository.
*/
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import {
forgeWebHost,
isForgeCloudHost,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { getClientForgeLogicModule } from "@/git/forges";
export interface ForgeBlobUrlInput {
remoteUrl: string | null | undefined;
forgeWeb?: ForgeWeb | null;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
@@ -25,12 +31,17 @@ export interface ForgeBlobUrlInput {
export interface ForgeBranchTreeUrlInput {
remoteUrl: string | null | undefined;
forgeWeb?: ForgeWeb | null;
branch: string | null | undefined;
}
export interface ForgeReferenceUrlInput {
remoteUrl: string | null | undefined;
/** Repository path of a cross-repository reference; the remote's repository when absent. */
forgeWeb?: ForgeWeb | null;
/**
* Repository path of a cross-repository reference; the repository that owns
* issue numbers (`forgeWeb.referenceRepository`, else the remote's) when absent.
*/
repo?: string | null;
sigil: ForgeReferenceSigil;
number: number;
@@ -62,13 +73,6 @@ interface ForgeWebLocation {
repo: string;
}
/**
* Web host + repo path from a remote. The host is the remote's host, except a
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
* Self-hosted hosts are returned untouched. The repo path supports nested
* groups (e.g. GitLab subgroups) since it is the full remote path.
*/
function isValidRepoPath(path: string): boolean {
const segments = path.split("/").filter((segment) => segment.length > 0);
if (segments.length === 0) {
@@ -77,9 +81,17 @@ function isValidRepoPath(path: string): boolean {
return !segments.includes("..");
}
/**
* Web location of the origin repository. The host is the daemon's
* `forgeWeb.host`. A daemon without `forgeWeb` publishes none, so the remote's
* own host is used, with a cloud SSH alias mapped to the forge's web host.
* The repo path supports nested groups (e.g. GitLab subgroups) since it is
* the full remote path.
*/
function resolveForgeWebLocation(
forge: string,
remoteUrl: string | null | undefined,
forgeWeb: ForgeWeb | null | undefined,
): ForgeWebLocation | null {
if (!remoteUrl) {
return null;
@@ -88,9 +100,9 @@ function resolveForgeWebLocation(
if (!location || !isValidRepoPath(location.path)) {
return null;
}
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
const isCloudHost = cloudHosts.includes(location.host);
const webHost = isCloudHost ? cloudHosts[0] : location.host;
// COMPAT(forgeWeb): added in v0.16.0, remove the remote-derived host after 2027-06-01 once daemon floor >= v0.16.0.
const webHost = forgeWeb?.host ?? forgeWebHost(forge, location.host);
const isCloudHost = isForgeCloudHost(forge, location.host);
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
// web port, and a canonicalized cloud host always serves on the default port.
@@ -139,7 +151,7 @@ export function buildForgeBranchTreeUrl(
input: ForgeBranchTreeUrlInput,
): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
const branch = input.branch?.trim();
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
@@ -149,7 +161,7 @@ export function buildForgeBranchTreeUrl(
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
const branch = input.branch?.trim();
const filePath = normalizeBlobPath(input.path);
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
@@ -175,8 +187,8 @@ export function buildForgeReferenceUrl(
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
(path) => path.sigil === input.sigil,
);
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const repo = input.repo ?? location?.repo;
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
const repo = input.repo ?? input.forgeWeb?.referenceRepository ?? location?.repo;
if (
!referencePath ||
!location ||
@@ -96,6 +96,8 @@ export interface AppSettings {
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
sidebarRowItems: SidebarRowItems;
sidebarChecksDisplay: SidebarChecksDisplay;
/** Plugin workspace row badge items by `<pluginId>/<itemId>`; a missing key shows. */
sidebarPluginRowBadgeItems: Record<string, boolean>;
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
sidebarNavItems: SidebarNavPreference[];
/** Sidebar footer items in display order; empty means the default order, all visible. */
@@ -156,6 +158,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
sidebarWorkspaceTrailing: "timestamp",
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
sidebarPluginRowBadgeItems: {},
sidebarNavItems: [],
sidebarFooterItems: [],
usage: DEFAULT_USAGE_PREFERENCES,
@@ -253,6 +256,7 @@ const StoredAppSettingsSchema = z
.enum(["iconAndText", "icon", "none"])
.optional()
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
sidebarPluginRowBadgeItems: z.record(z.string(), z.boolean()).catch({}),
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
usage: UsagePreferencesSchema,
@@ -4,20 +4,32 @@ export type KeyboardFocusSurface = "message-input" | "terminal";
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
const surfaces = new Map<symbol, KeyboardFocusSurface>();
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
return {
declare(surface: KeyboardFocusSurface) {
const token = Symbol(surface);
surfaces.set(token, surface);
notify();
return () => {
surfaces.delete(token);
if (surfaces.delete(token)) notify();
};
},
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
if (commandCenterOpen) return "command-center";
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
for (const surface of surfaces.values()) focused = surface;
return focused;
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
@@ -10,6 +10,7 @@ export interface KeyboardSourceHandlers {
keyUp(event: KeyboardInputEvent): void;
blur(): void;
commandCenterOpen(): boolean;
subscribeCommandCenter(listener: () => void): () => void;
}
export function nativeKeyboardInput(
event: KeyboardInputEvent,
@@ -23,5 +24,6 @@ export interface KeyboardPolicyInput {
bindings: readonly ParsedShortcutBinding[];
chordState?: ChordState;
isMac: boolean;
usesDesktopBindings: boolean;
enabled: boolean;
}
@@ -366,7 +366,13 @@ describe("Electron guest source through shared resolution", () => {
},
captureFocusRestore: (id) => restored.push(id),
},
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
{
keyDown: pipeline.keyDown,
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
},
);
receive({
browserId: "tab-1",
@@ -1,5 +1,5 @@
import { requireNativeModule } from "expo-modules-core";
import type { HardwareKeyboardEvent } from "./hardware";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
@@ -19,6 +19,9 @@ export function subscribeHardwareKeyboard(
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
// A focused Android EditText types nothing for Tab, Shift+Tab or Alt+Shift+[,
// so there is no character to take from it.
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
@@ -1,9 +1,10 @@
import { requireNativeModule } from "expo-modules-core";
import type { HardwareKeyboardEvent } from "./hardware";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
setOverlayKeys(keys: readonly string[]): void;
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
addListener(
event: "onHardwareKeyboardShortcut",
handler: (event: HardwareKeyboardEvent) => void,
@@ -19,6 +20,9 @@ export function subscribeHardwareKeyboard(
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
export function setHardwareKeyboardClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void {
module.setClaimedShortcuts(shortcuts);
}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
@@ -3,12 +3,19 @@ export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
isComposing: boolean;
phase: "down" | "up";
}
/** A shortcut the native window takes from the focused text control, by DOM `code`. */
export interface ClaimedShortcut {
code: string;
shift: boolean;
alt: boolean;
}
export function subscribeHardwareKeyboard(
_handler: (event: HardwareKeyboardEvent) => void,
): () => void {
return () => {};
}
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
export function getHardwareKeyboardPresent(): boolean {
return false;
@@ -1,11 +1,19 @@
import type { KeyboardSourceHandlers } from "./contract";
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
import { attachHardwareKeyboardSource } from "./native";
import type { KeyboardPolicyInput, KeyboardSourceHandlers } from "./contract";
import {
setHardwareKeyboardClaimedShortcuts,
setHardwareKeyboardOverlayKeys,
subscribeHardwareKeyboard,
} from "./hardware";
import { createHardwareKeyboardSource } from "./native";
const hardwareKeyboard = createHardwareKeyboardSource({
subscribe: subscribeHardwareKeyboard,
setOverlayKeys: setHardwareKeyboardOverlayKeys,
setClaimedShortcuts: setHardwareKeyboardClaimedShortcuts,
});
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
return attachHardwareKeyboardSource(
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
handlers,
);
return hardwareKeyboard.attach(handlers);
}
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
hardwareKeyboard.publishPolicy(input);
}
@@ -1,4 +1,3 @@
import { usesDesktopBindings } from "../conventions";
import { getDesktopHost } from "@/desktop/host";
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -26,7 +25,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
}
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
const policy = input.enabled
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
? buildBrowserKeyboardPolicy(input)
: { menuPrefixes: [], prefixes: [] };
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
}
@@ -1,31 +1,92 @@
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
import { focusSurfaces } from "../focus-surfaces";
import { overlays } from "../overlays";
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
import type { HardwareKeyboardEvent } from "./hardware";
import {
nativeKeyboardInput,
type KeyboardPolicyInput,
type KeyboardSourceHandlers,
} from "./contract";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
interface HardwareKeySource {
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
setOverlayKeys(keys: readonly string[]): void;
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
}
export function attachHardwareKeyboardSource(
source: HardwareKeySource,
handlers: KeyboardSourceHandlers,
): () => void {
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
updateKeys();
const unsubscribeOverlays = overlays.subscribe(updateKeys);
const unsubscribeHardware = source.subscribe((input) => {
const event = { ...input, preventDefault() {}, stopPropagation() {} };
if (input.phase === "up") {
handlers.keyUp(event);
return;
const DIGIT_CODES = Array.from({ length: 9 }, (_, index) => `Digit${index + 1}`);
/**
* The shortcuts a focused text control would otherwise type or act on.
*
* The native window observes every press but cannot wait for the pipeline to
* decide, so it has to claim these keys up front: without a claim, Shift+Tab
* cycles the agent mode and the composer also inserts a tab. Cmd and Ctrl
* combos do not type, and the platform's own editing combos stay with the text
* control through `editable: false`. Unmodified keys belong to overlays and the
* composer's Return.
*/
function claimedShortcuts(
policy: KeyboardPolicyInput | null,
commandCenterOpen: boolean,
): ClaimedShortcut[] {
if (!policy?.enabled) return [];
const context = {
isMac: policy.isMac,
usesDesktopBindings: policy.usesDesktopBindings,
commandCenterOpen,
focusScope: focusSurfaces.resolve(commandCenterOpen, true),
};
const claimed = new Map<string, ClaimedShortcut>();
for (const binding of policy.bindings) {
const combo = binding.parsedChord[0];
if (!combo || binding.preventDefault === false) continue;
if (combo.meta || combo.ctrl || combo.mod || !(combo.shift || combo.alt)) continue;
if (!matchesKeyboardShortcutContext(binding.when, context)) continue;
const shift = combo.shift === true;
const alt = combo.alt === true;
for (const code of combo.code === "Digit" ? DIGIT_CODES : [combo.code]) {
claimed.set(`${code}:${shift}:${alt}`, { code, shift, alt });
}
handlers.keyDown(
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
);
});
return () => {
unsubscribeHardware();
unsubscribeOverlays();
source.setOverlayKeys([]);
}
return [...claimed.values()];
}
export function createHardwareKeyboardSource(source: HardwareKeySource) {
let policy: KeyboardPolicyInput | null = null;
let updateClaims = () => {};
return {
publishPolicy(next: KeyboardPolicyInput) {
policy = next;
updateClaims();
},
attach(handlers: KeyboardSourceHandlers): () => void {
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
updateClaims = () =>
source.setClaimedShortcuts(claimedShortcuts(policy, handlers.commandCenterOpen()));
updateKeys();
updateClaims();
const unsubscribeOverlays = overlays.subscribe(updateKeys);
const unsubscribeFocus = focusSurfaces.subscribe(() => updateClaims());
const unsubscribeCommandCenter = handlers.subscribeCommandCenter(() => updateClaims());
const unsubscribeHardware = source.subscribe((input) => {
const event = { ...input, preventDefault() {}, stopPropagation() {} };
if (input.phase === "up") {
handlers.keyUp(event);
return;
}
handlers.keyDown(
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
);
});
return () => {
unsubscribeHardware();
unsubscribeFocus();
unsubscribeCommandCenter();
unsubscribeOverlays();
updateClaims = () => {};
source.setOverlayKeys([]);
source.setClaimedShortcuts([]);
};
},
};
}
@@ -25,6 +25,7 @@ describe("DOM source adapter", () => {
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
@@ -130,7 +130,7 @@ describe("hardware source adapter", () => {
it.each([true, false])(
"delivers hardware down/up and declared focus with mac=%s",
async (isMac) => {
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
const { focusSurfaces } = await import("./internal/focus-surfaces");
const actions: string[] = [];
const releases: string[] = [];
@@ -148,23 +148,22 @@ describe("hardware source adapter", () => {
},
overlays: createOverlayRegistry(),
});
const detach = attachHardwareKeyboardSource(
{
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
},
setOverlayKeys() {},
const detach = createHardwareKeyboardSource({
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
},
{
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
},
);
setOverlayKeys() {},
setClaimedShortcuts() {},
}).attach({
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const down = {
...keyboardEvent("k", "KeyK", isMac),
isComposing: false,
@@ -180,4 +179,94 @@ describe("hardware source adapter", () => {
expect(events.size).toBe(0);
},
);
it("claims the Shift and Option shortcuts a focused text control would otherwise type", async () => {
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
const { focusSurfaces } = await import("./internal/focus-surfaces");
let claimed: string[] = [];
const source = createHardwareKeyboardSource({
subscribe: () => () => {},
setOverlayKeys() {},
setClaimedShortcuts(shortcuts) {
claimed = shortcuts
.map(({ code, shift, alt }) => [alt && "Alt", shift && "Shift", code])
.map((parts) => parts.filter(Boolean).join("+"))
.sort();
},
});
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
let commandCenterOpen = false;
const commandCenterListeners = new Set<() => void>();
const detach = source.attach({
keyDown() {},
keyUp() {},
blur() {},
commandCenterOpen: () => commandCenterOpen,
subscribeCommandCenter(listener) {
commandCenterListeners.add(listener);
return () => commandCenterListeners.delete(listener);
},
});
const setCommandCenterOpen = (open: boolean) => {
commandCenterOpen = open;
for (const listener of commandCenterListeners) listener();
};
// iPad tab switching uses Cmd+Shift+[ / ], which no text control types.
expect(claimed).toEqual([]);
const releaseComposer = focusSurfaces.declare("message-input");
expect(claimed).toEqual(["Shift+Tab"]);
setCommandCenterOpen(true);
expect(claimed).toEqual([]);
setCommandCenterOpen(false);
expect(claimed).toEqual(["Shift+Tab"]);
releaseComposer();
expect(claimed).toEqual([]);
source.publishPolicy({
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Alt+M" }),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
const releaseRebound = focusSurfaces.declare("message-input");
expect(claimed).toEqual(["Alt+KeyM"]);
releaseRebound();
// A chord's later step is only a shortcut while the chord is in progress;
// claiming it always would stop the composer typing a capital A.
source.publishPolicy({
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Mod+J Shift+A" }),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
const releaseChord = focusSurfaces.declare("message-input");
expect(claimed).toEqual([]);
releaseChord();
const releaseFinal = focusSurfaces.declare("message-input");
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: false,
});
expect(claimed).toEqual([]);
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
expect(claimed).toEqual(["Shift+Tab"]);
detach();
expect(claimed).toEqual([]);
releaseFinal();
});
});
+5 -1
View File
@@ -62,7 +62,7 @@ export function useKeyboardShortcuts({
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
const publishBrowserShortcutPolicy = useCallback(
(chordState?: ChordState) => {
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
publishKeyboardPolicy({ bindings, chordState, isMac, usesDesktopBindings, enabled });
},
[bindings, enabled, isMac],
);
@@ -265,6 +265,10 @@ export function useKeyboardShortcuts({
keyUp: handleKeyUp,
blur: resetModifiers,
commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen,
subscribeCommandCenter: (listener) =>
useKeyboardShortcutsStore.subscribe((state, previous) => {
if (state.commandCenterOpen !== previous.commandCenterOpen) listener();
}),
});
return () => {
detach();
@@ -134,6 +134,7 @@ describe("DOM source through the shared resolver", () => {
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
@@ -26,6 +26,7 @@ function installation(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -105,6 +105,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,6 +37,7 @@ function installed(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -47,6 +47,7 @@ function plugin(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
+41
View File
@@ -101,6 +101,7 @@ describe("evaluatePluginClientBundle", () => {
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }),
plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }),
plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }),
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Status", icon: "Blocks", Component }),
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
@@ -132,6 +133,7 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -152,6 +154,7 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -282,6 +285,44 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("Sidebar item main has no title");
});
it("collects workspace row badge items and rejects duplicate and malformed ones", () => {
const plugin = evaluatePluginClientBundle(
"example",
bundle(`
function Item() { return null; }
plugin.addWorkspaceRowBadgeItem({ id: "status", title: " Status ", icon: "Blocks", Component: Item });
`),
);
expect(
plugin.workspaceRowBadgeItems.map(({ id, title, icon }) => ({ id, title, icon })),
).toEqual([{ id: "status", title: "Status", icon: "Blocks" }]);
const row = (fields: string) =>
bundle(`function Item() { return null; } plugin.addWorkspaceRowBadgeItem({ ${fields} });`);
expect(() =>
evaluatePluginClientBundle(
"example",
bundle(`
function Item() { return null; }
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "One", icon: "Blocks", Component: Item });
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Two", icon: "Blocks", Component: Item });
`),
),
).toThrow("Duplicate workspace row badge item: status");
expect(() =>
evaluatePluginClientBundle(
"example",
row(`id: "status", title: " ", icon: "Blocks", Component: Item`),
),
).toThrow("Workspace row badge item status has no title");
expect(() =>
evaluatePluginClientBundle(
"example",
row(`id: "status", title: "Status", icon: "Blocks", Component: "Item"`),
),
).toThrow("Workspace row badge item status is not a component");
});
it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => {
const plugin = evaluatePluginClientBundle(
"example",
+21
View File
@@ -37,6 +37,7 @@ import type {
PluginScreenContribution,
PluginSidebarItemContribution,
PluginWorkspacePanelContribution,
PluginWorkspaceRowBadgeItemContribution,
} from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
@@ -133,6 +134,7 @@ export function runPluginClientBundle(
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -151,6 +153,7 @@ export function runPluginClientBundle(
header: new Set(),
footer: new Set(),
};
const workspaceRowItemIds = new Set<string>();
const filePanelIds = new Set<string>();
const composerItemIds = new Set<string>();
const newWorkspaceItemIds = new Set<string>();
@@ -323,6 +326,23 @@ export function runPluginClientBundle(
addSidebarFooterItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("footer", contribution);
},
addWorkspaceRowBadgeItem(contribution: PluginWorkspaceRowBadgeItemContribution) {
const itemId = requireId(contribution.id, "workspace row badge item id");
if (workspaceRowItemIds.has(itemId))
throw new Error(`Duplicate workspace row badge item: ${itemId}`);
const title = normalizePluginText(contribution.title);
if (!title) throw new Error(`Workspace row badge item ${itemId} has no title`);
const icon = parsePluginIcon(`Workspace row badge item ${itemId}`, contribution.icon);
if (!isComponentType(contribution.Component)) {
throw new Error(`Workspace row badge item ${itemId} is not a component`);
}
workspaceRowItemIds.add(itemId);
return register(
collector.workspaceRowBadgeItems,
{ id: itemId, title, icon, Component: contribution.Component },
() => workspaceRowItemIds.delete(itemId),
);
},
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
// Titled by its id; a legacy item pointing at it lends the header its own title instead.
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
@@ -634,6 +654,7 @@ export function runPluginClientBundle(
sidebarViews: collector.sidebarViews,
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
workspaceRowBadgeItems: collector.workspaceRowBadgeItems,
filePanels: collector.filePanels,
composerItems: collector.composerItems,
newWorkspaceItems: collector.newWorkspaceItems,
@@ -46,6 +46,7 @@ function installation(invoked: string[]): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
+1 -1
View File
@@ -14,7 +14,7 @@ import type { InstalledPlugin } from "./types";
/** `withUnistyles` mapping that hands a host the plugin theme. */
export const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
function renderNothing() {
export function renderNothing() {
return null;
}
@@ -11,6 +11,7 @@ export {
export { ExternalLink } from "@/components/ui/external-link";
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
export { WorkspaceRowBadge } from "@/plugins/workspace-row-badge-items/kit";
export { DocumentFrame } from "@/file-panels/document-frame";
+1
View File
@@ -125,6 +125,7 @@ export class PluginRegistry {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,6 +37,7 @@ function installed(
footer: [{ id: "status", title: "Status", Component: () => null }],
},
legacySidebarItems,
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -51,6 +51,7 @@ function installation(
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
workspaceRowBadgeItems: [],
legacySidebarItems: sidebarItems.map((item) => ({
...item,
title: item.id,
+1
View File
@@ -64,6 +64,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -27,6 +27,7 @@ function plugin(input: {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -34,6 +34,7 @@ vi.mock("../client-runtime", () => ({
}));
vi.mock("../icons", () => ({
Icon: () => null,
PluginIconView: () => null,
pluginIconComponent: () => () => null,
}));
+5
View File
@@ -17,6 +17,7 @@ import type {
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarViewContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution as SDKPluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution as SDKPluginScreenContribution,
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
PluginTimelineRendererContribution,
@@ -33,6 +34,8 @@ type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribut
type PluginComposerPillContribution = SDKPluginComposerPillContribution<PluginText>;
type PluginSidebarContribution = SDKPluginSidebarContribution<PluginText>;
type PluginSidebarItemContribution = SDKPluginSidebarItemContribution<PluginText>;
type PluginWorkspaceRowBadgeItemContribution =
SDKPluginWorkspaceRowBadgeItemContribution<PluginText>;
type PluginScreenContribution = SDKPluginScreenContribution<PluginText>;
type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution<PluginText>;
type WorkspacePanelInput = SDKPluginWorkspacePanelContribution<PluginText>;
@@ -56,6 +59,7 @@ export interface EvaluatedPlugin {
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
legacySidebarItems: PluginSidebarContribution[];
workspaceRowBadgeItems: PluginWorkspaceRowBadgeItemContribution[];
filePanels: PluginFilePanelContribution[];
composerItems: PluginComposerItemContribution[];
newWorkspaceItems: PluginNewWorkspaceItemContribution[];
@@ -95,6 +99,7 @@ export type {
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution,
PluginSettingsScreenContribution,
PluginThemeContribution,
@@ -24,6 +24,7 @@ function installed(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -0,0 +1,96 @@
import type { PluginTheme } from "@getpaseo/plugin";
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { withUnistyles } from "react-native-unistyles";
import { useAppSettings } from "@/hooks/use-settings";
import { useInstalledPlugins } from "../registry";
import { pluginThemeMapping, renderNothing, usePluginItemHost } from "../item-host";
import { PluginEnvironmentProvider } from "../popover";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import type { InstalledPlugin, PluginWorkspaceRowBadgeItemContribution } from "../types";
import { isWorkspaceRowBadgeItemVisible, workspaceRowBadgeItemKey } from "./model";
import { WorkspaceRowBadgeTitleContext } from "./kit";
export { WorkspaceRowBadge } from "./kit";
export {
groupPluginWorkspaceRowBadgeItems,
isWorkspaceRowBadgeItemVisible,
type PluginWorkspaceRowBadgeItemGroup,
} from "./model";
interface RowBadgeItemTarget {
plugin: InstalledPlugin;
item: PluginWorkspaceRowBadgeItemContribution;
}
/** The visible plugin row items installed on this workspace's host. */
export function usePluginWorkspaceRowBadgeItems(serverId: string | null): RowBadgeItemTarget[] {
const installed = useInstalledPlugins();
const {
settings: { sidebarPluginRowBadgeItems },
} = useAppSettings();
return useMemo(
() =>
installed
.filter((plugin) => plugin.serverId === serverId)
.flatMap((plugin) => plugin.workspaceRowBadgeItems.map((item) => ({ plugin, item })))
.filter(({ plugin, item }) =>
isWorkspaceRowBadgeItemVisible(
sidebarPluginRowBadgeItems,
workspaceRowBadgeItemKey(plugin.id, item.id),
),
),
[installed, serverId, sidebarPluginRowBadgeItems],
);
}
/**
* Plugin items on a workspace's meta line, after the row's own. No wrapper views: an item that
* renders nothing must leave no gap behind it on the line.
*/
export function PluginWorkspaceRowBadgeItems({
targets,
workspaceId,
}: {
targets: readonly RowBadgeItemTarget[];
workspaceId: string;
}) {
return targets.map((target) => (
<ThemedRowBadgeItemHost
key={workspaceRowBadgeItemKey(target.plugin.id, target.item.id)}
target={target}
workspaceId={workspaceId}
uniProps={pluginThemeMapping}
/>
));
}
function RowBadgeItemHost({
target: { plugin, item },
workspaceId,
theme,
}: {
target: RowBadgeItemTarget;
workspaceId: string;
theme: PluginTheme;
}) {
const { environment, props: hostProps } = usePluginItemHost(plugin, theme);
const props = useMemo<PluginWorkspaceRowBadgeItemProps>(
() => ({ ...hostProps, workspaceId }),
[hostProps, workspaceId],
);
const title = plugin.i18n.resolve(item.title, plugin.locale);
const Item = item.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Item} renderError={renderNothing}>
<PluginEnvironmentProvider environment={environment}>
<WorkspaceRowBadgeTitleContext.Provider value={title}>
<Item {...props} />
</WorkspaceRowBadgeTitleContext.Provider>
</PluginEnvironmentProvider>
</SurfaceErrorBoundary>
);
}
const ThemedRowBadgeItemHost = withUnistyles(RowBadgeItemHost);
@@ -0,0 +1,93 @@
import type { WorkspaceRowBadgeProps, WorkspaceRowBadgeTone } from "@getpaseo/plugin/client/ui";
import { createContext, useContext, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge";
import type { Theme } from "@/styles/theme";
import { parsePluginIcon, PluginIconView } from "../icons";
/** The item's title, the accessible name of a badge that has neither a label nor its own. */
export const WorkspaceRowBadgeTitleContext = createContext<string | undefined>(undefined);
const ThemedPluginIconView = withUnistyles(PluginIconView);
function toneColor(theme: Theme, tone: WorkspaceRowBadgeTone): string {
if (tone === "success") return theme.colors.statusSuccess;
if (tone === "warning") return theme.colors.statusWarning;
if (tone === "danger") return theme.colors.statusDanger;
if (tone === "info") return theme.colors.statusDotRunning;
return theme.colors.foregroundMuted;
}
const TONE_MAPPINGS: Record<WorkspaceRowBadgeTone, (theme: Theme) => { color: string }> = {
muted: (theme) => ({ color: toneColor(theme, "muted") }),
success: (theme) => ({ color: toneColor(theme, "success") }),
warning: (theme) => ({ color: toneColor(theme, "warning") }),
danger: (theme) => ({ color: toneColor(theme, "danger") }),
info: (theme) => ({ color: toneColor(theme, "info") }),
};
/**
* A plugin's badge on the line under a workspace title, drawn like the row's own peers: one glyph
* at the line's size and a short word, in the color of the state it reports. The plugin owns the
* slot, so the badge draws no separator.
*/
export function WorkspaceRowBadge({
icon,
label,
tone = "muted",
accessibilityLabel,
}: WorkspaceRowBadgeProps) {
const itemTitle = useContext(WorkspaceRowBadgeTitleContext);
// Parsed at render, not registration, so an invalid icon throws to the item's boundary.
const parsedIcon = useMemo(
() => (icon === undefined ? null : parsePluginIcon("Workspace row badge", icon)),
[icon],
);
if (!parsedIcon && !label) return null;
return (
<View style={styles.item} accessibilityLabel={accessibilityLabel ?? (label || itemTitle)}>
{parsedIcon ? (
<ThemedPluginIconView
icon={parsedIcon}
size={HOST_BADGE_ICON_SIZE}
uniProps={TONE_MAPPINGS[tone]}
/>
) : null}
{label ? (
<Text style={[styles.text, textToneStyle(tone)]} numberOfLines={1}>
{label}
</Text>
) : null}
</View>
);
}
// Read inside render, never into a module-scope table — see docs/unistyles.md.
function textToneStyle(tone: WorkspaceRowBadgeTone) {
if (tone === "success") return styles.success;
if (tone === "warning") return styles.warning;
if (tone === "danger") return styles.danger;
if (tone === "info") return styles.info;
return styles.muted;
}
const styles = StyleSheet.create((theme) => ({
item: {
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
flexShrink: 1,
},
text: {
fontSize: theme.fontSize.sm,
lineHeight: 16,
flexShrink: 1,
},
muted: { color: toneColor(theme, "muted") },
success: { color: toneColor(theme, "success") },
warning: { color: toneColor(theme, "warning") },
danger: { color: toneColor(theme, "danger") },
info: { color: toneColor(theme, "info") },
}));
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { InstalledPlugin } from "../types";
import { groupPluginWorkspaceRowBadgeItems, isWorkspaceRowBadgeItemVisible } from "./model";
function Item() {
return null;
}
function installed(id: string, serverId: string, itemIds: string[]): InstalledPlugin {
return {
id,
serverId,
locale: "en",
i18n: { resolve: (text: string) => text },
workspaceRowBadgeItems: itemIds.map((itemId) => ({
id: itemId,
title: `${id} ${itemId}`,
icon: "Gauge",
Component: Item,
})),
} as unknown as InstalledPlugin;
}
describe("groupPluginWorkspaceRowBadgeItems", () => {
it("lists each plugin item once across hosts, in install order", () => {
const groups = groupPluginWorkspaceRowBadgeItems([
installed("status", "host-a", ["state"]),
installed("asks", "host-a", ["open"]),
installed("status", "host-b", ["state"]),
]);
expect(groups.map(({ key, title }) => ({ key, title }))).toEqual([
{ key: "status/state", title: "status state" },
{ key: "asks/open", title: "asks open" },
]);
});
});
describe("isWorkspaceRowBadgeItemVisible", () => {
it("shows items until they are switched off", () => {
expect(isWorkspaceRowBadgeItemVisible({}, "status/state")).toBe(true);
expect(isWorkspaceRowBadgeItemVisible({ "status/state": true }, "status/state")).toBe(true);
expect(isWorkspaceRowBadgeItemVisible({ "status/state": false }, "status/state")).toBe(false);
});
});
@@ -0,0 +1,45 @@
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { InstalledPlugin } from "../types";
/** One workspace row badge item, coalesced across every host that contributes it. */
export interface PluginWorkspaceRowBadgeItemGroup {
/** `<pluginId>/<itemId>`: the key its Show toggle is stored under. */
key: string;
pluginId: string;
itemId: string;
title: string;
icon: PluginIcon;
}
export function workspaceRowBadgeItemKey(pluginId: string, itemId: string): string {
return `${pluginId}/${itemId}`;
}
/** An item missing from the stored record has never been toggled, so it shows. */
export function isWorkspaceRowBadgeItemVisible(
visibility: Readonly<Record<string, boolean>>,
key: string,
): boolean {
return visibility[key] !== false;
}
/** Distinct items in install order, for the Show menu. Each host's copy draws on its own rows. */
export function groupPluginWorkspaceRowBadgeItems(
plugins: readonly InstalledPlugin[],
): PluginWorkspaceRowBadgeItemGroup[] {
const groups = new Map<string, PluginWorkspaceRowBadgeItemGroup>();
for (const plugin of plugins) {
for (const item of plugin.workspaceRowBadgeItems) {
const key = workspaceRowBadgeItemKey(plugin.id, item.id);
if (groups.has(key)) continue;
groups.set(key, {
key,
pluginId: plugin.id,
itemId: item.id,
title: plugin.i18n.resolve(item.title, plugin.locale),
icon: item.icon,
});
}
}
return [...groups.values()];
}
@@ -354,6 +354,21 @@ describe("ReplicaCache", () => {
);
});
it("preserves where the workspace's forge is on the web across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } });
const forgeWeb = { host: "github.com", referenceRepository: "main-owner/repo" };
const workspace = normalizeWorkspaceDescriptor({ ...workspacePayload(), forgeWeb });
baseline.workspaces.set(workspace.id, workspace);
commitDirectory(writer, SERVER_ID, baseline);
await writer.flush();
const restored = await createCache(storage).readDirectory(SERVER_ID);
expect(restored.workspaces.get(workspace.id)?.forgeWeb).toEqual(forgeWeb);
});
it("preserves workspace sorting timestamps across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
@@ -3,6 +3,7 @@ import { z } from "zod";
import {
AgentStatusSchema,
AgentTimelineItemPayloadSchema,
ForgeWebSchema,
WorkspaceGitHubRuntimePayloadSchema,
} from "@getpaseo/protocol/messages";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
@@ -335,6 +336,7 @@ const StoredWorkspaceSchema = z.strictObject({
gitRuntime: WorkspaceGitRuntimeSchema,
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
forge: z.string().optional(),
forgeWeb: ForgeWebSchema.optional(),
});
const StoredProjectSchema = z.strictObject({
@@ -768,6 +770,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
gitRuntime: workspace.gitRuntime,
githubRuntime: workspace.githubRuntime,
forge: workspace.forge,
forgeWeb: workspace.forgeWeb,
};
}
@@ -3919,7 +3919,11 @@ function WorkspaceMarkdownLinks({
}: AssistantFileLinkResolverProviderProps & { workspace: WorkspaceDescriptor | null | undefined }) {
return (
<AssistantFileLinkResolverProvider {...fileLinks}>
<ForgeShorthandProvider forge={workspace?.forge} remoteUrl={workspace?.gitRuntime?.remoteUrl}>
<ForgeShorthandProvider
forge={workspace?.forge}
remoteUrl={workspace?.gitRuntime?.remoteUrl}
forgeWeb={workspace?.forgeWeb}
>
{children}
</ForgeShorthandProvider>
</AssistantFileLinkResolverProvider>
+2
View File
@@ -140,6 +140,7 @@ export interface WorkspaceDescriptor {
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
forge?: WorkspaceDescriptorPayload["forge"];
forgeWeb?: WorkspaceDescriptorPayload["forgeWeb"];
project?: ProjectPlacementPayload;
}
@@ -185,6 +186,7 @@ export function normalizeWorkspaceDescriptor(
gitRuntime: payload.gitRuntime,
githubRuntime: payload.githubRuntime,
forge: payload.forge,
forgeWeb: payload.forgeWeb,
project: payload.project,
};
}
+38 -15
View File
@@ -2,7 +2,13 @@ import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { usageCopy } from "./copy";
import type { UsageDisplayAs } from "./preferences";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
import type {
AvailableUsageReport,
UsageReport,
UsageReportEntry,
UsageView,
UsageWindow,
} from "./types";
export function usedPercent(window: UsageWindow): number | null {
if (window.usedPct != null) return window.usedPct;
@@ -205,13 +211,34 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
}
export interface UsageSnapshot {
report: AvailableUsageReport;
successfulAt: string;
retained: boolean;
}
/** The latest successful values and their freshness, independent of the latest attempt's outcome. */
export function resolveUsageSnapshot(entry: UsageReportEntry): UsageSnapshot | null {
if (entry.report.status === "available") {
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Old daemons expose only attempt time for available reports, never for failures.
return {
report: entry.report,
successfulAt: entry.successfulAt ?? entry.fetchedAt,
retained: false,
};
}
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Older daemons cannot provide history; a failed attempt is never a successful update.
const retained = entry.lastSuccessful;
if (!retained) return null;
return { report: retained.report, successfulAt: retained.fetchedAt, retained: true };
}
/** Current outcome owns messages; only a successful poll owns values and freshness. */
export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
const available = entry.report.status === "available";
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Older daemons cannot provide history; a failed attempt is never a successful update.
const snapshot = available ? null : (entry.lastSuccessful ?? null);
const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null);
const snapshot = resolveUsageSnapshot(entry);
const messages: string[] = [];
if (!available) {
if (entry.loginErrors)
@@ -224,16 +251,12 @@ export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
else messages.push(usageProblemText(entry.report, t));
}
return {
windows: content?.windows ?? [],
balances: content?.balances ?? [],
details: content?.details ?? [],
planLabel: content?.planLabel ?? null,
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Old daemons expose only attempt time for available reports, never for failures.
successfulAt: available
? (entry.successfulAt ?? entry.fetchedAt)
: (snapshot?.fetchedAt ?? null),
retained: snapshot !== null,
windows: snapshot?.report.windows ?? [],
balances: snapshot?.report.balances ?? [],
details: snapshot?.report.details ?? [],
planLabel: snapshot?.report.planLabel ?? null,
successfulAt: snapshot?.successfulAt ?? null,
retained: snapshot?.retained ?? false,
messages,
};
}
+26 -2
View File
@@ -139,6 +139,7 @@ describe("resolvePinnedUsage", () => {
expect(sources[0]).toEqual({
key: "claude:default",
icon: "<svg/>",
hasError: false,
windows: [
{
key: "claude:default/five-hour",
@@ -257,7 +258,7 @@ describe("resolvePinnedUsage", () => {
});
});
it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => {
it("shows retained windows and marks errored sources in the sidebar summary", () => {
if (claude.report.status !== "available" || codex.report.status !== "available")
throw new Error("fixtures must be available");
const expired: UsageReportEntry = {
@@ -273,7 +274,30 @@ it("keeps unavailable and error reports out of the sidebar summary even with ret
lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report },
report: { status: "error", error: "Store deleted" },
};
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
const expected = [
{ key: "claude:default", hasError: false, windows: ["31%"] },
{ key: "codex:default", hasError: true, windows: ["12%"] },
];
const summarize = (pins: UsagePreferences["pins"]) =>
resolvePinnedUsage([expired, failed], preferences(pins)).map((source) => ({
key: source.key,
hasError: source.hasError,
windows: source.windows.map((window) => window.percentText),
}));
expect(summarize(null)).toEqual(expected);
expect(
summarize([
{ sourceId: "claude", windowId: "five-hour" },
{ sourceId: "codex", windowId: "weekly" },
]),
).toEqual(expected);
expect(
resolvePinnedUsage(
[{ ...failed, lastSuccessful: undefined }],
preferences([{ sourceId: "codex", windowId: "weekly" }]),
),
).toEqual([]);
});
it("localizes pinned labels and meanings while preserving custom window names", async () => {
+14 -4
View File
@@ -1,7 +1,7 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
import { displayPercent } from "./model";
import { displayPercent, resolveUsageSnapshot } from "./model";
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
import { windowTone } from "./tone";
import type { UsageReportEntry, UsageTone } from "./types";
@@ -23,6 +23,8 @@ export interface PinnedUsageWindow {
export interface PinnedUsageSource {
key: string;
icon: string | null;
/** The displayed values are retained from before a failed refresh. */
hasError: boolean;
windows: PinnedUsageWindow[];
}
@@ -74,8 +76,9 @@ export function resolvePinnedUsage(
): PinnedUsageSource[] {
const pins = effectiveUsagePins(preferences, reports);
return groupBySource(reports).flatMap((entry) => {
if (entry.report.status !== "available") return [];
const windows = entry.report.windows
const snapshot = resolveUsageSnapshot(entry);
if (!snapshot) return [];
const windows = snapshot.report.windows
.filter(
(window) =>
displayPercent(window, preferences.displayAs) !== null &&
@@ -94,6 +97,13 @@ export function resolvePinnedUsage(
};
});
if (windows.length === 0) return [];
return [{ key: entry.id, icon: entry.icon ?? null, windows }];
return [
{
key: entry.id,
icon: entry.icon ?? null,
hasError: entry.report.status === "error",
windows,
},
];
});
}
+4 -3
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { displayPercent } from "./model";
import { displayPercent, resolveUsageSnapshot } from "./model";
import type { UsageReportEntry } from "./types";
/** Whether percentages read as the share used or the share left. */
@@ -55,8 +55,9 @@ export function effectiveUsagePins(
if (preferences.pins !== null) return preferences.pins;
const pins: UsagePin[] = [];
for (const entry of reports) {
if (entry.report.status !== "available") continue;
const withPercent = entry.report.windows.filter(
const snapshot = resolveUsageSnapshot(entry);
if (!snapshot) continue;
const withPercent = snapshot.report.windows.filter(
(window) => displayPercent(window, preferences.displayAs) !== null,
);
const marked = withPercent.filter((window) => window.summary);
+38 -6
View File
@@ -8,6 +8,7 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react-native";
import {
Pressable,
Text,
@@ -15,7 +16,8 @@ import {
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppVisible } from "@/hooks/use-app-visible";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
@@ -35,6 +37,9 @@ import {
} from "./pinned";
import { UsageModal } from "./usage-modal";
const ThemedTriangleAlert = withUnistyles(TriangleAlert);
const errorIconColor = (theme: Theme) => ({ color: theme.colors.statusDanger });
/** Each summary window with data on the usage host, under its source; empty while none has. */
export function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
@@ -60,7 +65,14 @@ export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSou
const label = t(builtinSidebarNavLabelKey("usage"));
const openUsage = useOpenSidebarUsage();
if (sources.length === 0) return null;
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
return (
<PinnedUsageTrigger
label={label}
sources={sources}
errorLabel={t("providerUsage.refreshFailed")}
onPress={openUsage}
/>
);
}
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
@@ -95,9 +107,15 @@ export function useOpenSidebarUsage(): () => void {
return openUsage;
}
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
function pinnedUsageLabel(
label: string,
sources: readonly PinnedUsageSource[],
errorLabel: string,
): string {
const windows = sources.flatMap((source) => source.windows);
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
const summary = windows.map((window) => window.label).join(", ");
const error = sources.some((source) => source.hasError) ? `, ${errorLabel}` : "";
return `${label}: ${summary}${error}`;
}
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
@@ -116,10 +134,12 @@ function layoutWidth(event: LayoutChangeEvent): number {
function PinnedUsageTrigger({
label,
sources,
errorLabel,
onPress,
}: {
label: string;
sources: readonly PinnedUsageSource[];
errorLabel: string;
onPress: () => void;
}) {
const [available, setAvailable] = useState<number | null>(null);
@@ -144,7 +164,7 @@ function PinnedUsageTrigger({
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={pinnedUsageLabel(label, sources)}
accessibilityLabel={pinnedUsageLabel(label, sources, errorLabel)}
style={triggerStyle}
testID="sidebar-usage"
>
@@ -185,10 +205,17 @@ function PinnedUsageLine({
return sources.map((source, sourceIndex) => (
<Fragment key={source.key}>
<View
style={sourceIndex === 0 ? null : styles.sourceGap}
style={[styles.source, sourceIndex === 0 ? null : styles.sourceGap]}
testID={measuring ? undefined : "sidebar-usage-source"}
>
<UsageSourceIcon svg={source.icon} size={14} />
{source.hasError ? (
<ThemedTriangleAlert
size={12}
uniProps={errorIconColor}
testID={measuring ? undefined : "sidebar-usage-error"}
/>
) : null}
</View>
{source.windows.map((window, windowIndex) => {
const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap;
@@ -251,6 +278,11 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
},
source: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
sourceGap: {
marginLeft: theme.spacing[3],
},
@@ -184,6 +184,55 @@ describe("planWorkspaceOpenTargets", () => {
]);
});
it("opens the branch and the active file on the web host the daemon resolved for an SSH alias", () => {
const aliasStatus = {
isGit: true,
remoteUrl: "git@github.com-work:acme/repo.git",
currentBranch: "feature/x",
forgeWeb: { host: "github.com" },
};
const open = (activeFile?: { path: string }) =>
planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile,
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus: aliasStatus,
forge: "github",
}).map((target) => (target.source === "forge" ? target.url : null));
expect(open()).toEqual(["https://github.com/acme/repo/tree/feature/x"]);
expect(open({ path: "src/app.ts" })).toEqual([
"https://github.com/acme/repo/blob/feature/x/src/app.ts",
]);
});
it("keeps the branch and file on the origin repository of a fork", () => {
const forkStatus = {
isGit: true,
remoteUrl: "git@github.com:fork-owner/repo.git",
currentBranch: "feature/only-on-fork",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
};
const forgeUrls = (activeFile: { path: string } | undefined) =>
planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile,
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus: forkStatus,
forge: "github",
}).flatMap((target) => (target.source === "forge" ? [target.url] : []));
const urls = [...forgeUrls(undefined), ...forgeUrls({ path: "src/app.ts" })];
expect(urls).toEqual([
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/src/app.ts",
]);
});
it("infers the forge from the remote URL when the forge input is null", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
@@ -1,3 +1,4 @@
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import { type Forge, forgeFromRemoteUrl, getForgePresentation } from "@/git/forge";
import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets";
import {
@@ -9,6 +10,7 @@ import {
interface CheckoutStatusForOpenTarget {
isGit: boolean;
remoteUrl?: string | null;
forgeWeb?: ForgeWeb | null;
currentBranch?: string | null;
}
@@ -118,6 +120,7 @@ function buildForgeWebUrl(
forge: Forge,
input: {
remoteUrl: string | null | undefined;
forgeWeb: ForgeWeb | null | undefined;
branch: string | null | undefined;
path: string | null;
lineStart?: number;
@@ -129,6 +132,7 @@ function buildForgeWebUrl(
return (
presentation.buildBlobUrl?.({
remoteUrl: input.remoteUrl,
forgeWeb: input.forgeWeb,
branch: input.branch,
path: input.path,
lineStart: input.lineStart,
@@ -139,6 +143,7 @@ function buildForgeWebUrl(
return (
presentation.buildBranchTreeUrl?.({
remoteUrl: input.remoteUrl,
forgeWeb: input.forgeWeb,
branch: input.branch,
}) ?? null
);
@@ -159,6 +164,7 @@ function planForgeOpenTarget(input: {
}
const url = buildForgeWebUrl(forge, {
remoteUrl: input.checkoutStatus.remoteUrl,
forgeWeb: input.checkoutStatus.forgeWeb,
branch: input.checkoutStatus.currentBranch,
path: input.resolvedFile?.relativePath ?? null,
lineStart: input.activeFile?.lineStart,
+5
View File
@@ -306,6 +306,11 @@ export interface PaseoAgentTimelineRefetchOptions {
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
/**
* Extends a `tail` or `before` page back to the start of the oldest turn it touches, so
* `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn.
*/
wholeTurns?: boolean;
requestId?: string;
}
+11 -1
View File
@@ -393,6 +393,7 @@ describe("lifecycle hook SDK types", () => {
expect(
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
@@ -426,8 +427,17 @@ describe("lifecycle hook SDK types", () => {
const { metadata, ...withoutMetadata } = request;
return withoutMetadata;
});
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
const { agent, outcome } = event;
const kind: "completed" | "failed" | "canceled" = outcome.kind;
// @ts-expect-error Turn payloads carry no history; read it from the agent's timeline.
event.timeline;
const turn = await paseo.agents
.ref(agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const items: readonly AgentTimelineItem[] = turn.entries
.filter((entry) => entry.turnId === event.turnId)
.map(({ item }) => item);
// @ts-expect-error Notification payloads have no creation request.
outcome.request;
});
+19
View File
@@ -121,6 +121,22 @@ export interface PluginSidebarViewContribution<Text extends PluginText = string>
Component: ComponentType<PluginSidebarViewProps>;
}
export interface PluginWorkspaceRowBadgeItemProps extends PluginHostProps {
workspaceId: string;
}
/**
* Part of the line under a workspace's title in the sidebar, on every workspace of the plugin's host.
* Render `WorkspaceRowBadge`, or nothing when there is nothing to say about this workspace.
*/
export interface PluginWorkspaceRowBadgeItemContribution<Text extends PluginText = string> {
id: string;
/** The item's toggle in the sidebar's Show menu. */
title: Text;
icon: PluginIcon;
Component: ComponentType<PluginWorkspaceRowBadgeItemProps>;
}
/**
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
* and color; buttons widen the props with their host and target context.
@@ -214,6 +230,9 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
addWorkspaceRowBadgeItem(
contribution: PluginWorkspaceRowBadgeItemContribution<PluginText>,
): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
addCommandCenterItem(
contribution: PluginCommandCenterItemContribution<PluginText>,
+2
View File
@@ -18,6 +18,8 @@ export type {
PluginSidebarViewProps,
PluginSidebarViewContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemProps,
PluginWorkspaceRowBadgeItemContribution,
PluginIcon,
PluginPanelLocation,
PluginOpenPanelOptions,
+13
View File
@@ -173,6 +173,19 @@ export declare const SidebarRow: ComponentType<SidebarRowProps>;
/** A line between groups of rows. Render it from a sidebar item's `Component`. */
export declare const SidebarSeparator: ComponentType;
export type WorkspaceRowBadgeTone = "muted" | "success" | "warning" | "danger" | "info";
export interface WorkspaceRowBadgeProps {
/** A Lucide icon name, inline SVG, or a component. */
icon?: SidebarIcon;
label?: string;
/** Default "muted". */
tone?: WorkspaceRowBadgeTone;
/** Defaults to `label`, then the item's `title`. */
accessibilityLabel?: string;
}
/** One item on a sidebar workspace row's meta line. Render it from a workspace row badge item's `Component`. */
export declare const WorkspaceRowBadge: ComponentType<WorkspaceRowBadgeProps>;
/** Paseo's loading state. Fills the panel; the panel decides when to show it. */
export declare const Loading: ComponentType;
+5 -5
View File
@@ -2,7 +2,6 @@ import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
JsonValue,
} from "@getpaseo/protocol/agent-types";
@@ -104,12 +103,13 @@ export interface PluginHookEvents {
"agent.turn.after": {
agent: PluginHookAgent;
turnId: string | null;
outcome: PluginTurnOutcome;
/**
* @deprecated Always empty. Fetch history on demand with
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
* The payload carries no history. Read the turn with
* `paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true })`
* and keep the entries whose `turnId` equals this `turnId`. The result is empty when a newer turn
* started before the read, or when `turnId` is `null`.
*/
timeline: readonly AgentTimelineItem[];
outcome: PluginTurnOutcome;
};
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission.after": {
+20
View File
@@ -108,6 +108,26 @@ export function normalizeHost(host: string): string {
return host.trim().replace(/\.+$/u, "").toLowerCase();
}
/** Whether `host` is one of the forge's public cloud hosts, SSH aliases included. */
export function isForgeCloudHost(forge: string, host: string): boolean {
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
return cloudHosts.includes(normalizeHost(host));
}
/**
* Web host for a remote host of `forge`. A cloud SSH alias (a non-first entry
* in the forge's `cloudHosts`, e.g. ssh.github.com) maps to the forge's web
* host (`cloudHosts[0]`); any other host, self-hosted included, is returned
* as is.
*/
export function forgeWebHost(forge: string, host: string): string {
const normalized = normalizeHost(host);
if (!isForgeCloudHost(forge, normalized)) {
return normalized;
}
return normalizeHost(getForgeDefinition(forge)?.cloudHosts?.[0] ?? normalized);
}
function normalizeRemotePath(path: string): string | null {
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
+19
View File
@@ -4328,6 +4328,22 @@ export const WorkspaceScriptPayloadSchema = z.object({
terminalId: z.string().nullable().optional().default(null),
});
/**
* Where a workspace's forge is on the web, resolved by the daemon so every
* client builds the same links.
*/
export const ForgeWebSchema = z.object({
/** Web host of the forge: the SSH config alias resolved, cloud SSH hosts mapped to their web host. */
host: z.string(),
/**
* Repository path that owns issue and change-request numbers when it is not
* the origin repository, such as a fork's parent. Absent: the origin repository.
*/
referenceRepository: z.string().optional(),
});
export type ForgeWeb = z.infer<typeof ForgeWebSchema>;
const WorkspaceGitRuntimePayloadSchema = z
.object({
currentBranch: z.string().nullable().optional(),
@@ -4464,6 +4480,8 @@ export const WorkspaceDescriptorPayloadSchema = z
// until 2027-01-17; remove the consumer fallback once the supported daemon
// floor is >= v0.2.0.
forge: z.string().optional(),
// Absent from daemons older than v0.16.0; clients then derive links from the origin remote.
forgeWeb: ForgeWebSchema.optional(),
project: ProjectPlacementPayloadSchema.optional(),
// COMPAT(directorySync): sequence of this latest directory projection.
syncSeq: z.number().int().positive().optional(),
@@ -5697,6 +5715,7 @@ const CheckoutStatusCommonSchema = z.object({
// upstream branch name is not necessarily currentBranch, so composing one is wrong.
// aheadOfOrigin/behindOfOrigin are measured against exactly this ref.
upstreamRef: z.string().nullable().optional(),
forgeWeb: ForgeWebSchema.optional(),
});
const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({
@@ -1251,6 +1251,15 @@ describe("workspace message schemas", () => {
expect(
WorkspaceDescriptorPayloadSchema.parse({ ...baseWorkspace, forge: "gitlab" }).forge,
).toBe("gitlab");
// forgeWeb is optional: an old daemon omits it, a new daemon's value round-trips.
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).forgeWeb).toBeUndefined();
expect(
WorkspaceDescriptorPayloadSchema.parse({
...baseWorkspace,
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
}).forgeWeb,
).toEqual({ host: "github.com", referenceRepository: "main-owner/repo" });
});
test("workspace.create.request rejects old flat backing shape and accepts new source envelope", () => {
@@ -13334,7 +13334,6 @@ test.each([
},
turnId: "hook-turn",
outcome,
timeline: [],
},
},
]);
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import { omitNullArgs } from "../tool-call-args.js";
interface BashToolInput {
command: string;
@@ -324,7 +325,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToo
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
const parsed = schema.safeParse(omitNullArgs(rawArgs));
if (parsed.success) {
return {
kind: toolName as SimpleToolKind,
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { ProviderImageOutput } from "../provider-image-output.js";
import { omitNullArgs } from "../tool-call-args.js";
interface BashToolInput {
command: string;
@@ -331,7 +332,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedTool
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
const parsed = schema.safeParse(omitNullArgs(rawArgs));
if (parsed.success) {
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
}
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { parseToolArgs as parseOmpToolArgs } from "./omp/tool-call-detail.js";
import { parseToolArgs as parsePiToolArgs } from "./pi/tool-call-mapper.js";
// Models send `null` for optional arguments (captured in #7011: read offset/limit), which the
// runtimes treat as omitted. Rejecting them turned every such call into an unknown card.
describe.each([
["Pi", parsePiToolArgs],
["OMP", parseOmpToolArgs],
])("%s tool args with null optional values", (_provider, parseToolArgs) => {
test.each([
["read", { path: "./preview.png", offset: null, limit: null }, { path: "./preview.png" }],
["bash", { command: "ls", timeout: null }, { command: "ls" }],
])("parses %s as its own tool kind", (toolName, rawArgs, args) => {
expect(parseToolArgs(toolName, rawArgs)).toEqual({ kind: toolName, toolName, args });
});
});
@@ -0,0 +1,9 @@
// Models routinely send `null` for optional tool arguments (e.g. read offset/limit); the
// runtimes treat that as omitted, so the mappers must too or the call degrades to an
// unknown card that carries the raw result.
export function omitNullArgs(rawArgs: unknown): unknown {
if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) {
return rawArgs;
}
return Object.fromEntries(Object.entries(rawArgs).filter(([, value]) => value !== null));
}
@@ -1,13 +1,52 @@
import { describe, expect, test } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { CheckoutPrStatusSchema, CheckoutStatusResponseSchema } from "@getpaseo/protocol/messages";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
import {
buildCheckoutPrStatusPayloadFromSnapshot,
buildCheckoutStatusPayloadFromSnapshot,
normalizeCheckoutPrStatusPayload,
} from "./status-projection.js";
describe("checkout status projection", () => {
test("publishes where the forge is on the web with the checkout status", () => {
const snapshot: WorkspaceGitRuntimeSnapshot = {
cwd: "/repo",
git: {
isGit: true,
repoRoot: "/repo",
mainRepoRoot: null,
currentBranch: "main",
remoteUrl: "git@github.com-work:fork-owner/repo.git",
isPaseoOwnedWorktree: false,
isDirty: false,
baseRef: "main",
aheadBehind: null,
upstreamRef: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: true,
diffStat: null,
},
forge: { featuresEnabled: true, authState: "authenticated", pullRequest: null, error: null },
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
};
const payload = buildCheckoutStatusPayloadFromSnapshot({
cwd: "/repo",
requestId: "req-1",
snapshot,
});
expect(payload.forgeWeb).toEqual({
host: "github.com",
referenceRepository: "main-owner/repo",
});
expect(
CheckoutStatusResponseSchema.parse({ type: "checkout_status_response", payload }).payload,
).toMatchObject({ forgeWeb: payload.forgeWeb });
});
test("includes repository identity fields on the PR status wire payload", () => {
const payload = normalizeCheckoutPrStatusPayload(
{
@@ -69,6 +69,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
hasRemote: snapshot.git.hasRemote,
remoteUrl: snapshot.git.remoteUrl,
...(snapshot.forgeWeb ? { forgeWeb: snapshot.forgeWeb } : {}),
isPaseoOwnedWorktree: true,
error: null,
requestId,
@@ -91,6 +92,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
hasRemote: snapshot.git.hasRemote,
remoteUrl: snapshot.git.remoteUrl,
...(snapshot.forgeWeb ? { forgeWeb: snapshot.forgeWeb } : {}),
isPaseoOwnedWorktree: false,
error: null,
requestId,
+113 -34
View File
@@ -12,7 +12,6 @@ import {
type CheckoutDiffCompare,
} from "../../utils/checkout-git.js";
import { lstat, readFile, readlink } from "node:fs/promises";
import type { BigIntStats } from "node:fs";
import { resolve } from "node:path";
import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages";
import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index";
@@ -27,6 +26,16 @@ export interface ListedFile {
identity: string;
source: { kind: "tracked" } | { kind: "untracked"; size: number };
}
interface WorktreeInput {
/** Comparison-side mode, or null when the file is added/untracked on this side. */
oldMode: string | null;
/** Comparison-side blob, or null when the file is added/untracked on this side. */
oldBlob: string | null;
/** A regular file whose bytes readGitInputs blob-hashes. */
hashed: boolean;
/** Content Git cannot read as a regular file, such as a symlink target or submodule state. */
literal: string | null;
}
interface ResolvedComparison {
baseSha: string;
headSha: string;
@@ -195,20 +204,47 @@ async function readComparisonList(
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
},
};
const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles(
compare,
args,
options,
);
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
const tracked = await readTrackedFiles(compare, args, options);
const files = tracked.files;
const worktreeInputs = tracked.worktreeInputs;
if (compare.head.kind === "worktree") {
const untracked = await listUntrackedFiles(options);
files.push(...untracked.files);
for (const [path, input] of untracked.worktreeInputs) worktreeInputs.set(path, input);
}
files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
for (const file of files) interpretationPaths.add(file.entry.path);
const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options);
for (const file of files)
for (const file of files) tracked.interpretationPaths.add(file.entry.path);
const hashedPaths = new Set(
[...worktreeInputs].filter(([, input]) => input.hashed).map(([path]) => path),
);
const literalContent = new Map(
[...worktreeInputs].flatMap(([path, input]) =>
input.literal === null ? [] : [[path, input.literal] as const],
),
);
const gitInputs = await readGitInputs({
paths: [...tracked.interpretationPaths].sort(),
hashedPaths,
options,
literalContent,
});
for (const file of files) {
if (compare.head.kind === "worktree") {
const input = worktreeInputs.get(file.entry.path);
if (!input) throw new Error(`Missing worktree identity for ${file.entry.path}`);
file.identity = JSON.stringify([
input.oldMode,
input.oldBlob,
gitInputs.identities.get(file.entry.path) ?? null,
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path) ?? null,
]);
continue;
}
file.identity += JSON.stringify([
gitInputs.identities.get(file.entry.path),
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path),
]);
}
const metrics = { highlightCalls: 0 };
const patches = new Patches(args, options, files);
const sources = new HighlightSources({
@@ -237,7 +273,15 @@ async function readComparisonList(
const result =
file.source.kind === "untracked"
? await runGitCommand(
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
[
"diff",
"--no-index",
"--no-textconv",
...GIT_PATCH_FORMAT_ARGS,
"--",
"/dev/null",
file.entry.path,
],
{
...options,
acceptExitCodes: [0, 1],
@@ -293,7 +337,11 @@ async function readTrackedFiles(
compare: Compare,
args: string[],
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; interpretationPaths: Set<string> }> {
): Promise<{
files: ListedFile[];
worktreeInputs: Map<string, WorktreeInput>;
interpretationPaths: Set<string>;
}> {
const [raw, stat] = await Promise.all([
runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options),
runGitCommand([...args, "--numstat", "-z"], options),
@@ -303,7 +351,7 @@ async function readTrackedFiles(
const stats = parseStats(stat.stdout);
const fields = raw.stdout.split("\0");
const files: ListedFile[] = [];
const worktreeFiles = new Set<string>();
const worktreeInputs = new Map<string, WorktreeInput>();
const interpretationPaths = new Set<string>();
for (let index = 0; index < fields.length - 1; index++) {
const header = fields[index].split(" ");
@@ -325,10 +373,25 @@ async function readTrackedFiles(
else if (code === "D") change = "deleted";
else if (oldPath !== null) change = "renamed";
let identity = header.join(" ");
if (compare.head.kind === "worktree" && code !== "D") {
const worktree = await readWorktreeIdentity(path, header, options);
identity += `:${worktree.identity}`;
if (worktree.regular && !counts.binary) worktreeFiles.add(path);
if (compare.head.kind === "worktree") {
const input: WorktreeInput = {
oldMode: code === "A" ? null : header[0],
oldBlob: code === "A" ? null : absentBlob(header[2]),
hashed: false,
literal: null,
};
if (code !== "D") {
const worktree = await readWorktreeContent({
path,
header,
text: !counts.binary,
options,
});
input.hashed = worktree.hashed;
input.literal = worktree.literal;
}
worktreeInputs.set(path, input);
identity = "";
}
files.push({
@@ -345,24 +408,32 @@ async function readTrackedFiles(
source: { kind: "tracked" },
});
}
return { files, worktreeFiles, interpretationPaths };
return { files, worktreeInputs, interpretationPaths };
}
async function readWorktreeIdentity(
path: string,
header: string[],
options: GitCommandOptions,
): Promise<{ identity: string; regular: boolean }> {
const value = await lstat(resolve(options.cwd, path), { bigint: true });
let identity = fileStatIdentity(value);
async function readWorktreeContent({
path,
header,
text,
options,
}: {
path: string;
header: string[];
text: boolean;
options: GitCommandOptions;
}): Promise<{ hashed: boolean; literal: string | null }> {
const absolutePath = resolve(options.cwd, path);
const value = await lstat(absolutePath, { bigint: true });
if (value.isSymbolicLink())
return { hashed: false, literal: `symlink:${await readlink(absolutePath)}` };
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
const submodule = await runGitCommand(
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
{ cwd: absolutePath, envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
);
identity += `:${submodule.stdout}`;
return { hashed: false, literal: `submodule:${submodule.stdout}` };
}
return { identity, regular: value.isFile() };
return { hashed: value.isFile() && text, literal: null };
}
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
@@ -385,9 +456,12 @@ async function readUntrackedText(
return readFile(path);
}
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
async function listUntrackedFiles(
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeInputs: Map<string, WorktreeInput> }> {
const cwd = options.cwd;
const files: ListedFile[] = [];
const worktreeInputs = new Map<string, WorktreeInput>();
const untracked = await runGitCommand(
["ls-files", "--others", "--exclude-standard", "-z"],
options,
@@ -408,6 +482,12 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
binary || content.length === 0
? 0
: content.toString("utf8").split("\n").length - Number(content.at(-1) === 10);
worktreeInputs.set(path, {
oldMode: null,
oldBlob: null,
hashed: !symlink && !binary && !omitted,
literal: symlink ? `symlink:${content.toString("utf8")}` : null,
});
files.push({
entry: {
path,
@@ -418,14 +498,13 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
deletions: 0,
body: !binary,
},
identity: fileStatIdentity(value),
identity: "",
source: { kind: "untracked", size: Number(value.size) },
});
}
return files;
return { files, worktreeInputs };
}
function fileStatIdentity(value: BigIntStats): string {
// Timestamp-preserving copies still change file status or replace the inode.
return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
function absentBlob(value: string): string | null {
return /^0+$/.test(value) ? null : value;
}
+15 -7
View File
@@ -9,12 +9,20 @@ interface Interpretation {
// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git
// for effective attributes/config and filtered blob identities without writing objects.
export async function readGitInputs(
paths: string[],
worktreeFiles: Set<string>,
options: GitCommandOptions,
) {
const regular = paths.filter((path) => worktreeFiles.has(path));
interface ReadGitInputsOptions {
paths: string[];
hashedPaths: ReadonlySet<string>;
options: GitCommandOptions;
literalContent?: ReadonlyMap<string, string>;
}
export async function readGitInputs({
paths,
hashedPaths,
options,
literalContent = new Map(),
}: ReadGitInputsOptions) {
const regular = paths.filter((path) => hashedPaths.has(path));
const [interpretation, blobs] = await Promise.all([
readInterpretation(paths, options),
regular.length
@@ -34,7 +42,7 @@ export async function readGitInputs(
fingerprint([
interpretation.config,
interpretation.attributes.get(path) ?? [],
blobByPath.get(path) ?? null,
blobByPath.get(path) ?? literalContent.get(path) ?? null,
]),
]),
);
@@ -530,6 +530,222 @@ test("submodule commits invalidate cached worktree gitlink bodies", async () =>
}
});
test("stat-only changes reuse cached bodies and send no body frames", async () => {
const { utimes } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-stat-only-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const path = join(cwd, "file.txt");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
await writeFile(path, "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(path, "changed\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "file.txt" }));
frames.length = 0;
for (let index = 0; index < 3; index++) {
const ended = frames.filter((frame) => frame.opcode === DiffOpcode.End).length;
const timestamp = new Date(Date.now() + 1000 * (index + 1));
await utimes(path, timestamp, timestamp);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, {
timeout: 5000,
})
.toBeGreaterThanOrEqual(ended + 1);
await new Promise((resolve) => setTimeout(resolve, 300));
}
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
const beginnings = frames.filter((frame) => frame.opcode === DiffOpcode.Begin);
expect(beginnings.length).toBeGreaterThanOrEqual(3);
for (const begin of beginnings) {
expect(begin.entries.find((entry) => entry.path === "file.txt")?.body).toBe(false);
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test("staging unchanged worktree content reuses its cached body", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-staging-only-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "staged.ts" }));
frames.length = 0;
git("add", "-A");
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "staged.ts", change: "added", body: false }],
});
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test.skipIf(process.platform === "win32")(
"untracked bodies use the same patch conversion as tracked files",
async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-textconv-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, ".gitattributes"), "*.dat diff=upper\n");
git("config", "diff.upper.textconv", "sed s/hello/HELLO/");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "x.dat"), "hello\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
const initial = frames
.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : []))
.find((file) => file.path === "x.dat");
expect(initial?.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "hello",
});
frames.length = 0;
git("add", "x.dat");
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"comparison-side file type changes refresh cached bodies",
async () => {
const { symlink } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-base-mode-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const path = join(cwd, "x");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(path, "target");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "regular");
git("branch", "compare");
await rm(path);
await symlink("target", path);
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "symlink");
const moved = git("rev-parse", "HEAD");
await rm(path);
await writeFile(path, "target\n");
const compare = {
cwd,
base: { kind: "rev" as const, rev: "refs/heads/compare" },
head: { kind: "worktree" as const },
};
const subscription = await streams.subscribe(compare, async (frame) => {
frames.push(frame);
});
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "x" }));
frames.length = 0;
git("update-ref", "refs/heads/compare", moved);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
const bodies = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(bodies).toContainEqual(expect.objectContaining({ path: "x" }));
const { listComparison } = await import("./git.js");
const fresh = await listComparison(compare);
try {
const record = await fresh.read(fresh.files[0]);
if (record.kind !== "ready") throw new Error("Expected readable type change");
expect(bodies).toContainEqual(record.body);
} finally {
await fresh.close();
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test.each([true, false])(
"tracked=%s timestamp-preserving replacements refresh cached bodies",
async (tracked) => {
+79
View File
@@ -0,0 +1,79 @@
import { forgeWebHost } from "@getpaseo/protocol/git-remote";
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import type { ForgeResolution } from "../services/forge-resolver.js";
// A failed reference-repository lookup (signed out, network down, rate limit) is retried after this long.
const REFERENCE_REPOSITORY_RETRY_MS = 60_000;
/**
* Web location of a workspace's forge, remembered per origin remote. The host
* comes from the forge resolution; the reference repository needs the forge, so
* it is looked up once per remote, and again only after a failed lookup, once
* the retry delay has passed.
*/
export interface ForgeWebEntry {
remoteUrl: string;
remoteKey: string;
forgeWeb: ForgeWeb;
retryAtMs: number | null;
}
/**
* The web location available without the forge lookup: a settled answer for this
* remote, or the host alone while its reference repository is due. Publish this
* with the snapshot immediately; a slow or signed-out forge CLI must not hold
* back the workspace's git state or pull request status.
*/
export function forgeWebForResolution(input: {
remoteUrl: string;
resolution: ForgeResolution;
previous: ForgeWebEntry | null;
nowMs: number;
}): ForgeWebEntry {
const remoteKey = `${input.resolution.forge}\0${input.remoteUrl}`;
const previous = input.previous?.remoteKey === remoteKey ? input.previous : null;
// A settled lookup stands for as long as the remote does, and a failed one keeps its retry
// time, however often forced refreshes (every push, fetch and branch switch) ask.
if (previous) {
return previous;
}
return {
remoteUrl: input.remoteUrl,
remoteKey,
forgeWeb: { host: forgeWebHost(input.resolution.forge, input.resolution.host) },
// A lookup that has never run is due now; one that failed keeps its own retry time.
retryAtMs: input.resolution.service.getReferenceRepository ? input.nowMs : null,
};
}
/**
* Enriches an entry with the repository that owns issue and change-request
* numbers. The links still work without it, so a failed lookup is retried later
* rather than surfaced.
*/
export async function resolveForgeWebReference(input: {
cwd: string;
resolution: ForgeResolution;
entry: ForgeWebEntry;
nowMs: number;
}): Promise<ForgeWebEntry> {
const host = input.entry.forgeWeb.host;
const lookup = input.resolution.service.getReferenceRepository;
if (!lookup) {
return input.entry.retryAtMs === null ? input.entry : { ...input.entry, retryAtMs: null };
}
try {
const referenceRepository = await lookup.call(input.resolution.service, { cwd: input.cwd });
return {
...input.entry,
forgeWeb: referenceRepository ? { host, referenceRepository } : { host },
retryAtMs: null,
};
} catch {
return {
...input.entry,
forgeWeb: { host },
retryAtMs: input.nowMs + REFERENCE_REPOSITORY_RETRY_MS,
};
}
}
@@ -163,8 +163,14 @@ export default function contribute(server) {
server.addHook("agent.turn.before", async (event) => {
console.log(JSON.stringify({ hook: "agent.turn.before", event }));
});
server.addHook("agent.turn.after", async (event) => {
console.log(JSON.stringify({ hook: "agent.turn.after", event }));
server.addHook("agent.turn.after", async (event, context) => {
const page = await context.paseo.agents
.ref(event.agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries
.filter((entry) => entry.turnId === event.turnId)
.map(({ item }) => item.type);
console.log(JSON.stringify({ hook: "agent.turn.after", event, turn }));
});
server.addHook("agent.permission.before", async (event, context) => {
console.log(JSON.stringify({ hook: "agent.permission.before", event }));
@@ -220,6 +226,7 @@ export default function contribute(server) {
{
hook: "agent.turn.after",
event: { outcome: { kind: "completed" }, timeline: [] },
turn: ["user_message", "tool_call"],
},
{ hook: "agent.turn.before", event: { agent: { id: agent.id } } },
]);
@@ -157,8 +157,7 @@ test.skipIf(process.platform !== "linux")(
"This is a hook integration test. Reply exactly: out of credits. If the next user message is Try again., reply exactly RETRY_OK instead. Do not use tools.",
);
await waitForTurns(client, agent.id, 2);
const retryTurns = await endedTurns(client, agent.id);
expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK");
expect(await latestReplyText(client, agent.id)).toBe("RETRY_OK");
proof.followup = "RETRY_OK";
await client.sendMessage(
@@ -207,8 +206,7 @@ test.skipIf(process.platform !== "linux")(
const envPermission = await waitForPermission(client, agent.id, envCommand);
await client.respondToPermission(agent.id, envPermission, { behavior: "allow" });
await waitForTurns(client, agent.id, 5);
const envTurns = await endedTurns(client, agent.id);
expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened");
expect(await latestReplyText(client, agent.id)).toBe("created|opened");
proof.environmentOutput = "created|opened";
await client.refreshAgent(agent.id);
@@ -332,6 +330,16 @@ test.skipIf(process.platform !== "linux")(
600_000,
);
// Turn hooks carry no history; read the reply the way plugins do.
async function latestReplyText(client: DaemonClient, agentId: string): Promise<string> {
const page = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 1,
wholeTurns: true,
});
return assistantText(page.entries.map((entry) => entry.item));
}
function assistantText(timeline: readonly AgentTimelineItem[]): string {
let text = "";
for (const item of timeline) {
@@ -195,21 +195,18 @@ export function publishAgentStream(
lifecycle.emit("agent.turn.after", {
agent,
turnId: event.turnId ?? null,
timeline: [],
outcome: { kind: "completed" },
});
} else if (event.type === "turn_failed") {
lifecycle.emit("agent.turn.after", {
agent,
turnId: event.turnId ?? null,
timeline: [],
outcome: { kind: "failed", error: { message: event.error, code: event.code } },
});
} else if (event.type === "turn_canceled") {
lifecycle.emit("agent.turn.after", {
agent,
turnId: event.turnId ?? null,
timeline: [],
outcome: { kind: "canceled", reason: event.reason },
});
} else if (event.type === "permission_requested") {
@@ -564,7 +564,7 @@ export class PluginRuntime {
type: "hook",
requestId: randomUUID(),
name,
input: event,
input: wireEvent(name, event),
})
.then(() => undefined)
.catch((error) => {
@@ -1482,3 +1482,11 @@ function readConnectionId(value: unknown): string | null {
const connectionId = Reflect.get(value, "connectionId");
return typeof connectionId === "string" ? connectionId : null;
}
// COMPAT(pluginTurnTimeline): added in v0.16.0-beta.3; plugins built before the field left the SDK type read `timeline`; remove after 2027-05-31
function wireEvent<Name extends keyof PluginHookEvents>(
name: Name,
event: PluginHookEvents[Name],
): PluginHookEvents[Name] {
return name === "agent.turn.after" ? { ...event, timeline: [] } : event;
}
+1
View File
@@ -6184,6 +6184,7 @@ export class Session {
// resolves to "github") so the sidebar/hover-card brand mark matches the
// status projection without a second resolve.
forge: snapshot.forge.forge,
forgeWeb: snapshot.forgeWeb,
};
}
@@ -6619,6 +6619,45 @@ test("lists Git runtime for a checkout explicitly owned by a non-Git project", a
});
});
test("lists where the workspace's forge is on the web so every client builds the same links", async () => {
const session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({
projectId: "proj-forge-web",
rootPath: REPO_CWD,
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-forge-web",
projectId: project.projectId,
cwd: REPO_CWD,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.listAgentPayloads = async () => [];
session.projectRegistry.list = async () => [project];
session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [
workspace,
];
session.workspaceRuntimeStore.get = () => ({
...createWorkspaceRuntimeSnapshot(REPO_CWD),
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
});
const [descriptor] = (
await session.buildWorkspaceDescriptorMap({ includeGitData: true })
).values();
expect(descriptor?.forgeWeb).toEqual({
host: "github.com",
referenceRepository: "main-owner/repo",
});
});
test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => {
const setupSession = () => {
const session = createSessionForWorkspaceTests();
@@ -17,6 +17,7 @@ import {
type CheckoutSnapshotFacts,
type CheckoutStatusGit,
} from "../utils/checkout-git.js";
import { createGitHubService, GitHubCommandError } from "../services/github-service.js";
import { createFileObserver } from "./file-observer/index.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js";
@@ -1019,3 +1020,556 @@ test("a branch switch made before the Git metadata watcher starts still reaches
)
.toBe("feature");
}, 30_000);
function createCheckoutWithOrigin(origin: string): string {
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-forge-web-")));
const repo = path.join(root, "repo");
mkdirSync(repo);
const git = (...args: string[]) =>
execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { cwd: repo, stdio: "pipe" });
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
writeFileSync(path.join(repo, "README.md"), "base\n");
git("add", ".");
git("commit", "-m", "base");
git("remote", "add", "origin", origin);
cleanup.push(async () =>
rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }),
);
return repo;
}
// The GitHub adapter runs for real; only the `gh` binary it spawns is scripted.
function createGitHubWithRepositoryView(repositoryView: object) {
return createGitHubService({
resolveGhPath: async () => "gh",
resolveRepoHost: async () => null,
runner: async (args) => {
if (args[0] === "repo" && args[1] === "view") {
return { stdout: JSON.stringify(repositoryView), stderr: "" };
}
return { stdout: "", stderr: "" };
},
});
}
async function snapshotOf(
repo: string,
deps: ConstructorParameters<typeof WorkspaceGitServiceImpl>[0]["deps"],
) {
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(path.dirname(repo), "home"),
deps,
});
cleanup.push(() => service.dispose());
return {
service,
snapshot: await service.getSnapshot(repo, { force: true, reason: "forge-web-test" }),
};
}
async function expectForgeWebEventually(
service: WorkspaceGitServiceImpl,
repo: string,
expected: { host: string; referenceRepository?: string },
) {
await expect
.poll(async () => (await service.getSnapshot(repo, { includeForge: false })).forgeWeb, {
timeout: 10_000,
})
.toEqual(expected);
}
test("an SSH config alias remote publishes the web host the alias resolves to", async () => {
const repo = createCheckoutWithOrigin("git@github.com-work:acme/repo.git");
const { snapshot } = await snapshotOf(repo, {
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
forgeOverrides: { github: createGitHubWithRepositoryView({}) },
});
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
});
test("a cloud SSH host publishes the forge's web host", async () => {
const repo = createCheckoutWithOrigin("ssh://git@ssh.github.com/acme/repo.git");
const { snapshot } = await snapshotOf(repo, {
forgeOverrides: { github: createGitHubWithRepositoryView({}) },
});
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
});
test("a fork checkout publishes its parent as the repository that owns issue and pull request numbers", async () => {
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
const { service, snapshot } = await snapshotOf(repo, {
forgeOverrides: {
github: createGitHubWithRepositoryView({
owner: { login: "fork-owner" },
name: "repo",
parent: { owner: { login: "main-owner" }, name: "repo" },
}),
},
});
// The host is available with the first snapshot; the parent follows in a background update.
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
await expectForgeWebEventually(service, repo, {
host: "github.com",
referenceRepository: "main-owner/repo",
});
});
test("a plain remote publishes no separate reference repository", async () => {
const repo = createCheckoutWithOrigin("https://github.com/acme/repo.git");
const { service, snapshot } = await snapshotOf(repo, {
forgeOverrides: {
github: createGitHubWithRepositoryView({ owner: { login: "acme" }, name: "repo" }),
},
});
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
await expectForgeWebEventually(service, repo, { host: "github.com" });
});
test("a remote on no forge publishes no web location", async () => {
const repo = createCheckoutWithOrigin("git@bitbucket.org:acme/repo.git");
const { snapshot } = await snapshotOf(repo, { resolveSshHostname: async () => null });
expect(snapshot.forgeWeb).toBeUndefined();
});
test("changing the origin remote publishes the new remote's web location, not the previous one", async () => {
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
forgeOverrides: {
github: createGitHubService({
resolveGhPath: async () => "gh",
resolveRepoHost: async () => null,
runner: async (args) => {
if (args[0] === "repo" && args[1] === "view") {
const isFork = args[2] === "fork-owner/repo";
return {
stdout: JSON.stringify(
isFork
? {
owner: { login: "fork-owner" },
name: "repo",
parent: { owner: { login: "main-owner" }, name: "repo" },
}
: { owner: { login: "acme" }, name: "other" },
),
stderr: "",
};
}
return { stdout: "", stderr: "" };
},
}),
},
},
});
cleanup.push(() => service.dispose());
const before = await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
await expectForgeWebEventually(service, repo, {
host: "github.com",
referenceRepository: "main-owner/repo",
});
execFileSync("git", ["remote", "set-url", "origin", "git@github.com-work:acme/other.git"], {
cwd: repo,
});
// The refresh a `.git/config` change schedules does not ask for forge data.
const after = await service.getSnapshot(repo, {
force: true,
includeForge: false,
reason: "forge-web-test",
});
expect(after.git.remoteUrl).toBe("git@github.com-work:acme/other.git");
expect(after.forgeWeb).toEqual({ host: "github.com" });
expect(before.forgeWeb).toEqual({ host: "github.com" });
}, 30_000);
test("a failed parent lookup is retried by the pull request status poll, keeping the host an SSH alias resolves to", async () => {
const repo = createCheckoutWithOrigin("git@github.com-work:fork-owner/repo.git");
let nowMs = Date.parse("2026-01-01T00:00:00Z");
let ghAnswers = false;
let lookups = 0;
let pollStatus: ((status: null) => void) | undefined;
const github = createGitHubService({
resolveGhPath: async () => "gh",
resolveRepoHost: async () => null,
runner: async (args) => {
// The reference lookup names the origin repository; the PR status load asks for the cwd repo.
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
lookups += 1;
if (!ghAnswers) {
throw new GitHubCommandError({
args,
cwd: repo,
exitCode: 1,
stderr: "error connecting to api.github.com",
});
}
return {
stdout: JSON.stringify({
owner: { login: "fork-owner" },
name: "repo",
parent: { owner: { login: "main-owner" }, name: "repo" },
}),
stderr: "",
};
}
return { stdout: "", stderr: "" };
},
});
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
now: () => new Date(nowMs),
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
forgeOverrides: {
// The adapter is real; only the poll is driven by the test instead of a timer.
github: Object.assign(Object.create(github), {
retainCurrentPullRequestStatusPoll: (options: { onStatus?: (status: null) => void }) => {
pollStatus = options.onStatus;
return { unsubscribe: () => {} };
},
}),
},
},
});
const subscription = service.registerWorkspace({ cwd: repo }, () => {});
cleanup.push(async () => {
subscription.unsubscribe();
await service.dispose();
});
const failed = await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
expect(failed.forgeWeb).toEqual({ host: "github.com" });
await expect.poll(() => pollStatus, { timeout: 10_000 }).toBeDefined();
await expect.poll(() => lookups, { timeout: 10_000 }).toBe(1);
// Let the failed lookup record its retry time before advancing the clock.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
ghAnswers = true;
nowMs += 61_000;
pollStatus?.(null);
await expectForgeWebEventually(service, repo, {
host: "github.com",
referenceRepository: "main-owner/repo",
});
const forced = await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
expect(forced.forgeWeb).toEqual({ host: "github.com", referenceRepository: "main-owner/repo" });
}, 30_000);
test("a successful parent lookup is reused across forced refreshes of the same remote", async () => {
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
let repositoryViews = 0;
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
forgeOverrides: {
github: createGitHubService({
resolveGhPath: async () => "gh",
resolveRepoHost: async () => null,
runner: async (args) => {
if (args[0] === "repo" && args[1] === "view") {
// The parent lookup is the one that names the origin repository.
if (args[2] === "fork-owner/repo") repositoryViews += 1;
return {
stdout: JSON.stringify({
owner: { login: "fork-owner" },
name: "repo",
parent: { owner: { login: "main-owner" }, name: "repo" },
}),
stderr: "",
};
}
return { stdout: "", stderr: "" };
},
}),
},
},
});
cleanup.push(() => service.dispose());
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
await expectForgeWebEventually(service, repo, {
host: "github.com",
referenceRepository: "main-owner/repo",
});
expect(repositoryViews).toBe(1);
});
test("a fork checked out at a detached HEAD still publishes its parent", async () => {
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
execFileSync("git", ["checkout", "--detach"], { cwd: repo, stdio: "pipe" });
const { service, snapshot } = await snapshotOf(repo, {
forgeOverrides: {
github: createGitHubWithRepositoryView({
owner: { login: "fork-owner" },
name: "repo",
parent: { owner: { login: "main-owner" }, name: "repo" },
}),
},
});
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
await expectForgeWebEventually(service, repo, {
host: "github.com",
referenceRepository: "main-owner/repo",
});
});
test("a parent lookup that finishes during a refresh keeps its answer", async () => {
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
const lookupStarted = Promise.withResolvers<void>();
const releaseLookup = Promise.withResolvers<void>();
const statusLoadStarted = Promise.withResolvers<void>();
const releaseStatusLoad = Promise.withResolvers<void>();
let holdNextStatusLoad = false;
let repositoryViews = 0;
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
forgeOverrides: {
github: createGitHubService({
resolveGhPath: async () => "gh",
resolveRepoHost: async () => null,
runner: async (args) => {
if (args[0] === "auth" && args[1] === "status" && holdNextStatusLoad) {
holdNextStatusLoad = false;
statusLoadStarted.resolve();
await releaseStatusLoad.promise;
}
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
repositoryViews += 1;
if (repositoryViews === 1) {
lookupStarted.resolve();
await releaseLookup.promise;
}
return {
stdout: JSON.stringify({
owner: { login: "fork-owner" },
name: "repo",
parent: { owner: { login: "main-owner" }, name: "repo" },
}),
stderr: "",
};
}
return { stdout: "", stderr: "" };
},
}),
},
},
});
cleanup.push(() => service.dispose());
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
await lookupStarted.promise;
holdNextStatusLoad = true;
const second = service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
await statusLoadStarted.promise;
// The first refresh's lookup finishes while the second refresh is still loading the PR status.
releaseLookup.resolve();
await expectForgeWebEventually(service, repo, {
host: "github.com",
referenceRepository: "main-owner/repo",
});
releaseStatusLoad.resolve();
const snapshot = await second;
expect(snapshot.forgeWeb).toEqual({
host: "github.com",
referenceRepository: "main-owner/repo",
});
expect(repositoryViews).toBe(1);
}, 30_000);
test("disposal waits for a background parent lookup", async () => {
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
const lookupStarted = Promise.withResolvers<void>();
const releaseLookup = Promise.withResolvers<void>();
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
forgeOverrides: {
github: createGitHubService({
resolveGhPath: async () => "gh",
resolveRepoHost: async () => null,
runner: async (args) => {
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
lookupStarted.resolve();
await releaseLookup.promise;
return {
stdout: JSON.stringify({
owner: { login: "fork-owner" },
name: "repo",
parent: { owner: { login: "main-owner" }, name: "repo" },
}),
stderr: "",
};
}
return { stdout: "", stderr: "" };
},
}),
},
},
});
try {
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
await lookupStarted.promise;
let disposed = false;
const disposal = service.dispose().then(() => {
disposed = true;
return undefined;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(disposed).toBe(false);
releaseLookup.resolve();
await disposal;
expect(disposed).toBe(true);
} finally {
releaseLookup.resolve();
await service.dispose();
}
}, 30_000);
test("the first snapshot publishes git state and pull request status without waiting for the parent lookup", async () => {
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
writeFileSync(path.join(repo, "changed.txt"), "dirty\n");
const lookupStarted = Promise.withResolvers<void>();
const releaseLookup = Promise.withResolvers<void>();
const pullRequestStatusLoaded = Promise.withResolvers<void>();
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
forgeOverrides: {
github: createGitHubService({
resolveGhPath: async () => "gh",
resolveRepoHost: async () => null,
runner: async (args) => {
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
lookupStarted.resolve();
await releaseLookup.promise;
return {
stdout: JSON.stringify({
owner: { login: "fork-owner" },
name: "repo",
parent: { owner: { login: "main-owner" }, name: "repo" },
}),
stderr: "",
};
}
if (args[0] === "pr" && args[1] === "view") {
pullRequestStatusLoaded.resolve();
return {
stdout: JSON.stringify({
number: 42,
url: "https://github.com/fork-owner/repo/pull/42",
title: "Already known pull request",
state: "OPEN",
baseRefName: "main",
headRefName: "main",
mergedAt: null,
}),
stderr: "",
};
}
return { stdout: "", stderr: "" };
},
}),
},
},
});
cleanup.push(() => service.dispose());
let snapshot: Awaited<ReturnType<typeof service.getSnapshot>> | null = null;
const pending = service
.getSnapshot(repo, { force: true, reason: "forge-web-test" })
.then((value) => {
snapshot = value;
return value;
});
await pullRequestStatusLoaded.promise;
// The forge lookup is still held here; the snapshot must not wait for it.
await expect.poll(() => snapshot !== null, { timeout: 5_000 }).toBe(true);
expect(snapshot).not.toBeNull();
expect(snapshot!.git.currentBranch).toBe("main");
expect(snapshot!.git.isDirty).toBe(true);
expect(snapshot!.forge.pullRequest?.number).toBe(42);
expect(snapshot!.forgeWeb).toEqual({ host: "github.com" });
await lookupStarted.promise;
releaseLookup.resolve();
await pending;
await expectForgeWebEventually(service, repo, {
host: "github.com",
referenceRepository: "main-owner/repo",
});
}, 30_000);
test("a failed parent lookup is not repeated by forced refreshes before the retry delay", async () => {
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
let lookups = 0;
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
forgeOverrides: {
github: createGitHubService({
resolveGhPath: async () => "gh",
resolveRepoHost: async () => null,
runner: async (args) => {
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
lookups += 1;
throw new GitHubCommandError({
args,
cwd: repo,
exitCode: 1,
stderr: "error connecting to api.github.com",
});
}
return { stdout: "", stderr: "" };
},
}),
},
},
});
cleanup.push(() => service.dispose());
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
await expect.poll(() => lookups, { timeout: 10_000 }).toBe(1);
// Let the failed lookup record its retry time.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
expect(lookups).toBe(1);
}, 30_000);
@@ -264,6 +264,8 @@ function createSnapshot(cwd: string, overrides?: SnapshotOverrides): WorkspaceGi
pullRequest: resolveSnapshotPullRequest(base, overrides),
error: resolveSnapshotError(base, overrides),
},
// Every remote these fixtures use is on github.com.
...(forge ? { forgeWeb: { host: "github.com" } } : {}),
};
}
@@ -704,8 +706,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
await refresh;
expect(await service.getSnapshot(REPO_CWD)).toEqual(
createSnapshot(REPO_CWD, {
// A branch change resets the PR status but not where the same remote lives on the web.
expect(await service.getSnapshot(REPO_CWD)).toEqual({
...createSnapshot(REPO_CWD, {
git: {
currentBranch: "feature",
diffStat: { additions: 4, deletions: 2 },
@@ -716,7 +719,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
error: null,
},
}),
);
forgeWeb: { host: "github.com" },
});
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
@@ -93,6 +93,8 @@ function createSnapshot(
pullRequest: resolveSnapshotPullRequest(base, overrides),
error: resolveSnapshotError(base, overrides),
},
// Every remote these fixtures use is on github.com.
...(forgeName ? { forgeWeb: { host: "github.com" } } : {}),
};
}
@@ -5,7 +5,7 @@ import { LRUCache } from "lru-cache";
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
import pLimit from "p-limit";
import type pino from "pino";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import type { ForgeWeb, ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { CheckoutContext } from "../utils/checkout-git.js";
import {
@@ -71,6 +71,12 @@ import {
type SubscribeToFileChanges,
createFileObserver,
} from "./file-observer/index.js";
import {
forgeWebForResolution,
resolveForgeWebReference,
type ForgeWebEntry,
} from "./forge-web.js";
import type { SshHostnameResolver } from "../utils/ssh-hostname.js";
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
@@ -200,6 +206,8 @@ export interface WorkspaceGitRuntimeSnapshot {
} | null;
error: { message: string } | null;
};
/** Absent when the remote resolves to no forge. */
forgeWeb?: ForgeWeb;
}
export interface WorkspaceGitService {
@@ -406,6 +414,8 @@ interface WorkspaceGitServiceDependencies {
* fakes. Any forge not listed here is built (and cached once) by the registry.
*/
forgeOverrides?: Record<string, ForgeService>;
/** Resolves an SSH config alias to its HostName; defaults to `ssh -G`. */
resolveSshHostname?: SshHostnameResolver;
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
hasOriginRemote: (cwd: string) => Promise<boolean>;
runGitFetch: (
@@ -459,6 +469,11 @@ interface WorkspaceGitTarget {
latestGitLoadedAtMs: number | null;
latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
latestForgeLoadedAtMs: number | null;
latestForgeWeb: ForgeWebEntry | null;
/** The entry whose reference repository is being looked up, so one remote costs one call. */
forgeWebLookupEntry: ForgeWebEntry | null;
/** The remote the forge snapshot was last refreshed for; null until the first forge refresh. */
forgeRefreshedForRemote: { remoteUrl: string | null } | null;
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
latestSnapshotLoadedAtMs: number | null;
latestFacts: CheckoutSnapshotFacts | null;
@@ -647,6 +662,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private disposed = false;
private disposePromise: Promise<void> | null = null;
private readonly pendingRefreshes = new Set<Promise<WorkspaceGitRuntimeSnapshot>>();
private readonly pendingForgeWebLookups = new Set<Promise<void>>();
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>();
@@ -691,6 +707,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
);
this.forgeResolver = createForgeResolver({
createService: (forge) => this.deps.forgeOverrides?.[forge] ?? createForgeService(forge),
resolveSshHostname: this.deps.resolveSshHostname,
});
}
@@ -1140,11 +1157,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.workingTreeWatchAliases.clear();
this.snapshotUpdatedListeners.clear();
// Unsubscribing can remove a target while its Git command still owns the cwd.
// Stop admission first, then drain those refreshes before releasing the service.
this.disposePromise = Promise.all([
this.fileObserver.close(),
Promise.allSettled(this.pendingRefreshes),
]).then(() => undefined);
// Stop admission first, then drain those refreshes and the parent lookups they start.
this.disposePromise = (async () => {
await Promise.all([this.fileObserver.close(), Promise.allSettled(this.pendingRefreshes)]);
await Promise.allSettled(this.pendingForgeWebLookups);
})();
return this.disposePromise;
}
@@ -1301,6 +1318,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
latestGit: null,
latestGitLoadedAtMs: null,
latestForge: null,
latestForgeWeb: null,
forgeWebLookupEntry: null,
forgeRefreshedForRemote: null,
latestForgeLoadedAtMs: null,
latestSnapshot: null,
latestSnapshotLoadedAtMs: null,
@@ -3568,6 +3588,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
if (request.includeForge) {
await this.refreshForgeSnapshot(target, request, facts, runRefreshGitCommand);
} else if (this.hasRemoteChangedSinceForgeRefresh(target)) {
// `git remote set-url` reaches here as a git-only refresh. The forge, its host and the
// fork's parent all belong to the old remote, so resolve the new one now.
await this.refreshForgeSnapshot(
target,
{ ...request, includeForge: true, force: true },
facts,
runRefreshGitCommand,
);
}
const snapshot = this.combineSnapshot(target);
@@ -3669,6 +3698,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const loadedAtMs = this.deps.now().getTime();
target.latestGitLoadedAtMs = loadedAtMs;
target.latestForge = buildForgeUnavailableSnapshot();
target.latestForgeWeb = null;
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
return facts;
}
@@ -3713,6 +3743,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return facts;
}
private hasRemoteChangedSinceForgeRefresh(target: WorkspaceGitTarget): boolean {
const refreshedFor = target.forgeRefreshedForRemote;
return (
refreshedFor !== null &&
target.latestGit?.isGit === true &&
refreshedFor.remoteUrl !== target.latestGit.remoteUrl
);
}
private async refreshForgeSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
@@ -3720,13 +3759,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
runRefreshGitCommand: RunGitCommand,
): Promise<void> {
const remoteUrl = target.latestGit?.remoteUrl ?? null;
target.forgeRefreshedForRemote = { remoteUrl };
const resolution = await this.forgeResolver.resolveFromRemoteUrlAsync(remoteUrl);
// Every forge gates on the resolver alone: a cloud host matches synchronously
// and a self-hosted/Enterprise host is recognized by the adapter probe (which
// this async resolution populates), so GitHub Enterprise is no longer gated
// out by a cloud-only identity check.
if (!resolution) {
if (!resolution || !remoteUrl) {
target.latestForge = buildUnresolvedRemoteForgeSnapshot(remoteUrl);
target.latestForgeWeb = null;
target.latestForgeLoadedAtMs = this.deps.now().getTime();
return;
}
@@ -3749,7 +3790,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
// Carry the resolved forge (probe-aware) so the wire projection labels
// self-managed GitLab hosts correctly instead of falling back to "github".
target.latestForge = { ...forgeSnapshot, forge: resolution.forge };
// Publish the host with the snapshot; the parent lookup finishes in the background. Read the
// previous entry after the wait: a lookup for the old entry may have completed meanwhile.
target.latestForgeWeb = forgeWebForResolution({
remoteUrl,
resolution,
previous: target.latestForgeWeb,
nowMs: this.deps.now().getTime(),
});
target.latestForgeLoadedAtMs = this.deps.now().getTime();
this.queueForgeWebReferenceLookup(target);
}
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
@@ -3761,6 +3811,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: target.cwd,
git: target.latestGit,
forge: target.latestForge ?? buildForgeUnavailableSnapshot(),
// Only the remote it was resolved for: after `git remote set-url` the old host and
// parent are wrong until the next forge refresh resolves the new remote.
...(target.latestForgeWeb?.remoteUrl === target.latestGit.remoteUrl
? { forgeWeb: target.latestForgeWeb.forgeWeb }
: {}),
};
}
@@ -3800,6 +3855,68 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.rememberSnapshot(target, this.combineSnapshot(target), {
forceEmit: false,
});
this.queueForgeWebReferenceLookup(target);
}
private queueForgeWebReferenceLookup(target: WorkspaceGitTarget): void {
const lookup = this.resolveForgeWebReferenceIfDue(target)
.catch((error) => {
if (!this.disposed) {
this.logger.warn(
{ err: error, cwd: target.cwd },
"Failed to resolve the forge reference repository",
);
}
})
.finally(() => {
this.pendingForgeWebLookups.delete(lookup);
});
this.pendingForgeWebLookups.add(lookup);
}
// The status poll is the only periodic work an idle workspace has, so it also
// retries a parent lookup that failed or ran before the forge CLI was signed in.
private async resolveForgeWebReferenceIfDue(target: WorkspaceGitTarget): Promise<void> {
const entry = target.latestForgeWeb;
const remoteUrl = target.latestGit?.remoteUrl ?? null;
const nowMs = this.deps.now().getTime();
if (
!entry ||
!remoteUrl ||
entry.remoteUrl !== remoteUrl ||
entry.retryAtMs === null ||
nowMs < entry.retryAtMs ||
target.forgeWebLookupEntry === entry
) {
return;
}
target.forgeWebLookupEntry = entry;
try {
// The async resolver, like the forge refresh: only it reports the `ssh -G` host for an alias.
const resolution = await this.forgeResolver.resolveFromRemoteUrlAsync(remoteUrl);
if (!resolution) {
return;
}
const next = await resolveForgeWebReference({
cwd: target.cwd,
resolution,
entry,
nowMs,
});
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
// The origin can change while the lookup runs; the old remote's answer is not ours to publish.
if (target.latestForgeWeb !== entry || target.latestGit?.remoteUrl !== entry.remoteUrl) {
return;
}
target.latestForgeWeb = next;
this.rememberSnapshot(target, this.combineSnapshot(target), { forceEmit: false });
} finally {
if (target.forgeWebLookupEntry === entry) {
target.forgeWebLookupEntry = null;
}
}
}
private rememberSnapshot(
@@ -467,6 +467,13 @@ export interface ForgeService {
* Others reject a cross-repo checkout that carries no refs.
*/
supportsCrossRepoCheckoutWithoutRefs?: boolean;
/**
* Repository path that owns the issue and change-request numbers of the
* checkout's origin repository when that is a different repository, such as a
* fork's parent. Null when the origin owns its numbers. Adapters whose forge
* has no fork model omit it.
*/
getReferenceRepository?(options: { cwd: string } & ForgeReadOptions): Promise<string | null>;
getCurrentPullRequestStatus(
options: {
cwd: string;
@@ -429,6 +429,41 @@ function giteaMergeStatus(
};
}
describe("createGiteaService getReferenceRepository", () => {
it("names the repository a fork was forked from, asking about the origin repository explicitly", async () => {
const { service, calls } = makeService(() =>
ok(
JSON.stringify({
full_name: "example-user/sample-repo",
fork: true,
parent: { full_name: "upstream-org/sample-repo" },
}),
),
);
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBe(
"upstream-org/sample-repo",
);
expect(calls).toEqual([["api", "repos/example-user/sample-repo"]]);
});
it("rejects when tea api answers with an error body, since tea exits 0 on HTTP errors", async () => {
const { service } = makeService(() =>
ok(JSON.stringify({ message: "user does not exist [uid: 0, name: ]" })),
);
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).rejects.toThrow();
});
it("is null for a repository that is not a fork", async () => {
const { service } = makeService(() =>
ok(JSON.stringify({ full_name: "example-user/sample-repo", fork: false, parent: null })),
);
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBeNull();
});
});
describe("createGiteaService", () => {
it("maps a tea pr list item to the neutral current PR status by head branch", async () => {
const { service, calls } = makeService((args) => {
@@ -247,6 +247,14 @@ const GiteaPullRequestApiSchema = z
})
.passthrough();
// `full_name` is required so an HTTP error body, which `tea api` returns with exit code 0, fails the parse.
const GiteaRepositoryApiSchema = z
.object({
full_name: z.string(),
parent: z.object({ full_name: z.string().optional() }).nullable().optional(),
})
.passthrough();
const GiteaCurrentPullRequestApiSchema = z
.object({
number: z.number(),
@@ -2007,6 +2015,19 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
}
},
async getReferenceRepository(input): Promise<string | null> {
const repo = await resolveCurrentRepoIdentity(input.cwd);
if (!repo) {
return null;
}
const repository = await runJson(
["api", `repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`],
{ cwd: input.cwd },
GiteaRepositoryApiSchema,
);
return repository.parent?.full_name ?? null;
},
getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
return loadCurrentPullRequestStatus({
cwd: input.cwd,
@@ -528,6 +528,65 @@ describe("ForgeService", () => {
vi.useFakeTimers();
});
describe("getReferenceRepository", () => {
const forkView = JSON.stringify({
owner: { login: "fork-owner" },
name: "repo",
parent: { owner: { login: "main-owner" }, name: "repo" },
});
it("names the parent of a fork checkout, asking about the origin repository explicitly", async () => {
const runner = createRunner([forkView]);
const service = createGitHubService({
runner: runner.runner,
resolveRepoHost: async () => null,
resolveOriginRepository: async () => "fork-owner/repo",
});
await expect(service.getReferenceRepository?.({ cwd: "/tmp/repo" })).resolves.toBe(
"main-owner/repo",
);
expect(runner.calls).toHaveLength(1);
expect(runner.calls[0].args).toEqual([
"repo",
"view",
"fork-owner/repo",
"--json",
"owner,name,parent",
]);
});
it("rejects when gh cannot answer, so a failed lookup is not mistaken for a repository without a parent", async () => {
const service = createGitHubService({
runner: async () => {
throw new GitHubCommandError({
args: ["repo", "view"],
cwd: "/tmp/repo",
exitCode: 1,
stderr: "error connecting to api.github.com",
});
},
resolveRepoHost: async () => null,
resolveOriginRepository: async () => "fork-owner/repo",
});
await expect(service.getReferenceRepository?.({ cwd: "/tmp/repo" })).rejects.toBeInstanceOf(
GitHubCommandError,
);
});
it("is null for a repository that is not a fork", async () => {
const runner = createRunner([JSON.stringify({ owner: { login: "acme" }, name: "repo" })]);
const service = createGitHubService({
runner: runner.runner,
resolveRepoHost: async () => null,
resolveOriginRepository: async () => "acme/repo",
});
await expect(service.getReferenceRepository?.({ cwd: "/tmp/repo" })).resolves.toBeNull();
});
});
afterEach(() => {
vi.useRealTimers();
});
+74 -13
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import {
isGitHubHost,
parseGitHubRemoteIdentity,
parseGitHubRemoteUrl,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
@@ -959,6 +960,11 @@ interface GitHubServiceDependencies {
* per invocation.
*/
resolveRepoSlug: (cwd: string) => Promise<string | null>;
/**
* owner/name of the origin remote on any host: an SSH alias or Enterprise
* host included, which `resolveRepoSlug` leaves to `gh`.
*/
resolveOriginRepository: (cwd: string) => Promise<string | null>;
}
export interface GitHubCommandRunnerOptions {
@@ -1050,6 +1056,7 @@ interface CreateGitHubServiceOptions {
now?: () => number;
resolveRepoHost?: (cwd: string) => Promise<string | null>;
resolveRepoSlug?: (cwd: string) => Promise<string | null>;
resolveOriginRepository?: (cwd: string) => Promise<string | null>;
}
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
@@ -1132,6 +1139,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
now: options.now ?? Date.now,
resolveRepoHost: options.resolveRepoHost ?? resolveGitHubEnterpriseHost,
resolveRepoSlug: options.resolveRepoSlug ?? resolveGitHubSlugFromOrigin,
resolveOriginRepository: options.resolveOriginRepository ?? resolveOriginRepositoryPath,
};
// A resolved enterprise host is cached permanently; a null resolution (no
// host, or the auth probe said no) expires so `gh auth login --hostname`
@@ -2543,6 +2551,26 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return { success: true };
},
getReferenceRepository(input) {
return cached({
cwd: input.cwd,
method: "getReferenceRepository",
args: {},
readOptions: input,
load: async () => {
const repo = await deps.resolveOriginRepository(input.cwd);
if (!repo) {
return null;
}
// A failed lookup must reject: "no parent" is cached as a final answer.
const view = await readGitHubRepoView({ cwd: input.cwd, repo, run });
const parentOwner = view.parent?.owner?.login;
const parentName = view.parent?.name;
return parentOwner && parentName ? `${parentOwner}/${parentName}` : null;
},
});
},
isAuthenticated(input) {
return cached({
cwd: input.cwd,
@@ -3224,20 +3252,53 @@ async function resolveGitHubSlugFromOrigin(cwd: string): Promise<string | null>
return parseGitHubRemoteUrl(stdout.trim())?.repo ?? null;
}
async function getGitHubRepoView(options: {
cwd: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<z.infer<typeof GitHubRepoViewSchema> | null> {
const args = ["repo", "view", "--json", "owner,name,parent"];
async function resolveOriginRepositoryPath(cwd: string): Promise<string | null> {
let stdout: string;
try {
const stdout = await options.run(args, {
cwd: options.cwd,
});
return parseGitHubJsonOutput(stdout, GitHubRepoViewSchema, {
args,
cwd: options.cwd,
emptyFallback: "{}",
});
({ stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
cwd,
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
timeout: GIT_ORIGIN_URL_READ_TIMEOUT_MS,
}));
} catch {
return null;
}
const location = parseGitRemoteLocation(stdout.trim());
return location ? (parseGitHubRemoteIdentity(location.path)?.repo ?? null) : null;
}
interface GitHubRepoViewOptions {
cwd: string;
/** owner/name to ask about; `gh` picks the default repository for the directory when absent. */
repo?: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}
async function readGitHubRepoView(
options: GitHubRepoViewOptions,
): Promise<z.infer<typeof GitHubRepoViewSchema>> {
const args = [
"repo",
"view",
...(options.repo ? [options.repo] : []),
"--json",
"owner,name,parent",
];
const stdout = await options.run(args, {
cwd: options.cwd,
});
return parseGitHubJsonOutput(stdout, GitHubRepoViewSchema, {
args,
cwd: options.cwd,
emptyFallback: "{}",
});
}
async function getGitHubRepoView(
options: GitHubRepoViewOptions,
): Promise<z.infer<typeof GitHubRepoViewSchema> | null> {
try {
return await readGitHubRepoView(options);
} catch (error) {
// A missing CLI or an auth failure must surface as its typed class so the
// caller reports the real problem; only a genuine "not a resolvable repo"
@@ -260,6 +260,38 @@ const DISCUSSIONS = [
},
];
describe("createGitLabService getReferenceRepository", () => {
it("names the project a fork was forked from, asking about the origin project explicitly", async () => {
const { service, calls } = makeService(() =>
ok(
JSON.stringify({
path_with_namespace: "example-group/example-project",
forked_from_project: { path_with_namespace: "upstream-group/sub/example-project" },
}),
),
);
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBe(
"upstream-group/sub/example-project",
);
expect(calls).toEqual([["api", "projects/example-group%2Fexample-project"]]);
});
it("rejects when glab api answers with an error body", async () => {
const { service } = makeService(() => ok(JSON.stringify({ message: "404 Project Not Found" })));
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).rejects.toThrow();
});
it("is null for a project that is not a fork", async () => {
const { service } = makeService(() =>
ok(JSON.stringify({ path_with_namespace: "example-group/example-project" })),
);
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBeNull();
});
});
describe("createGitLabService", () => {
it("maps a glab merge request view to the neutral current PR status", async () => {
const { service, calls } = makeService((args) =>
@@ -249,6 +249,16 @@ const GitLabApprovalsSchema = z
})
.passthrough();
const GitLabProjectSchema = z
.object({
path_with_namespace: z.string(),
forked_from_project: z
.object({ path_with_namespace: z.string().optional() })
.nullable()
.optional(),
})
.passthrough();
type GitLabMergeRequest = z.infer<typeof GitLabMergeRequestSchema>;
type GitLabIssue = z.infer<typeof GitLabIssueSchema>;
type GitLabNote = z.infer<typeof GitLabNoteSchema>;
@@ -1151,6 +1161,20 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
}
},
async getReferenceRepository(input): Promise<string | null> {
const remoteUrl = await resolveRemoteUrl(input.cwd);
const projectPath = remoteUrl ? parseGitRemoteLocation(remoteUrl)?.path : null;
if (!projectPath) {
return null;
}
const project = await runJson(
["api", `projects/${encodeURIComponent(projectPath)}`],
{ cwd: input.cwd },
GitLabProjectSchema,
);
return project.forked_from_project?.path_with_namespace ?? null;
},
async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
try {
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef, input.headSha);
@@ -23,8 +23,9 @@ export default function contribute(server: PluginServerContext) {
return;
}
const agent = context.paseo.agents.ref(event.agent.id);
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.map(({ item }) => item));
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
const text = latestOutputText(turn.map(({ item }) => item));
if (/out of credits/i.test(text)) {
console.log("Sending a follow-up after out of credits", event.agent.id);
await agent.send("Try again.");
@@ -0,0 +1,9 @@
# Workspace row badges
Install this directory as a plugin. Any workspace that needs input or has failed shows a badge on
the line under its title in the sidebar. Workspaces in any other state show nothing.
The component reads its own data with `useWorkspace(workspaceId, selector)` and returns `null` when
it has nothing to say. **Show → Workspace attention** in the sidebar's display preferences toggles it.
See the [reference](../../public-docs/plugins/reference.md#workspace-row-badge-items).

Some files were not shown because too many files have changed in this diff Show More