Compare commits

..
Author SHA1 Message Date
e33d8e5ccb test(app): reproduce collapsed assistant drag selection (#9504)
A mouse drag that starts partway through assistant text leaves a
collapsed selection on main in Chromium, with and without touch. The
drag test is taken from #9469 without its handler-specific cases.

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 06:54:51 +00:00
111 changed files with 569 additions and 3272 deletions
-32
View File
@@ -113,38 +113,6 @@ 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
+3 -10
View File
@@ -20,16 +20,9 @@ settings the native composer does not implement.
## Native sources
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
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
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,21 +624,6 @@ 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
@@ -1,6 +1,9 @@
import type { BrowserContext, Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
openAgentRoute,
seedMockAgentWorkspace,
} from "../support/helpers/mock-agent";
const CROSS_STRUCTURE_MARKDOWN = [
"# P1 — High-value UI behavior",
@@ -74,20 +77,20 @@ const ASSISTANT_MARKDOWN = [
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
"www.example.com stays",
"[www.example.com](http://www.example.com) stays",
"[www.example.com](http://www.example.com) stays"
)
.replace(
"`https://example.com/generated` stays",
"[`https://example.com/generated`](https://example.com/generated) stays",
"[`https://example.com/generated`](https://example.com/generated) stays"
)
.replace(`${CROSS_STRUCTURE_MARKDOWN}\n\n`, "")
.replace(
"<https://autolink.example.com>",
"[https://autolink.example.com](https://autolink.example.com)",
"[https://autolink.example.com](https://autolink.example.com)"
)
.replace(
"3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
"3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
"3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine"
)
// Rendering drops the blank line before a closing fence, so copying cannot bring it
// back. The fence is here to cover a body that ends in more than one newline.
@@ -106,14 +109,19 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
* Locate assistant rows by source-message identity.
*/
function assistantMessageBlocks(page: Page): Locator {
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
return page
.locator("[data-message-id]")
.filter({ has: page.getByTestId("assistant-message") });
}
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
await selectAssistantTextRange(page, "Direct matches:", "ready");
}
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
async function selectAssistantElement(
page: Page,
selector: string
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage.locator(selector).evaluate((element) => {
const selection = window.getSelection();
@@ -131,7 +139,7 @@ async function selectAssistantElement(page: Page, selector: string): Promise<voi
async function selectAssistantElementFromEndOf(
page: Page,
startText: string,
selector: string,
selector: string
): Promise<void> {
await assistantMessageBlocks(page).evaluateAll(
(blocks, selected) => {
@@ -144,9 +152,13 @@ async function selectAssistantElementFromEndOf(
}
}
}
const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean);
const end = blocks
.map((block) => block.querySelector(selected.selector))
.find(Boolean);
if (!startNode || !end) {
throw new Error(`Could not find ${selected.startText} — ${selected.selector}`);
throw new Error(
`Could not find ${selected.startText} — ${selected.selector}`
);
}
const range = document.createRange();
range.setStart(startNode, startNode.length);
@@ -155,7 +167,7 @@ async function selectAssistantElementFromEndOf(
selection?.removeAllRanges();
selection?.addRange(range);
},
{ startText, selector },
{ startText, selector }
);
}
@@ -188,7 +200,11 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
range.setStart(node, offset);
range.setEnd(node, offset + selectedText.length);
const box = range.getBoundingClientRect();
return { x: box.right - 1, y: box.top + box.height / 2, left: box.left };
return {
x: box.right - 1,
y: box.top + box.height / 2,
left: box.left,
};
}
}
throw new Error(`Could not find ${selectedText}`);
@@ -203,6 +219,50 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
await page.mouse.up();
}
async function dragAssistantTextTo(
page: Page,
startText: string,
endText: string
): Promise<string> {
const blocks = assistantMessageBlocks(page);
const end = await blocks.getByText(endText).boundingBox();
if (!end) throw new Error(`Expected rendered ending text: ${endText}`);
const start = await assistantTextPoint(page, startText);
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 35, start.y, { steps: 5 });
const initialSelection = await page.evaluate(
() => window.getSelection()?.toString() ?? ""
);
await page.mouse.move(end.x + end.width - 1, end.y + end.height / 2, {
steps: 20,
});
await page.mouse.up();
return initialSelection;
}
async function assistantTextPoint(
page: Page,
text: string
): Promise<{ x: number; y: number }> {
return assistantMessageBlocks(page)
.getByText(text)
.evaluate((element, value) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node: Node | null = null;
while ((node = walker.nextNode())) {
if (node.textContent?.includes(value.slice(0, 4))) break;
}
if (!node?.textContent)
throw new Error(`Expected rendered text node: ${value}`);
const range = document.createRange();
range.setStart(node, 1);
range.setEnd(node, Math.min(2, node.textContent.length));
const rect = range.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
}, text);
}
async function selectAssistantText(page: Page, text: string): Promise<void> {
await selectAssistantTextRange(page, text, text);
}
@@ -211,7 +271,7 @@ async function selectAssistantListItemFromMarker(
page: Page,
listTag: "ol" | "ul",
itemText: string,
endText: string,
endText: string
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
const item = assistantMessage
@@ -234,7 +294,9 @@ async function selectAssistantListItemFromMarker(
}
}
if (!endNode) {
throw new Error(`Could not find list item selection end: ${selectedEndText}`);
throw new Error(
`Could not find list item selection end: ${selectedEndText}`
);
}
const range = document.createRange();
@@ -249,7 +311,7 @@ async function selectAssistantListItemFromMarker(
async function doubleClickAssistantMarkdownText(
page: Page,
tag: "code" | "strong",
text: string,
text: string
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage
@@ -261,14 +323,17 @@ async function doubleClickAssistantMarkdownText(
async function selectAssistantTextRange(
page: Page,
startText: string,
endText: string,
endText: string
): Promise<void> {
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
const blockWalker = document.createTreeWalker(
block,
NodeFilter.SHOW_TEXT
);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
@@ -280,11 +345,12 @@ async function selectAssistantTextRange(
const end = text.indexOf(selectedRange.endText, start);
if (start < 0 || end < 0)
throw new Error(
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`
);
const boundary = (offset: number, isEnd = false): [Text, number] => {
for (const node of nodes) {
if (offset < node.length || (isEnd && offset === node.length)) return [node, offset];
if (offset < node.length || (isEnd && offset === node.length))
return [node, offset];
offset -= node.length;
}
throw new Error("Selection extends beyond message");
@@ -296,7 +362,7 @@ async function selectAssistantTextRange(
selection?.removeAllRanges();
selection?.addRange(range);
},
{ startText, endText },
{ startText, endText }
);
}
@@ -310,14 +376,17 @@ async function selectAssistantTextRange(
async function selectAssistantAcrossCodeLines(
page: Page,
startText: string,
endText: string,
endText: string
): Promise<void> {
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
const blockWalker = document.createTreeWalker(
block,
NodeFilter.SHOW_TEXT
);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
@@ -331,7 +400,8 @@ async function selectAssistantAcrossCodeLines(
let textNode = walker.nextNode();
while (textNode) {
if (!startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
const offset =
textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
if (offset >= 0) {
startNode = textNode;
startOffset = offset;
@@ -339,7 +409,8 @@ async function selectAssistantAcrossCodeLines(
textNode = walker.nextNode();
continue;
}
const endOffset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
const endOffset =
textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
if (endOffset >= 0) {
const range = document.createRange();
range.setStart(startNode, startOffset);
@@ -352,10 +423,10 @@ async function selectAssistantAcrossCodeLines(
textNode = walker.nextNode();
}
throw new Error(
`Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`,
`Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`
);
},
{ startText, endText },
{ startText, endText }
);
}
@@ -387,7 +458,10 @@ test("copying an assistant selection preserves Markdown structure and links", as
page,
}) => {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "serif" }));
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ uiFontFamily: "serif" })
);
});
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-copy-",
@@ -401,7 +475,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
30_000
);
await openAgentRoute(page, agent);
@@ -411,11 +485,15 @@ test("copying an assistant selection preserves Markdown structure and links", as
["em", "emphasized prose"],
["s", "struck prose"],
]) {
const formattedProse = assistantMessage.locator(tag).filter({ hasText: text });
const formattedProse = assistantMessage
.locator(tag)
.filter({ hasText: text });
await expect(formattedProse).toHaveCSS("font-family", "serif");
await expect(formattedProse).not.toHaveAttribute("data-pmono");
}
const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" });
const inlineCode = assistantMessage
.locator("code")
.filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/);
await selectStructuredClipboardFixture(page);
@@ -427,38 +505,44 @@ test("copying an assistant selection preserves Markdown structure and links", as
expect(clipboard.html).not.toContain("<li>");
expect(clipboard.html).toContain("<div>- ");
expect(clipboard.html).toContain(
'<strong><a href="https://example.com/issues/1">First issue</a></strong>',
'<strong><a href="https://example.com/issues/1">First issue</a></strong>'
);
expect(clipboard.html).toContain("<code>apply_patch</code>");
expect(clipboard.html).toContain("<div>5. Fifth item</div>");
expect(clipboard.html).toContain("A hard break<br>");
expect(clipboard.html).toContain(
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.',
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.'
);
expect(clipboard.html).toContain(
'<a href="https://docs.example.com/" title="Reference">docs</a>',
'<a href="https://docs.example.com/" title="Reference">docs</a>'
);
expect(clipboard.html).toContain(
'<a href="https://autolink.example.com/">https://autolink.example.com</a>',
'<a href="https://autolink.example.com/">https://autolink.example.com</a>'
);
expect(clipboard.html).toContain(
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>'
);
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
expect(clipboard.html).toContain(
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
"<code>https://example.com/generated</code>"
);
expect(clipboard.html).toContain(
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>'
);
expect(clipboard.html).toContain('<code class="language-typescript">');
expect(clipboard.html).toContain(
'<pre><code class="language-text">before\n```\nafter\n</code></pre>',
'<pre><code class="language-text">before\n```\nafter\n</code></pre>'
);
expect(clipboard.html).toContain("<table>");
expect(clipboard.html).toContain('<td style="text-align:left">Current | active</td>');
expect(clipboard.html).toContain(
'<td style="text-align:left">Current | active</td>'
);
expect(clipboard.html.match(/<td\b/g)).toHaveLength(3);
expect(clipboard.html).toContain("<s>obsolete</s>");
expect(clipboard.html).toContain('<th style="text-align:left">Left</th>');
expect(clipboard.html).toContain('<th style="text-align:right">Right</th>');
expect(clipboard.html).toContain('<th style="text-align:center">Center</th>');
expect(clipboard.html).toContain(
'<th style="text-align:center">Center</th>'
);
await doubleClickAssistantMarkdownText(page, "code", "apply_patch");
await copySelection(page);
@@ -477,7 +561,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
await selectAssistantTextRange(
page,
"P1 — High-value UI behavior",
"Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
"Smoke with an older saved Fabel/model alias and with daemon/client version drift."
);
await copySelection(page);
@@ -492,10 +576,10 @@ test("copying an assistant selection preserves Markdown structure and links", as
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
" - Confirm Opus 5 exposes the Fast toggle.",
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
].join("\n"),
].join("\n")
);
expect(headingAndListClipboard.html).toMatch(
/<h1>P1 — High-value UI behavior<\/h1>\s*<div>5\. Claude model and thinking preferences\s*<\/div><div>- Switch Claude models/,
/<h1>P1 — High-value UI behavior<\/h1>\s*<div>5\. Claude model and thinking preferences\s*<\/div><div>- Switch Claude models/
);
await selectAssistantText(page, "First");
@@ -505,7 +589,11 @@ test("copying an assistant selection preserves Markdown structure and links", as
expect(partialClipboard.plainText).toBe("First");
expect(partialClipboard.html).toContain("<p>First</p>");
await selectAssistantTextRange(page, "Direct matches:", "repeated sandbox setup.");
await selectAssistantTextRange(
page,
"Direct matches:",
"repeated sandbox setup."
);
await copySelection(page);
const paragraphAndListClipboard = await readRichClipboard(page);
@@ -517,26 +605,36 @@ test("copying an assistant selection preserves Markdown structure and links", as
"",
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
].join("\n"),
].join("\n")
);
expect(paragraphAndListClipboard.html).not.toContain("<ul>");
expect(paragraphAndListClipboard.html).not.toContain("<li>");
expect(paragraphAndListClipboard.html).toContain(
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>'
);
await selectAssistantListItemFromMarker(page, "ul", "First issue", "failure.");
await selectAssistantListItemFromMarker(
page,
"ul",
"First issue",
"failure."
);
await copySelection(page);
const draggedBulletClipboard = await readRichClipboard(page);
expect(draggedBulletClipboard.plainText).toBe(
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure."
);
expect(draggedBulletClipboard.html).toContain(
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>'
);
await selectAssistantListItemFromMarker(page, "ol", "Sixth item", "Sixth item");
await selectAssistantListItemFromMarker(
page,
"ol",
"Sixth item",
"Sixth item"
);
await copySelection(page);
const draggedNumberClipboard = await readRichClipboard(page);
@@ -581,7 +679,9 @@ test("copying an assistant selection preserves Markdown structure and links", as
await copySelection(page);
const crossBlockClipboard = await readRichClipboard(page);
expect(crossBlockClipboard.plainText).toBe("Seventh item\n\n8. Eighth item\n\nNested list:");
expect(crossBlockClipboard.plainText).toBe(
"Seventh item\n\n8. Eighth item\n\nNested list:"
);
expect(crossBlockClipboard.html).toContain("<div>8. Eighth item</div>");
await selectAssistantText(page, "Current");
@@ -608,31 +708,37 @@ test("copying an assistant selection preserves Markdown structure and links", as
await copySelection(page);
const codeLinesClipboard = await readRichClipboard(page);
expect(codeLinesClipboard.plainText).toBe('const answer = "yes";\n return');
expect(codeLinesClipboard.html).toContain('<pre><code class="language-typescript">');
expect(codeLinesClipboard.plainText).toBe(
'const answer = "yes";\n return'
);
expect(codeLinesClipboard.html).toContain(
'<pre><code class="language-typescript">'
);
// Selecting inside code expresses an intent to copy code, even when the selection
// contains every character in the block.
await selectAssistantElement(
page,
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]'
);
await copySelection(page);
const completeCodeClipboard = await readRichClipboard(page);
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
expect(completeCodeClipboard.plainText).toBe(
'const answer = "yes";\n return answer;'
);
// Touching the block before it without selecting any of its text is still a
// selection inside the code.
await selectAssistantElementFromEndOf(
page,
"not a generated link.",
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]'
);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'const answer = "yes";\n return answer;',
'const answer = "yes";\n return answer;'
);
// Crossing the block boundary expresses an intent to carry its Markdown structure.
@@ -641,15 +747,19 @@ test("copying an assistant selection preserves Markdown structure and links", as
const crossedCodeClipboard = await readRichClipboard(page);
expect(crossedCodeClipboard.plainText).toBe(
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.'
);
// Crossing into part of the block keeps the selected code lines as a fenced block.
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
await selectAssistantAcrossCodeLines(
page,
"not a generated link.",
"return"
);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```'
);
// So does starting partway through the block and leaving it.
@@ -657,7 +767,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.'
);
// Selecting a whole line runs off its end into the newline node. A trailing
@@ -665,20 +775,28 @@ test("copying an assistant selection preserves Markdown structure and links", as
await selectAssistantAcrossCodeLines(page, "const", " ");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe('const answer = "yes";');
expect((await readRichClipboard(page)).plainText).toBe(
'const answer = "yes";'
);
// The block's own Copy button had the same hazard: a Markdown fence body ends in
// a newline, and the button copied it raw.
const typescriptFence = assistantMessage.locator('[data-paseo-markdown-language="typescript"]');
const typescriptFence = assistantMessage.locator(
'[data-paseo-markdown-language="typescript"]'
);
// The button is opacity 0 / pointerEvents none until the fence is hovered.
await typescriptFence.hover();
await typescriptFence.locator("[data-paseo-markdown-ignore]").click();
expect(await readPlainClipboard(page)).toBe('const answer = "yes";\n return answer;');
expect(await readPlainClipboard(page)).toBe(
'const answer = "yes";\n return answer;'
);
// A blank line before the closing fence leaves the body ending in two newlines,
// so stripping only the terminal one still hands the terminal an executable line.
const bashFence = assistantMessage.locator('[data-paseo-markdown-language="bash"]');
const bashFence = assistantMessage.locator(
'[data-paseo-markdown-language="bash"]'
);
await bashFence.hover();
await bashFence.locator("[data-paseo-markdown-ignore]").click();
@@ -714,22 +832,28 @@ test("copying a selection across an assistant image keeps its Markdown source an
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
30_000
);
await openAgentRoute(page, agent);
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
await selectAssistantTextRange(page, "Before the image.", "After the image.");
await selectAssistantTextRange(
page,
"Before the image.",
"After the image."
);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
`Before the image.\n\n![chart](${image})\n\nAfter the image.`,
`Before the image.\n\n![chart](${image})\n\nAfter the image.`
);
await selectAssistantImage(page);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(`![chart](${image})`);
expect((await readRichClipboard(page)).plainText).toBe(
`![chart](${image})`
);
await dragFromBelowImageTo(page, "After the");
await copySelection(page);
@@ -745,7 +869,9 @@ test.describe("on a touch-capable desktop browser", () => {
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
test.use({ hasTouch: true });
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
test("a mouse drag selects across assistant list, quote, and code blocks", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-drag-",
title: "Assistant selection drag",
@@ -771,27 +897,115 @@ test.describe("on a touch-capable desktop browser", () => {
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
30_000
);
await openAgentRoute(page, agent);
const blocks = assistantMessageBlocks(page);
const first = await blocks.getByText("First paragraph.").boundingBox();
const third = await blocks.getByText("Third paragraph.").boundingBox();
if (!first || !third) {
throw new Error("Expected rendered paragraphs");
await allowRichClipboard(page.context());
for (const startText of [
"First paragraph.",
"List item.",
"Quote text.",
"const answer = 1;",
]) {
const initialSelection = await dragAssistantTextTo(
page,
startText,
"Third paragraph."
);
expect(
initialSelection,
`drag starts selecting inside ${startText}`
).toContain(startText.slice(2, 4));
const selectedFromBlock = await page.evaluate(
() => window.getSelection()?.toString() ?? ""
);
expect(selectedFromBlock, `drag starting in ${startText}`).toContain(
startText.slice(2)
);
expect(selectedFromBlock, `drag ending in third paragraph`).toContain(
"Third paragraph."
);
await copySelection(page);
const copied = await readRichClipboard(page);
expect(copied.plainText, `copy starting in ${startText}`).toContain(
startText.slice(2)
);
expect(copied.plainText).toContain("Third paragraph.");
}
} finally {
await agent.cleanup();
}
});
});
test.describe("on a desktop browser without touch", () => {
// Reported in #9504: a drag that starts partway through text only moves a collapsed caret.
test("a mouse drag selects across assistant list, quote, and code blocks", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-drag-",
title: "Assistant selection drag",
initialPrompt: "Render structured text.",
featureValues: {
mockAssistantResponse: [
"First paragraph.",
"",
"- List item.",
"",
"> Quote text.",
"",
"```ts",
"const answer = 1;",
"```",
"",
"Third paragraph.",
].join("\n"),
},
});
try {
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000
);
await openAgentRoute(page, agent);
await allowRichClipboard(page.context());
for (const startText of [
"First paragraph.",
"List item.",
"Quote text.",
"const answer = 1;",
]) {
const initialSelection = await dragAssistantTextTo(
page,
startText,
"Third paragraph."
);
expect(
initialSelection,
`drag starts selecting inside ${startText}`
).toContain(startText.slice(2, 4));
const selectedFromBlock = await page.evaluate(
() => window.getSelection()?.toString() ?? ""
);
expect(selectedFromBlock, `drag starting in ${startText}`).toContain(
startText.slice(2)
);
expect(selectedFromBlock, `drag ending in third paragraph`).toContain(
"Third paragraph."
);
await copySelection(page);
const copied = await readRichClipboard(page);
expect(copied.plainText, `copy starting in ${startText}`).toContain(
startText.slice(2)
);
expect(copied.plainText).toContain("Third paragraph.");
}
await page.mouse.move(first.x + 1, first.y + first.height / 2);
await page.mouse.down();
await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 });
await page.mouse.up();
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("First paragraph.");
expect(selected).toContain("List item.");
expect(selected).toContain("Quote text.");
expect(selected).toContain("const answer = 1;");
expect(selected).toContain("Third paragraph.");
} finally {
await agent.cleanup();
}
@@ -1,148 +0,0 @@
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();
}
});
+2 -22
View File
@@ -25,7 +25,6 @@ import {
} from "../support/helpers/usage-login";
import {
claudeAndCodexReports,
closeUsage,
expectPinnedUsage,
openCompactSidebar,
openUsageFromIcon,
@@ -80,17 +79,12 @@ function twoHoursAgo(): string {
}
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
async function qaScreenshot(page: Page, name: string) {
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; }" });
const file = path.join(directory, `${name}.png`);
if (target === "summary") {
await usageItem(page).screenshot({ path: file });
return;
}
await page.screenshot({ path: file });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}
function hostFilter(page: Page) {
@@ -634,15 +628,6 @@ 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);
@@ -654,8 +639,6 @@ 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",
@@ -664,9 +647,6 @@ 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,7 +8,6 @@ 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
@@ -20,42 +19,6 @@ 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
@@ -99,14 +62,6 @@ 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 = []
@@ -114,7 +69,6 @@ public class PaseoHardwareKeyboardModule: Module {
activeModule = nil
isHardwareSubmitEnabled = false
overlayKeys = []
claimedShortcuts = []
}
}
}
@@ -185,10 +139,7 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
{
commands.append(makeSubmitCommand())
}
if !isComposingText() {
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
}
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
return commands
}
@@ -221,18 +172,6 @@ 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,8 +58,6 @@ 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 });
@@ -67,7 +65,6 @@ 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;
@@ -610,9 +607,9 @@ function OptionList<Value extends string>({
}
/**
* 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.
* 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.
*
* 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
@@ -635,12 +632,6 @@ 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
@@ -658,37 +649,6 @@ 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,12 +10,6 @@ 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";
@@ -33,10 +27,6 @@ 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;
@@ -85,30 +75,9 @@ 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) => {
@@ -154,9 +123,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
rowItems: sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
checksDisplay: sidebarChecksDisplay,
setChecksDisplay,
trailing: sidebarWorkspaceTrailing,
@@ -182,9 +148,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
sidebarChecksDisplay,
setChecksDisplay,
sidebarWorkspaceTrailing,
@@ -186,7 +186,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
prHint={workspace.prHint}
serviceSummary={serviceSummary}
labels={labels}
workspace={workspace}
/>
</View>
</View>
@@ -16,10 +16,6 @@ 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";
@@ -63,9 +59,6 @@ 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,
@@ -74,7 +67,6 @@ export function WorkspaceMetaRow({
prHint,
serviceSummary,
labels = EMPTY_LABELS,
workspace = null,
}: {
currentBranch: string | null;
projectName: string | null;
@@ -82,8 +74,6 @@ 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({
@@ -96,9 +86,8 @@ export function WorkspaceMetaRow({
visible: rowItems,
checksDisplay,
});
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
if (items.length === 0) return null;
return (
<View style={styles.row}>
@@ -108,9 +97,6 @@ export function WorkspaceMetaRow({
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
</Fragment>
))}
{workspace ? (
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
) : null}
</View>
);
}
+1 -16
View File
@@ -1,4 +1,3 @@
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
@@ -10,27 +9,13 @@ 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;
}) {
// 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],
);
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
}
@@ -22,17 +22,6 @@ 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,4 +1,3 @@
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";
@@ -27,15 +26,12 @@ 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, forgeWeb: input.forgeWeb, sigil, number: 1 }) !==
null,
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
);
if (sigils.length === 0) return null;
return {
@@ -45,7 +41,6 @@ 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,81 +326,3 @@ 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");
});
});
+20 -32
View File
@@ -6,23 +6,17 @@
* 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 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.
* 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.
*/
import {
forgeWebHost,
isForgeCloudHost,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
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;
@@ -31,17 +25,12 @@ export interface ForgeBlobUrlInput {
export interface ForgeBranchTreeUrlInput {
remoteUrl: string | null | undefined;
forgeWeb?: ForgeWeb | null;
branch: string | null | undefined;
}
export interface ForgeReferenceUrlInput {
remoteUrl: string | null | undefined;
forgeWeb?: ForgeWeb | null;
/**
* Repository path of a cross-repository reference; the repository that owns
* issue numbers (`forgeWeb.referenceRepository`, else the remote's) when absent.
*/
/** Repository path of a cross-repository reference; the remote's repository when absent. */
repo?: string | null;
sigil: ForgeReferenceSigil;
number: number;
@@ -73,6 +62,13 @@ 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) {
@@ -81,17 +77,9 @@ 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;
@@ -100,9 +88,9 @@ function resolveForgeWebLocation(
if (!location || !isValidRepoPath(location.path)) {
return null;
}
// 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);
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
const isCloudHost = cloudHosts.includes(location.host);
const webHost = isCloudHost ? cloudHosts[0] : 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.
@@ -151,7 +139,7 @@ export function buildForgeBranchTreeUrl(
input: ForgeBranchTreeUrlInput,
): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
@@ -161,7 +149,7 @@ export function buildForgeBranchTreeUrl(
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeBlobPath(input.path);
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
@@ -187,8 +175,8 @@ export function buildForgeReferenceUrl(
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
(path) => path.sigil === input.sigil,
);
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
const repo = input.repo ?? input.forgeWeb?.referenceRepository ?? location?.repo;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const repo = input.repo ?? location?.repo;
if (
!referencePath ||
!location ||
@@ -96,8 +96,6 @@ 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. */
@@ -158,7 +156,6 @@ 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,
@@ -256,7 +253,6 @@ 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,32 +4,20 @@ 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 () => {
if (surfaces.delete(token)) notify();
surfaces.delete(token);
};
},
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
if (commandCenterOpen) return "command-center";
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
let focused: KeyboardFocusScope = hasEditableFocus() ? "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,7 +10,6 @@ export interface KeyboardSourceHandlers {
keyUp(event: KeyboardInputEvent): void;
blur(): void;
commandCenterOpen(): boolean;
subscribeCommandCenter(listener: () => void): () => void;
}
export function nativeKeyboardInput(
event: KeyboardInputEvent,
@@ -24,6 +23,5 @@ export interface KeyboardPolicyInput {
bindings: readonly ParsedShortcutBinding[];
chordState?: ChordState;
isMac: boolean;
usesDesktopBindings: boolean;
enabled: boolean;
}
@@ -366,13 +366,7 @@ describe("Electron guest source through shared resolution", () => {
},
captureFocusRestore: (id) => restored.push(id),
},
{
keyDown: pipeline.keyDown,
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
},
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
);
receive({
browserId: "tab-1",
@@ -1,5 +1,5 @@
import { requireNativeModule } from "expo-modules-core";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
import type { HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
@@ -19,9 +19,6 @@ 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,10 +1,9 @@
import { requireNativeModule } from "expo-modules-core";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
import type { 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,
@@ -20,9 +19,6 @@ 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,19 +3,12 @@ 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,19 +1,11 @@
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,
});
import type { KeyboardSourceHandlers } from "./contract";
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
import { attachHardwareKeyboardSource } from "./native";
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
return hardwareKeyboard.attach(handlers);
return attachHardwareKeyboardSource(
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
handlers,
);
}
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
hardwareKeyboard.publishPolicy(input);
}
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
@@ -1,3 +1,4 @@
import { usesDesktopBindings } from "../conventions";
import { getDesktopHost } from "@/desktop/host";
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -25,7 +26,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
}
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
const policy = input.enabled
? buildBrowserKeyboardPolicy(input)
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
: { menuPrefixes: [], prefixes: [] };
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
}
@@ -1,92 +1,31 @@
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
import { focusSurfaces } from "../focus-surfaces";
import { overlays } from "../overlays";
import {
nativeKeyboardInput,
type KeyboardPolicyInput,
type KeyboardSourceHandlers,
} from "./contract";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
import type { HardwareKeyboardEvent } from "./hardware";
interface HardwareKeySource {
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
setOverlayKeys(keys: readonly string[]): void;
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
}
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 });
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;
}
}
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([]);
};
},
handlers.keyDown(
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
);
});
return () => {
unsubscribeHardware();
unsubscribeOverlays();
source.setOverlayKeys([]);
};
}
@@ -25,7 +25,6 @@ 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 { createHardwareKeyboardSource } = await import("./internal/sources/native");
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
const { focusSurfaces } = await import("./internal/focus-surfaces");
const actions: string[] = [];
const releases: string[] = [];
@@ -148,22 +148,23 @@ describe("hardware source adapter", () => {
},
overlays: createOverlayRegistry(),
});
const detach = createHardwareKeyboardSource({
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
const detach = attachHardwareKeyboardSource(
{
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
},
setOverlayKeys() {},
},
setOverlayKeys() {},
setClaimedShortcuts() {},
}).attach({
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
{
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
},
);
const down = {
...keyboardEvent("k", "KeyK", isMac),
isComposing: false,
@@ -179,94 +180,4 @@ 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();
});
});
+1 -5
View File
@@ -62,7 +62,7 @@ export function useKeyboardShortcuts({
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
const publishBrowserShortcutPolicy = useCallback(
(chordState?: ChordState) => {
publishKeyboardPolicy({ bindings, chordState, isMac, usesDesktopBindings, enabled });
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
},
[bindings, enabled, isMac],
);
@@ -265,10 +265,6 @@ 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,7 +134,6 @@ describe("DOM source through the shared resolver", () => {
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
@@ -26,7 +26,6 @@ function installation(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -105,7 +105,6 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,7 +37,6 @@ function installed(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -47,7 +47,6 @@ function plugin(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
-41
View File
@@ -101,7 +101,6 @@ 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() {} }),
@@ -133,7 +132,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -154,7 +152,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -285,44 +282,6 @@ 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,7 +37,6 @@ import type {
PluginScreenContribution,
PluginSidebarItemContribution,
PluginWorkspacePanelContribution,
PluginWorkspaceRowBadgeItemContribution,
} from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
@@ -134,7 +133,6 @@ export function runPluginClientBundle(
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -153,7 +151,6 @@ 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>();
@@ -326,23 +323,6 @@ 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>) {
@@ -654,7 +634,6 @@ 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,7 +46,6 @@ 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) });
export function renderNothing() {
function renderNothing() {
return null;
}
@@ -11,7 +11,6 @@ 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,7 +125,6 @@ export class PluginRegistry {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,7 +37,6 @@ function installed(
footer: [{ id: "status", title: "Status", Component: () => null }],
},
legacySidebarItems,
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -51,7 +51,6 @@ 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,7 +64,6 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -27,7 +27,6 @@ function plugin(input: {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -34,7 +34,6 @@ vi.mock("../client-runtime", () => ({
}));
vi.mock("../icons", () => ({
Icon: () => null,
PluginIconView: () => null,
pluginIconComponent: () => () => null,
}));
-5
View File
@@ -17,7 +17,6 @@ import type {
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarViewContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution as SDKPluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution as SDKPluginScreenContribution,
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
PluginTimelineRendererContribution,
@@ -34,8 +33,6 @@ 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>;
@@ -59,7 +56,6 @@ 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[];
@@ -99,7 +95,6 @@ export type {
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution,
PluginSettingsScreenContribution,
PluginThemeContribution,
@@ -24,7 +24,6 @@ function installed(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -1,96 +0,0 @@
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);
@@ -1,93 +0,0 @@
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") },
}));
@@ -1,44 +0,0 @@
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);
});
});
@@ -1,45 +0,0 @@
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,21 +354,6 @@ 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,7 +3,6 @@ import { z } from "zod";
import {
AgentStatusSchema,
AgentTimelineItemPayloadSchema,
ForgeWebSchema,
WorkspaceGitHubRuntimePayloadSchema,
} from "@getpaseo/protocol/messages";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
@@ -336,7 +335,6 @@ const StoredWorkspaceSchema = z.strictObject({
gitRuntime: WorkspaceGitRuntimeSchema,
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
forge: z.string().optional(),
forgeWeb: ForgeWebSchema.optional(),
});
const StoredProjectSchema = z.strictObject({
@@ -770,7 +768,6 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
gitRuntime: workspace.gitRuntime,
githubRuntime: workspace.githubRuntime,
forge: workspace.forge,
forgeWeb: workspace.forgeWeb,
};
}
@@ -3919,11 +3919,7 @@ function WorkspaceMarkdownLinks({
}: AssistantFileLinkResolverProviderProps & { workspace: WorkspaceDescriptor | null | undefined }) {
return (
<AssistantFileLinkResolverProvider {...fileLinks}>
<ForgeShorthandProvider
forge={workspace?.forge}
remoteUrl={workspace?.gitRuntime?.remoteUrl}
forgeWeb={workspace?.forgeWeb}
>
<ForgeShorthandProvider forge={workspace?.forge} remoteUrl={workspace?.gitRuntime?.remoteUrl}>
{children}
</ForgeShorthandProvider>
</AssistantFileLinkResolverProvider>
-2
View File
@@ -140,7 +140,6 @@ export interface WorkspaceDescriptor {
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
forge?: WorkspaceDescriptorPayload["forge"];
forgeWeb?: WorkspaceDescriptorPayload["forgeWeb"];
project?: ProjectPlacementPayload;
}
@@ -186,7 +185,6 @@ export function normalizeWorkspaceDescriptor(
gitRuntime: payload.gitRuntime,
githubRuntime: payload.githubRuntime,
forge: payload.forge,
forgeWeb: payload.forgeWeb,
project: payload.project,
};
}
+15 -38
View File
@@ -2,13 +2,7 @@ import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { usageCopy } from "./copy";
import type { UsageDisplayAs } from "./preferences";
import type {
AvailableUsageReport,
UsageReport,
UsageReportEntry,
UsageView,
UsageWindow,
} from "./types";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
export function usedPercent(window: UsageWindow): number | null {
if (window.usedPct != null) return window.usedPct;
@@ -211,34 +205,13 @@ 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";
const snapshot = resolveUsageSnapshot(entry);
// 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 messages: string[] = [];
if (!available) {
if (entry.loginErrors)
@@ -251,12 +224,16 @@ export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
else messages.push(usageProblemText(entry.report, t));
}
return {
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,
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,
messages,
};
}
+2 -26
View File
@@ -139,7 +139,6 @@ describe("resolvePinnedUsage", () => {
expect(sources[0]).toEqual({
key: "claude:default",
icon: "<svg/>",
hasError: false,
windows: [
{
key: "claude:default/five-hour",
@@ -258,7 +257,7 @@ describe("resolvePinnedUsage", () => {
});
});
it("shows retained windows and marks errored sources in the sidebar summary", () => {
it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => {
if (claude.report.status !== "available" || codex.report.status !== "available")
throw new Error("fixtures must be available");
const expired: UsageReportEntry = {
@@ -274,30 +273,7 @@ it("shows retained windows and marks errored sources in the sidebar summary", ()
lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report },
report: { status: "error", error: "Store deleted" },
};
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([]);
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
});
it("localizes pinned labels and meanings while preserving custom window names", async () => {
+4 -14
View File
@@ -1,7 +1,7 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
import { displayPercent, resolveUsageSnapshot } from "./model";
import { displayPercent } from "./model";
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
import { windowTone } from "./tone";
import type { UsageReportEntry, UsageTone } from "./types";
@@ -23,8 +23,6 @@ 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[];
}
@@ -76,9 +74,8 @@ export function resolvePinnedUsage(
): PinnedUsageSource[] {
const pins = effectiveUsagePins(preferences, reports);
return groupBySource(reports).flatMap((entry) => {
const snapshot = resolveUsageSnapshot(entry);
if (!snapshot) return [];
const windows = snapshot.report.windows
if (entry.report.status !== "available") return [];
const windows = entry.report.windows
.filter(
(window) =>
displayPercent(window, preferences.displayAs) !== null &&
@@ -97,13 +94,6 @@ export function resolvePinnedUsage(
};
});
if (windows.length === 0) return [];
return [
{
key: entry.id,
icon: entry.icon ?? null,
hasError: entry.report.status === "error",
windows,
},
];
return [{ key: entry.id, icon: entry.icon ?? null, windows }];
});
}
+3 -4
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { displayPercent, resolveUsageSnapshot } from "./model";
import { displayPercent } from "./model";
import type { UsageReportEntry } from "./types";
/** Whether percentages read as the share used or the share left. */
@@ -55,9 +55,8 @@ export function effectiveUsagePins(
if (preferences.pins !== null) return preferences.pins;
const pins: UsagePin[] = [];
for (const entry of reports) {
const snapshot = resolveUsageSnapshot(entry);
if (!snapshot) continue;
const withPercent = snapshot.report.windows.filter(
if (entry.report.status !== "available") continue;
const withPercent = entry.report.windows.filter(
(window) => displayPercent(window, preferences.displayAs) !== null,
);
const marked = withPercent.filter((window) => window.summary);
+6 -38
View File
@@ -8,7 +8,6 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react-native";
import {
Pressable,
Text,
@@ -16,8 +15,7 @@ import {
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { StyleSheet } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppVisible } from "@/hooks/use-app-visible";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
@@ -37,9 +35,6 @@ 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();
@@ -65,14 +60,7 @@ 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}
errorLabel={t("providerUsage.refreshFailed")}
onPress={openUsage}
/>
);
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
}
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
@@ -107,15 +95,9 @@ export function useOpenSidebarUsage(): () => void {
return openUsage;
}
function pinnedUsageLabel(
label: string,
sources: readonly PinnedUsageSource[],
errorLabel: string,
): string {
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
const windows = sources.flatMap((source) => source.windows);
const summary = windows.map((window) => window.label).join(", ");
const error = sources.some((source) => source.hasError) ? `, ${errorLabel}` : "";
return `${label}: ${summary}${error}`;
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
}
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
@@ -134,12 +116,10 @@ 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);
@@ -164,7 +144,7 @@ function PinnedUsageTrigger({
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={pinnedUsageLabel(label, sources, errorLabel)}
accessibilityLabel={pinnedUsageLabel(label, sources)}
style={triggerStyle}
testID="sidebar-usage"
>
@@ -205,17 +185,10 @@ function PinnedUsageLine({
return sources.map((source, sourceIndex) => (
<Fragment key={source.key}>
<View
style={[styles.source, sourceIndex === 0 ? null : styles.sourceGap]}
style={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;
@@ -278,11 +251,6 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
},
source: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
sourceGap: {
marginLeft: theme.spacing[3],
},
@@ -184,55 +184,6 @@ 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,4 +1,3 @@
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 {
@@ -10,7 +9,6 @@ import {
interface CheckoutStatusForOpenTarget {
isGit: boolean;
remoteUrl?: string | null;
forgeWeb?: ForgeWeb | null;
currentBranch?: string | null;
}
@@ -120,7 +118,6 @@ function buildForgeWebUrl(
forge: Forge,
input: {
remoteUrl: string | null | undefined;
forgeWeb: ForgeWeb | null | undefined;
branch: string | null | undefined;
path: string | null;
lineStart?: number;
@@ -132,7 +129,6 @@ function buildForgeWebUrl(
return (
presentation.buildBlobUrl?.({
remoteUrl: input.remoteUrl,
forgeWeb: input.forgeWeb,
branch: input.branch,
path: input.path,
lineStart: input.lineStart,
@@ -143,7 +139,6 @@ function buildForgeWebUrl(
return (
presentation.buildBranchTreeUrl?.({
remoteUrl: input.remoteUrl,
forgeWeb: input.forgeWeb,
branch: input.branch,
}) ?? null
);
@@ -164,7 +159,6 @@ 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,11 +306,6 @@ 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;
}
+1 -11
View File
@@ -393,7 +393,6 @@ 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") {
@@ -427,17 +426,8 @@ describe("lifecycle hook SDK types", () => {
const { metadata, ...withoutMetadata } = request;
return withoutMetadata;
});
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
const { agent, outcome } = event;
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
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,22 +121,6 @@ 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.
@@ -230,9 +214,6 @@ 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,8 +18,6 @@ export type {
PluginSidebarViewProps,
PluginSidebarViewContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemProps,
PluginWorkspaceRowBadgeItemContribution,
PluginIcon,
PluginPanelLocation,
PluginOpenPanelOptions,
-13
View File
@@ -173,19 +173,6 @@ 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;
+6 -6
View File
@@ -2,6 +2,7 @@ import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
JsonValue,
} from "@getpaseo/protocol/agent-types";
@@ -103,13 +104,12 @@ export interface PluginHookEvents {
"agent.turn.after": {
agent: PluginHookAgent;
turnId: string | null;
/**
* 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`.
*/
outcome: PluginTurnOutcome;
/**
* @deprecated Always empty. Fetch history on demand with
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
*/
timeline: readonly AgentTimelineItem[];
};
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission.after": {
-20
View File
@@ -108,26 +108,6 @@ 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,22 +4328,6 @@ 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(),
@@ -4480,8 +4464,6 @@ 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(),
@@ -5715,7 +5697,6 @@ 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,15 +1251,6 @@ 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,6 +13334,7 @@ test.each([
},
turnId: "hook-turn",
outcome,
timeline: [],
},
},
]);
@@ -1,7 +1,6 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import { omitNullArgs } from "../tool-call-args.js";
interface BashToolInput {
command: string;
@@ -325,7 +324,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToo
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(omitNullArgs(rawArgs));
const parsed = schema.safeParse(rawArgs);
if (parsed.success) {
return {
kind: toolName as SimpleToolKind,
@@ -2,7 +2,6 @@ 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;
@@ -332,7 +331,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedTool
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(omitNullArgs(rawArgs));
const parsed = schema.safeParse(rawArgs);
if (parsed.success) {
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
}
@@ -1,18 +0,0 @@
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 });
});
});
@@ -1,9 +0,0 @@
// 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,52 +1,13 @@
import { describe, expect, test } from "vitest";
import { CheckoutPrStatusSchema, CheckoutStatusResponseSchema } from "@getpaseo/protocol/messages";
import { CheckoutPrStatusSchema } 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,7 +69,6 @@ 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,
@@ -92,7 +91,6 @@ 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,
+34 -113
View File
@@ -12,6 +12,7 @@ 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";
@@ -26,16 +27,6 @@ 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;
@@ -204,47 +195,20 @@ async function readComparisonList(
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
},
};
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) 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,
const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles(
compare,
args,
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;
}
);
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
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)
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({
@@ -273,15 +237,7 @@ async function readComparisonList(
const result =
file.source.kind === "untracked"
? await runGitCommand(
[
"diff",
"--no-index",
"--no-textconv",
...GIT_PATCH_FORMAT_ARGS,
"--",
"/dev/null",
file.entry.path,
],
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
{
...options,
acceptExitCodes: [0, 1],
@@ -337,11 +293,7 @@ async function readTrackedFiles(
compare: Compare,
args: string[],
options: GitCommandOptions,
): Promise<{
files: ListedFile[];
worktreeInputs: Map<string, WorktreeInput>;
interpretationPaths: Set<string>;
}> {
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; 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),
@@ -351,7 +303,7 @@ async function readTrackedFiles(
const stats = parseStats(stat.stdout);
const fields = raw.stdout.split("\0");
const files: ListedFile[] = [];
const worktreeInputs = new Map<string, WorktreeInput>();
const worktreeFiles = new Set<string>();
const interpretationPaths = new Set<string>();
for (let index = 0; index < fields.length - 1; index++) {
const header = fields[index].split(" ");
@@ -373,25 +325,10 @@ async function readTrackedFiles(
else if (code === "D") change = "deleted";
else if (oldPath !== null) change = "renamed";
let identity = header.join(" ");
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 = "";
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);
}
files.push({
@@ -408,32 +345,24 @@ async function readTrackedFiles(
source: { kind: "tracked" },
});
}
return { files, worktreeInputs, interpretationPaths };
return { files, worktreeFiles, interpretationPaths };
}
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)}` };
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);
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
const submodule = await runGitCommand(
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
{ cwd: absolutePath, envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
);
return { hashed: false, literal: `submodule:${submodule.stdout}` };
identity += `:${submodule.stdout}`;
}
return { hashed: value.isFile() && text, literal: null };
return { identity, regular: value.isFile() };
}
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
@@ -456,12 +385,9 @@ async function readUntrackedText(
return readFile(path);
}
async function listUntrackedFiles(
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeInputs: Map<string, WorktreeInput> }> {
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
const cwd = options.cwd;
const files: ListedFile[] = [];
const worktreeInputs = new Map<string, WorktreeInput>();
const untracked = await runGitCommand(
["ls-files", "--others", "--exclude-standard", "-z"],
options,
@@ -482,12 +408,6 @@ async function listUntrackedFiles(
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,
@@ -498,13 +418,14 @@ async function listUntrackedFiles(
deletions: 0,
body: !binary,
},
identity: "",
identity: fileStatIdentity(value),
source: { kind: "untracked", size: Number(value.size) },
});
}
return { files, worktreeInputs };
return files;
}
function absentBlob(value: string): string | null {
return /^0+$/.test(value) ? null : value;
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}`;
}
+7 -15
View File
@@ -9,20 +9,12 @@ 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.
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));
export async function readGitInputs(
paths: string[],
worktreeFiles: Set<string>,
options: GitCommandOptions,
) {
const regular = paths.filter((path) => worktreeFiles.has(path));
const [interpretation, blobs] = await Promise.all([
readInterpretation(paths, options),
regular.length
@@ -42,7 +34,7 @@ export async function readGitInputs({
fingerprint([
interpretation.config,
interpretation.attributes.get(path) ?? [],
blobByPath.get(path) ?? literalContent.get(path) ?? null,
blobByPath.get(path) ?? null,
]),
]),
);
@@ -530,222 +530,6 @@ 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
@@ -1,79 +0,0 @@
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,14 +163,8 @@ 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, 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.turn.after", async (event) => {
console.log(JSON.stringify({ hook: "agent.turn.after", event }));
});
server.addHook("agent.permission.before", async (event, context) => {
console.log(JSON.stringify({ hook: "agent.permission.before", event }));
@@ -226,7 +220,6 @@ 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,7 +157,8 @@ 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);
expect(await latestReplyText(client, agent.id)).toBe("RETRY_OK");
const retryTurns = await endedTurns(client, agent.id);
expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK");
proof.followup = "RETRY_OK";
await client.sendMessage(
@@ -206,7 +207,8 @@ 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);
expect(await latestReplyText(client, agent.id)).toBe("created|opened");
const envTurns = await endedTurns(client, agent.id);
expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened");
proof.environmentOutput = "created|opened";
await client.refreshAgent(agent.id);
@@ -330,16 +332,6 @@ 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,18 +195,21 @@ 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: wireEvent(name, event),
input: event,
})
.then(() => undefined)
.catch((error) => {
@@ -1482,11 +1482,3 @@ 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,7 +6184,6 @@ 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,45 +6619,6 @@ 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,7 +17,6 @@ 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";
@@ -1020,556 +1019,3 @@ 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,8 +264,6 @@ 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" } } : {}),
};
}
@@ -706,9 +704,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
await refresh;
// 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, {
expect(await service.getSnapshot(REPO_CWD)).toEqual(
createSnapshot(REPO_CWD, {
git: {
currentBranch: "feature",
diffStat: { additions: 4, deletions: 2 },
@@ -719,8 +716,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
error: null,
},
}),
forgeWeb: { host: "github.com" },
});
);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
@@ -93,8 +93,6 @@ 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 { ForgeWeb, ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { CheckoutContext } from "../utils/checkout-git.js";
import {
@@ -71,12 +71,6 @@ 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";
@@ -206,8 +200,6 @@ export interface WorkspaceGitRuntimeSnapshot {
} | null;
error: { message: string } | null;
};
/** Absent when the remote resolves to no forge. */
forgeWeb?: ForgeWeb;
}
export interface WorkspaceGitService {
@@ -414,8 +406,6 @@ 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: (
@@ -469,11 +459,6 @@ 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;
@@ -662,7 +647,6 @@ 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>();
@@ -707,7 +691,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
);
this.forgeResolver = createForgeResolver({
createService: (forge) => this.deps.forgeOverrides?.[forge] ?? createForgeService(forge),
resolveSshHostname: this.deps.resolveSshHostname,
});
}
@@ -1157,11 +1140,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 and the parent lookups they start.
this.disposePromise = (async () => {
await Promise.all([this.fileObserver.close(), Promise.allSettled(this.pendingRefreshes)]);
await Promise.allSettled(this.pendingForgeWebLookups);
})();
// Stop admission first, then drain those refreshes before releasing the service.
this.disposePromise = Promise.all([
this.fileObserver.close(),
Promise.allSettled(this.pendingRefreshes),
]).then(() => undefined);
return this.disposePromise;
}
@@ -1318,9 +1301,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
latestGit: null,
latestGitLoadedAtMs: null,
latestForge: null,
latestForgeWeb: null,
forgeWebLookupEntry: null,
forgeRefreshedForRemote: null,
latestForgeLoadedAtMs: null,
latestSnapshot: null,
latestSnapshotLoadedAtMs: null,
@@ -3588,15 +3568,6 @@ 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);
@@ -3698,7 +3669,6 @@ 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;
}
@@ -3743,15 +3713,6 @@ 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,
@@ -3759,15 +3720,13 @@ 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 || !remoteUrl) {
if (!resolution) {
target.latestForge = buildUnresolvedRemoteForgeSnapshot(remoteUrl);
target.latestForgeWeb = null;
target.latestForgeLoadedAtMs = this.deps.now().getTime();
return;
}
@@ -3790,16 +3749,7 @@ 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 {
@@ -3811,11 +3761,6 @@ 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 }
: {}),
};
}
@@ -3855,68 +3800,6 @@ 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,13 +467,6 @@ 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,41 +429,6 @@ 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,14 +247,6 @@ 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(),
@@ -2015,19 +2007,6 @@ 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,65 +528,6 @@ 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();
});
+11 -72
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import {
isGitHubHost,
parseGitHubRemoteIdentity,
parseGitHubRemoteUrl,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
@@ -960,11 +959,6 @@ 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 {
@@ -1056,7 +1050,6 @@ 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>;
@@ -1139,7 +1132,6 @@ 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`
@@ -2551,26 +2543,6 @@ 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,
@@ -3252,53 +3224,20 @@ async function resolveGitHubSlugFromOrigin(cwd: string): Promise<string | null>
return parseGitHubRemoteUrl(stdout.trim())?.repo ?? null;
}
async function resolveOriginRepositoryPath(cwd: string): Promise<string | null> {
let stdout: string;
try {
({ 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 {
async function getGitHubRepoView(options: {
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> {
}): Promise<z.infer<typeof GitHubRepoViewSchema> | null> {
const args = ["repo", "view", "--json", "owner,name,parent"];
try {
return await readGitHubRepoView(options);
const stdout = await options.run(args, {
cwd: options.cwd,
});
return parseGitHubJsonOutput(stdout, GitHubRepoViewSchema, {
args,
cwd: options.cwd,
emptyFallback: "{}",
});
} 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,38 +260,6 @@ 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,16 +249,6 @@ 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>;
@@ -1161,20 +1151,6 @@ 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,9 +23,8 @@ 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: 1, wholeTurns: true });
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
const text = latestOutputText(turn.map(({ item }) => item));
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.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.");

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