mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 12:54:58 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781cb8d5b0 | ||
|
|
116b0a9190 | ||
|
|
5a7a60ae50 | ||
|
|
dd06bc0833 | ||
|
|
2eba291a8e | ||
|
|
96a143efe9 | ||
|
|
fe59a6c1a2 | ||
|
|
a5015dba80 | ||
|
|
da3be528e9 | ||
|
|
dc2dfdab93 | ||
|
|
fee32c9594 | ||
|
|
5b1baa0e72 |
+1
-1
@@ -136,7 +136,7 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand. Settings rows that share a menu surface with menu rows, such as Usage's options, go inside `<MenuSettingsRows>`, which puts their labels on the menu rows' label rail (`menuRowContentInset`).
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
|
||||
+4
-3
@@ -873,9 +873,10 @@ await paseo.agents.ref(agentId).timeline.append({
|
||||
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
|
||||
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
|
||||
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
|
||||
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
|
||||
be rendered intact. The daemon advertises this RPC through
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. `kind` must match
|
||||
`/^[a-z][a-z0-9-]*$/`, the rule `addTimelineRenderer` enforces; the daemon rejects any other kind.
|
||||
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
|
||||
payload that cannot be rendered intact. The daemon advertises this RPC through
|
||||
`server_info.features.pluginTimelineItems`.
|
||||
|
||||
## Contribute client slash commands
|
||||
|
||||
@@ -20,6 +20,21 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
|
||||
const LAYOUT_VIEWPORTS = {
|
||||
compact: { width: 390, height: 844 },
|
||||
wide: { width: 1280, height: 900 },
|
||||
};
|
||||
|
||||
// The previous layout stays on screen for a few renders after the resize. Wait for the shell to
|
||||
// switch so the next step reads the layout it resized to.
|
||||
async function resizeToLayout(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
layout: keyof typeof LAYOUT_VIEWPORTS,
|
||||
) {
|
||||
await page.setViewportSize(LAYOUT_VIEWPORTS[layout]);
|
||||
await expect(page.getByTestId(`${layout}-shell`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerActiveTabForeground(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
) {
|
||||
@@ -244,7 +259,7 @@ test("tablet prototype keeps Explorer tabs when resizing down to a phone", async
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
|
||||
await openExplorerAgentDraft(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await revealPhoneExplorer(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
|
||||
@@ -289,10 +304,10 @@ test("retains the Explorer host through appearance changes and folding", async (
|
||||
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(previousColor);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
await expect(explorer).toBeVisible();
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
} finally {
|
||||
@@ -327,7 +342,7 @@ test("reorders compact Explorer tabs with a pointer without changing selection",
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await openFilesPanel(page);
|
||||
await reorderCompactExplorerTabs(page);
|
||||
await expect(
|
||||
@@ -394,9 +409,9 @@ test("updates populated code and diff appearance without losing the editor insta
|
||||
.getByTestId("file-source-editor")
|
||||
.elementHandle();
|
||||
await text.fill(`${source}\n// retained Explorer edit`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("retained Explorer edit");
|
||||
} finally {
|
||||
@@ -461,9 +476,9 @@ test("retains populated Explorer chat and terminal attachments through appearanc
|
||||
const terminalNode = await terminal.elementHandle();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
expect(attaches).toHaveLength(1);
|
||||
@@ -483,7 +498,7 @@ test("compact file sheets cover Explorer and return to its retained file list",
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
|
||||
@@ -160,6 +160,22 @@ test.describe("New workspace entry points", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Ctrl+N opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await page.goBack();
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page, "Control+N");
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { gate } from "../support/helpers/context-window";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
closeUsageOptions,
|
||||
pickUsageHost,
|
||||
usageModalHost,
|
||||
dragUsageCardAbove,
|
||||
expectUsageCardOrder,
|
||||
expectUsageOptionsFitContent,
|
||||
@@ -96,10 +99,6 @@ async function qaScreenshot(page: Page, name: string, target: "page" | "summary"
|
||||
await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
function hostFilter(page: Page) {
|
||||
return page.locator('[data-testid="usage-host-filter-trigger"]:visible');
|
||||
}
|
||||
|
||||
function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
return {
|
||||
id: `${sourceId}:a`,
|
||||
@@ -154,7 +153,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
|
||||
}
|
||||
|
||||
test.describe("usage modal", () => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
test("opens from the sidebar on the host's reports, with no host named for one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -194,7 +193,7 @@ test.describe("usage modal", () => {
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(hostFilter(page)).toHaveCount(0);
|
||||
await expect(page.getByTestId("usage-modal-host")).toHaveCount(0);
|
||||
await qaScreenshot(page, "usage-modal-one-host");
|
||||
await expect(group.getByText("31% used")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
@@ -377,9 +376,11 @@ test.describe("usage modal", () => {
|
||||
);
|
||||
try {
|
||||
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
|
||||
// The second host's reports wait until the test lets them through, like a slow host.
|
||||
const secondaryReports = gate();
|
||||
await installUsageReportsFixture(page, {
|
||||
port: secondary.port,
|
||||
lists: [[weeklyReport("beta", 12)]],
|
||||
lists: [{ stream: [secondaryReports.promise, weeklyReport("beta", 12)] }],
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
@@ -393,12 +394,15 @@ test.describe("usage modal", () => {
|
||||
await openUsageFromItem(page);
|
||||
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
||||
|
||||
await hostFilter(page).click();
|
||||
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await expect(usageModalHost(page)).toHaveText("localhost");
|
||||
await pickUsageHost(page, secondary.serverId);
|
||||
// Until its reports arrive, the picked host loads; it never shows the first host's cards.
|
||||
const secondaryBody = page.getByTestId(`usage-host-${secondary.serverId}`);
|
||||
await expect(secondaryBody.getByText("Loading usage...")).toBeVisible();
|
||||
await expect(page.getByTestId("usage-modal-body").getByText("31% used")).toHaveCount(0);
|
||||
await expect(usageModalHost(page)).toHaveText("Secondary box");
|
||||
secondaryReports.open();
|
||||
await expect(secondaryBody.getByText("12% used")).toBeVisible({ timeout: 30_000 });
|
||||
await qaScreenshot(page, "usage-modal-picked-host");
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
|
||||
@@ -417,6 +421,51 @@ test.describe("usage modal", () => {
|
||||
await secondary.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("on a phone, picking a host in Settings keeps Usage open on that host", async ({ page }) => {
|
||||
test.setTimeout(420_000);
|
||||
const longName = "build-box-eu-west-gpu-runner-02.internal";
|
||||
const secondary = await startIsolatedHostDaemon(
|
||||
`srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
|
||||
);
|
||||
try {
|
||||
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
|
||||
await installUsageReportsFixture(page, {
|
||||
port: secondary.port,
|
||||
lists: [[weeklyReport("beta", 12)]],
|
||||
});
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondary.serverId,
|
||||
label: longName,
|
||||
port: secondary.port,
|
||||
});
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromItem(page);
|
||||
|
||||
await pickUsageHost(page, secondary.serverId);
|
||||
|
||||
// The sheet stays up once Settings has closed over it, showing the picked host's cards.
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(usageModalHost(page)).toHaveText(longName);
|
||||
// The long name truncates under the title; the title itself reads in full.
|
||||
const title = page.locator('[data-testid="usage-modal-title"]:visible');
|
||||
await expect(title).toHaveText("Usage");
|
||||
expect(await title.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
|
||||
true,
|
||||
);
|
||||
const host = usageModalHost(page);
|
||||
expect(await host.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(
|
||||
true,
|
||||
);
|
||||
await qaScreenshot(page, "usage-phone-picked-long-host");
|
||||
} finally {
|
||||
await secondary.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const loginWindows: UsageReportEntry["report"] = {
|
||||
|
||||
@@ -318,10 +318,11 @@ test.describe("Usage item", () => {
|
||||
|
||||
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
// One host: no host filter, as on History.
|
||||
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
// One host: Settings has no Host row, and the title no host under it.
|
||||
await expect(page.getByTestId("usage-modal-host")).toHaveCount(0);
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-options-host")).toHaveCount(0);
|
||||
await closeUsageOptions(page);
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
|
||||
@@ -232,8 +232,11 @@ export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<voi
|
||||
});
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Control+P");
|
||||
export async function openNewWorkspaceProjectPickerWithShortcut(
|
||||
page: Page,
|
||||
shortcut = "Control+P",
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(shortcut);
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search projects");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -317,6 +317,19 @@ export async function openUsageOptions(page: Page): Promise<void> {
|
||||
await expect(visible(page, "usage-display-used")).toBeVisible();
|
||||
}
|
||||
|
||||
/** Picks the host Usage shows, from the Host row of its Settings. */
|
||||
export async function pickUsageHost(page: Page, serverId: string): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await visible(page, "usage-options-host").click();
|
||||
await visible(page, `usage-options-host-${serverId}`).click();
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The host Usage shows, under its title; absent with a single host. */
|
||||
export function usageModalHost(page: Page): Locator {
|
||||
return visible(page, "usage-modal-host");
|
||||
}
|
||||
|
||||
export function usageOptionsSurface(page: Page): Locator {
|
||||
return page
|
||||
.locator(
|
||||
|
||||
@@ -91,10 +91,13 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const explorer = explorerSidebar(page);
|
||||
if ((await explorer.count()) === 0) {
|
||||
// Decide from the toggle, which reports the open state. The sidebar's own visibility lags it:
|
||||
// a compact layout draws its closed Explorer for a frame before hiding it.
|
||||
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
const explorer = explorerSidebar(page);
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
}
|
||||
|
||||
@@ -139,6 +139,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
subtitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
headerTrailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -421,6 +425,15 @@ function SheetHeaderBackButton({ back }: { back: SheetHeaderBack }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A plain-text header subtitle: one muted line under the title that truncates, never the title. */
|
||||
export function SheetHeaderSubtitle({ text, testID }: { text: string; testID?: string }) {
|
||||
return (
|
||||
<Text style={styles.subtitle} numberOfLines={1} testID={testID}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetHeaderView({
|
||||
header,
|
||||
onClose,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { View } from "react-native";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { MenuItem } from "@/components/ui/menu";
|
||||
|
||||
interface HostMenuItemProps {
|
||||
serverId: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onSelect: (serverId: string) => void;
|
||||
/** Off for a multi-select page, where choosing a host is one of several toggles. */
|
||||
closeOnSelect?: boolean;
|
||||
testID?: string;
|
||||
statusTestID?: string;
|
||||
}
|
||||
|
||||
/** A host as a menu option: its live status dot leads, and the chosen host carries the check. */
|
||||
export function HostMenuItem({
|
||||
serverId,
|
||||
label,
|
||||
selected,
|
||||
onSelect,
|
||||
closeOnSelect = true,
|
||||
testID,
|
||||
statusTestID,
|
||||
}: HostMenuItemProps): ReactElement {
|
||||
const handleSelect = useCallback(() => onSelect(serverId), [onSelect, serverId]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<View testID={statusTestID}>
|
||||
<HostStatusDot serverId={serverId} />
|
||||
</View>
|
||||
),
|
||||
[serverId, statusTestID],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={selected}
|
||||
closeOnSelect={closeOnSelect}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
testID={testID}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export { SettingsSection } from "./headings/settings-section";
|
||||
export { SettingsCollapsibleRow } from "./collapsible-row";
|
||||
export { SettingsDetailRow } from "./detail-row";
|
||||
export { SettingsInfoTip } from "./headings/settings-info-tip";
|
||||
export { MenuSettingsRows } from "./row-density";
|
||||
|
||||
/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */
|
||||
interface AppRowExtras {
|
||||
|
||||
@@ -2,16 +2,27 @@ import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { CONTROL_HEIGHTS, mapLayout, type ControlSize } from "@/components/ui/control-geometry";
|
||||
import { menuRowContentInset } from "@/components/ui/menu";
|
||||
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
|
||||
// of the card: tighter padding, smaller text, smaller controls. The container decides; a row
|
||||
// never knows on its own that it is nested.
|
||||
const NestedRowsContext = createContext(false);
|
||||
// The container decides how its rows lay out; a row never knows on its own where it is.
|
||||
//
|
||||
// - card: the rows of a settings card.
|
||||
// - nested: rows revealed under a collapsible row belong to it, so they sit one step smaller than
|
||||
// the rows of the card: tighter padding, smaller text, smaller controls.
|
||||
// - menu: rows that share a menu surface with menu rows put their labels on the menu's label rail.
|
||||
type RowPlacement = "card" | "nested" | "menu";
|
||||
|
||||
const RowPlacementContext = createContext<RowPlacement>("card");
|
||||
|
||||
export function NestedSettingsRows({ children }: { children: ReactNode }) {
|
||||
return <NestedRowsContext.Provider value>{children}</NestedRowsContext.Provider>;
|
||||
return <RowPlacementContext.Provider value="nested">{children}</RowPlacementContext.Provider>;
|
||||
}
|
||||
|
||||
/** Settings rows rendered in a menu surface, alongside menu rows. */
|
||||
export function MenuSettingsRows({ children }: { children: ReactNode }) {
|
||||
return <RowPlacementContext.Provider value="menu">{children}</RowPlacementContext.Provider>;
|
||||
}
|
||||
|
||||
export interface SettingsRowLayout {
|
||||
@@ -22,26 +33,36 @@ export interface SettingsRowLayout {
|
||||
controlSize: ControlSize;
|
||||
}
|
||||
|
||||
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
|
||||
function layoutFor(placement: RowPlacement): SettingsRowLayout {
|
||||
switch (placement) {
|
||||
case "nested":
|
||||
return {
|
||||
row: [settingsStyles.row, styles.nestedRow],
|
||||
title: [settingsStyles.rowTitle, styles.nestedText],
|
||||
hint: [settingsStyles.rowHint, styles.nestedText],
|
||||
controlSize: "sm",
|
||||
};
|
||||
case "menu":
|
||||
return {
|
||||
row: [settingsStyles.row, styles.menuRow],
|
||||
title: settingsStyles.rowTitle,
|
||||
hint: settingsStyles.rowHint,
|
||||
controlSize: "md",
|
||||
};
|
||||
case "card":
|
||||
return {
|
||||
row: settingsStyles.row,
|
||||
title: settingsStyles.rowTitle,
|
||||
hint: settingsStyles.rowHint,
|
||||
controlSize: "md",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** How a row lays out where it is rendered: in a card, nested under a collapsible row, or in a menu. */
|
||||
export function useSettingsRowLayout(): SettingsRowLayout {
|
||||
const nested = useContext(NestedRowsContext);
|
||||
return useMemo(
|
||||
() =>
|
||||
nested
|
||||
? {
|
||||
row: [settingsStyles.row, styles.nestedRow],
|
||||
title: [settingsStyles.rowTitle, styles.nestedText],
|
||||
hint: [settingsStyles.rowHint, styles.nestedText],
|
||||
controlSize: "sm",
|
||||
}
|
||||
: {
|
||||
row: settingsStyles.row,
|
||||
title: settingsStyles.rowTitle,
|
||||
hint: settingsStyles.rowHint,
|
||||
controlSize: "md",
|
||||
},
|
||||
[nested],
|
||||
);
|
||||
const placement = useContext(RowPlacementContext);
|
||||
return useMemo(() => layoutFor(placement), [placement]);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -54,4 +75,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
nestedText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
menuRow: {
|
||||
paddingHorizontal: menuRowContentInset(theme),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type PressableStateCallbackType } from "react-native";
|
||||
import type { PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Captions,
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
MenuTrigger,
|
||||
type MenuPageDefinition,
|
||||
} from "@/components/ui/menu";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { HostMenuItem } from "@/components/hosts/host-menu-item";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
@@ -813,53 +813,21 @@ function HostFilterPage({
|
||||
{t("sidebar.display.hostFilter.all")}
|
||||
</MenuItem>
|
||||
{hosts.map((host) => (
|
||||
<HostFilterItem
|
||||
<HostMenuItem
|
||||
key={host.serverId}
|
||||
serverId={host.serverId}
|
||||
label={host.label?.trim() || host.serverId}
|
||||
selected={preferences.hostFilters.includes(host.serverId)}
|
||||
onToggle={preferences.toggleHostFilter}
|
||||
onSelect={preferences.toggleHostFilter}
|
||||
closeOnSelect={false}
|
||||
testID={`sidebar-host-filter-${host.serverId}`}
|
||||
statusTestID={`sidebar-host-filter-status-${host.serverId}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The one option row whose mark is live state rather than an icon. */
|
||||
function HostFilterItem({
|
||||
serverId,
|
||||
label,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
serverId: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onToggle: (serverId: string) => void;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(() => onToggle(serverId), [onToggle, serverId]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<View testID={`sidebar-host-filter-status-${serverId}`}>
|
||||
<HostStatusDot serverId={serverId} />
|
||||
</View>
|
||||
),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
selected={selected}
|
||||
closeOnSelect={false}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
testID={`sidebar-host-filter-${serverId}`}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 28,
|
||||
|
||||
@@ -2210,6 +2210,7 @@ export const ar: TranslationResources = {
|
||||
turnOn: "تشغيل",
|
||||
|
||||
options: "الإعدادات",
|
||||
host: "المضيف",
|
||||
refreshFailed: "تعذّر تحديث الاستخدام",
|
||||
noHosts: "لا توجد مضيفات متصلة",
|
||||
agentError: "تعذّر تحميل الاستخدام: {{reason}}",
|
||||
|
||||
@@ -2236,6 +2236,7 @@ export const en = {
|
||||
turnOn: "Turn on",
|
||||
|
||||
options: "Settings",
|
||||
host: "Host",
|
||||
refreshFailed: "Unable to refresh usage",
|
||||
noHosts: "No connected hosts",
|
||||
agentError: "Unable to load usage: {{reason}}",
|
||||
|
||||
@@ -2259,6 +2259,7 @@ export const es: TranslationResources = {
|
||||
turnOn: "Activar",
|
||||
|
||||
options: "Configuración",
|
||||
host: "Host",
|
||||
refreshFailed: "No se pudo actualizar el uso",
|
||||
noHosts: "No hay hosts conectados",
|
||||
agentError: "No se pudo cargar el uso: {{reason}}",
|
||||
|
||||
@@ -2260,6 +2260,7 @@ export const fr: TranslationResources = {
|
||||
turnOn: "Activer",
|
||||
|
||||
options: "Paramètres",
|
||||
host: "Hôte",
|
||||
refreshFailed: "Impossible d’actualiser l’utilisation",
|
||||
noHosts: "Aucun hôte connecté",
|
||||
agentError: "Impossible de charger l’utilisation : {{reason}}",
|
||||
|
||||
@@ -2233,6 +2233,7 @@ export const ja: TranslationResources = {
|
||||
turnOn: "オンにする",
|
||||
|
||||
options: "設定",
|
||||
host: "ホスト",
|
||||
refreshFailed: "使用状況を更新できません",
|
||||
noHosts: "接続中のホストがありません",
|
||||
agentError: "使用状況を読み込めません: {{reason}}",
|
||||
|
||||
@@ -2221,6 +2221,7 @@ export const ko: TranslationResources = {
|
||||
turnOn: "켜기",
|
||||
|
||||
options: "설정",
|
||||
host: "호스트",
|
||||
refreshFailed: "사용량을 새로 고칠 수 없습니다",
|
||||
noHosts: "연결된 호스트가 없습니다",
|
||||
agentError: "사용량을 불러올 수 없습니다: {{reason}}",
|
||||
|
||||
@@ -2244,6 +2244,7 @@ export const ptBR: TranslationResources = {
|
||||
turnOn: "Ativar",
|
||||
|
||||
options: "Configurações",
|
||||
host: "Host",
|
||||
refreshFailed: "Não foi possível atualizar o uso",
|
||||
noHosts: "Nenhum host conectado",
|
||||
agentError: "Não foi possível carregar o uso: {{reason}}",
|
||||
|
||||
@@ -2245,6 +2245,7 @@ export const ru: TranslationResources = {
|
||||
turnOn: "Включить",
|
||||
|
||||
options: "Настройки",
|
||||
host: "Хост",
|
||||
refreshFailed: "Не удалось обновить использование",
|
||||
noHosts: "Нет подключённых хостов",
|
||||
agentError: "Не удалось загрузить использование: {{reason}}",
|
||||
|
||||
@@ -2187,6 +2187,7 @@ export const zhCN: TranslationResources = {
|
||||
turnOn: "开启",
|
||||
|
||||
options: "设置",
|
||||
host: "主机",
|
||||
refreshFailed: "无法刷新用量",
|
||||
noHosts: "没有已连接的主机",
|
||||
agentError: "无法加载用量:{{reason}}",
|
||||
|
||||
@@ -2149,6 +2149,68 @@ describe("HostRuntimeController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("wakes an active routed client before an unrelated direct probe settles", async () => {
|
||||
useHostRuntimeClock();
|
||||
const route: HostConnection = {
|
||||
id: "via:srv_parent",
|
||||
type: "routed",
|
||||
originServerId: "srv_parent",
|
||||
};
|
||||
const host = makeHost({ connections: [route] });
|
||||
const client = makeConnectedProbeClient(5);
|
||||
const directProbeStarted = createDeferred<void>();
|
||||
const directProbe = createDeferred<void>();
|
||||
let directProbeCalls = 0;
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: {
|
||||
getClientId: async () => "blocked-direct-probe",
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ connection }) => {
|
||||
if (connection.type === "directTcp") {
|
||||
directProbeCalls += 1;
|
||||
directProbeStarted.resolve();
|
||||
await directProbe.promise;
|
||||
throw new Error("Direct connection unavailable");
|
||||
}
|
||||
return {
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
await controller.start({ autoProbe: false });
|
||||
client.setConnectionState({
|
||||
status: "disconnected",
|
||||
reason: "origin offline",
|
||||
nextRetryAt: new Date(Date.now() + 12000).toISOString(),
|
||||
});
|
||||
const probing = controller.updateHost({
|
||||
...host,
|
||||
connections: [
|
||||
route,
|
||||
{ id: "direct:unreachable", type: "directTcp", endpoint: "unreachable:6767" },
|
||||
],
|
||||
});
|
||||
await directProbeStarted.promise;
|
||||
const recovering = controller.reconnectVia("srv_parent");
|
||||
try {
|
||||
expect(client.ensureConnectedCalls).toBe(1);
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
||||
expect(directProbeCalls).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(client.ensureConnectedCalls).toBe(1);
|
||||
expect(directProbeCalls).toBe(1);
|
||||
} finally {
|
||||
directProbe.resolve();
|
||||
await Promise.all([probing, recovering]);
|
||||
await controller.stop();
|
||||
}
|
||||
expect(directProbeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps active client generation stable during background probe cycles", async () => {
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({
|
||||
@@ -2195,6 +2257,146 @@ describe("HostRuntimeController", () => {
|
||||
});
|
||||
|
||||
describe("HostRuntimeStore", () => {
|
||||
function gatewayRecoveryFixture() {
|
||||
const parent = new FakeDaemonClient();
|
||||
parent.connectedHosts = true;
|
||||
parent.hostDirectory = Promise.resolve({
|
||||
self: { serverId: "srv_parent", label: "parent" },
|
||||
hosts: ["srv_child", "srv_healthy"].map((serverId) => ({
|
||||
registrationId: `reg_${serverId}`,
|
||||
serverId,
|
||||
label: serverId,
|
||||
source: { id: "user", key: serverId },
|
||||
metadata: {},
|
||||
enabled: true,
|
||||
status: "online" as const,
|
||||
targetDescription: "relay",
|
||||
lastOnlineAt: null,
|
||||
nextRetryAt: null,
|
||||
error: null,
|
||||
})),
|
||||
});
|
||||
const parentReady = createDeferred<void>();
|
||||
const child = makeConnectedProbeClient(5);
|
||||
const healthy = makeConnectedProbeClient(5);
|
||||
const unrelated = makeConnectedProbeClient(5);
|
||||
const attempts = new Map<string, number>();
|
||||
const hosts = [
|
||||
makeHost({
|
||||
serverId: "srv_parent",
|
||||
connections: [
|
||||
{
|
||||
id: "relay:parent",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_parent",
|
||||
},
|
||||
],
|
||||
}),
|
||||
...["srv_child", "srv_healthy"].map((serverId) =>
|
||||
makeHost({
|
||||
serverId,
|
||||
connections: [
|
||||
{
|
||||
id: "via:srv_parent",
|
||||
type: "routed" as const,
|
||||
originServerId: "srv_parent",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
makeHost({
|
||||
serverId: "srv_unrelated",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:unrelated:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "unrelated:6767",
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage({
|
||||
"@paseo:e2e": "1",
|
||||
"@paseo:daemon-registry": JSON.stringify(hosts),
|
||||
}),
|
||||
deps: {
|
||||
getClientId: async () => "gateway-recovery",
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host, connection }) => {
|
||||
attempts.set(host.serverId, (attempts.get(host.serverId) ?? 0) + 1);
|
||||
let client: FakeDaemonClient;
|
||||
if (host.serverId === "srv_parent") {
|
||||
await parentReady.promise;
|
||||
parent.setConnectionState({ status: "connected" });
|
||||
client = parent;
|
||||
} else if (connection.type === "routed") {
|
||||
if (!parent.isConnected) throw new Error("Origin host srv_parent is unavailable");
|
||||
client = host.serverId === "srv_child" ? child : healthy;
|
||||
} else {
|
||||
client = unrelated;
|
||||
}
|
||||
return {
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
return { store, parent, parentReady, child, healthy, unrelated, attempts };
|
||||
}
|
||||
|
||||
it("retries saved child routes immediately when their relay gateway first connects", async () => {
|
||||
useHostRuntimeClock();
|
||||
const { store, parentReady, attempts } = gatewayRecoveryFixture();
|
||||
try {
|
||||
await store.boot();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(attempts.get("srv_child")).toBe(1);
|
||||
expect(store.getSnapshot("srv_child")?.connectionStatus).not.toBe("online");
|
||||
parentReady.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(store.getSnapshot("srv_parent")?.connectionStatus).toBe("online");
|
||||
expect(store.getSnapshot("srv_child")?.connectionStatus).toBe("online");
|
||||
expect(store.getSnapshot("srv_healthy")?.connectionStatus).toBe("online");
|
||||
expect(attempts.get("srv_child")).toBe(2);
|
||||
expect(attempts.get("srv_healthy")).toBe(2);
|
||||
expect(attempts.get("srv_unrelated")).toBe(1);
|
||||
} finally {
|
||||
store.syncHosts([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("wakes disconnected routed clients on gateway recovery without disturbing healthy hosts", async () => {
|
||||
useHostRuntimeClock();
|
||||
const { store, parent, parentReady, child, healthy, unrelated } = gatewayRecoveryFixture();
|
||||
try {
|
||||
parentReady.resolve();
|
||||
await store.boot();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(store.getSnapshot("srv_child")?.connectionStatus).toBe("online");
|
||||
parent.setConnectionState({ status: "disconnected", reason: "relay lost" });
|
||||
child.setConnectionState({
|
||||
status: "disconnected",
|
||||
reason: "origin offline",
|
||||
nextRetryAt: new Date(Date.now() + 12000).toISOString(),
|
||||
});
|
||||
parent.setConnectionState({ status: "connected" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(child.ensureConnectedCalls).toBe(1);
|
||||
expect(store.getSnapshot("srv_child")?.connectionStatus).toBe("online");
|
||||
expect(healthy.ensureConnectedCalls).toBe(0);
|
||||
expect(unrelated.ensureConnectedCalls).toBe(0);
|
||||
parent.setConnectionState({ status: "connected" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(child.ensureConnectedCalls).toBe(1);
|
||||
} finally {
|
||||
store.syncHosts([]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps B and C distinct with routed and direct candidates (B direct first: %s)",
|
||||
async (directFirst) => {
|
||||
|
||||
@@ -869,6 +869,22 @@ export class HostRuntimeController {
|
||||
this.activeClient?.ensureConnected(options);
|
||||
}
|
||||
|
||||
async reconnectVia(originServerId: string): Promise<void> {
|
||||
const routes = this.host.connections.filter(
|
||||
(connection) =>
|
||||
connection.type === "routed" &&
|
||||
connection.originServerId === originServerId &&
|
||||
!this.authRejectedConnectionIds.has(connection.id),
|
||||
);
|
||||
if (routes.length === 0 || !this.started || isHostRuntimeConnected(this.snapshot)) return;
|
||||
if (routes.some((route) => route.id === this.snapshot.activeConnectionId))
|
||||
this.ensureConnected();
|
||||
await this.probeCycleInFlight;
|
||||
if (!this.started || this.snapshot.connectionStatus === "online") return;
|
||||
for (const route of routes) this.connectionLastProbedAt.delete(route.id);
|
||||
await this.runProbeCycleNow();
|
||||
}
|
||||
|
||||
markAgentDirectorySyncLoading(): void {
|
||||
const status = this.snapshot.hasEverLoadedAgentDirectory ? "revalidating" : "initial_loading";
|
||||
this.updateSnapshot({
|
||||
@@ -2601,6 +2617,7 @@ export class HostRuntimeStore {
|
||||
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
||||
if (didTransitionOnline) {
|
||||
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
|
||||
for (const child of this.controllers.values()) void child.reconnectVia(serverId);
|
||||
// Checkout git data is push-driven; pushes emitted while disconnected are gone for
|
||||
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
|
||||
// queries refetch now and evicted ones on their next mount.
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/da
|
||||
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useIsFocused } from "expo-router/react-navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -188,7 +188,10 @@ function noopChangeAttachments() {}
|
||||
function noopClearDraft() {}
|
||||
|
||||
// Stable reference so the keyboard-action handler doesn't re-register each render.
|
||||
const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"];
|
||||
const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = [
|
||||
"workspace.project.pick",
|
||||
"workspace.new",
|
||||
];
|
||||
|
||||
interface WorkspaceIsolationState {
|
||||
isolation: "local" | "worktree";
|
||||
@@ -907,6 +910,7 @@ export function NewWorkspaceScreen({
|
||||
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
||||
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const projectPickHandlerId = useId();
|
||||
|
||||
// Launch target: what the composer submits to (chat agent, or a terminal
|
||||
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
|
||||
@@ -1181,19 +1185,18 @@ export function NewWorkspaceScreen({
|
||||
setProjectPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
// Cmd/Ctrl+P opens the project picker with its search focused so the user can
|
||||
// switch projects from the keyboard. Registered only while this screen is
|
||||
// mounted, so the shortcut doesn't swallow the browser's native print
|
||||
// elsewhere; gated on having projects to pick.
|
||||
// On this screen, both project-pick and new-workspace open the chooser.
|
||||
// Take precedence over global new-workspace navigation to preserve the draft.
|
||||
const handleProjectPick = useCallback(() => {
|
||||
openProjectPicker();
|
||||
return true;
|
||||
}, [openProjectPicker]);
|
||||
useKeyboardActionHandler({
|
||||
handlerId: "new-workspace-project-pick",
|
||||
handlerId: `new-workspace-project-pick:${projectPickHandlerId}`,
|
||||
actions: PROJECT_PICK_ACTIONS,
|
||||
enabled: projectPickerOptions.length > 0,
|
||||
priority: 0,
|
||||
enabled: isScreenFocused,
|
||||
isActive: isStillOnCreateScreen,
|
||||
priority: 10,
|
||||
handle: handleProjectPick,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ChevronDown } from "lucide-react-native";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
/**
|
||||
@@ -97,15 +99,17 @@ export interface FieldTriggerProps extends FieldTriggerContentProps {
|
||||
disabled?: boolean;
|
||||
/** Desktop hover hint; badges only, since a field shows its label. */
|
||||
tooltip?: string;
|
||||
shortcutAction?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/** A field trigger that anchors a Combobox. */
|
||||
export const FieldTrigger = forwardRef<View, FieldTriggerProps>(function FieldTrigger(
|
||||
{ accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content },
|
||||
{ accessibilityLabel, onPress, disabled = false, tooltip, shortcutAction, testID, ...content },
|
||||
ref,
|
||||
): ReactElement {
|
||||
const style = useFieldTriggerStyle(content.variant, disabled);
|
||||
const shortcutKeys = useShortcutKeys(shortcutAction ?? null);
|
||||
const trigger = (
|
||||
<ComboboxTrigger
|
||||
ref={ref}
|
||||
@@ -128,7 +132,10 @@ export const FieldTrigger = forwardRef<View, FieldTriggerProps>(function FieldTr
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -210,6 +217,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
detail: {
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
|
||||
@@ -209,6 +209,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
|
||||
label={t("newWorkspace.fields.project")}
|
||||
accessibilityLabel="Workspace project"
|
||||
tooltip={t("newWorkspace.tooltips.project")}
|
||||
shortcutAction="new-workspace"
|
||||
icon={projectIcon}
|
||||
value={project.triggerLabel}
|
||||
onPress={project.open}
|
||||
|
||||
@@ -1,60 +1,42 @@
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { UsageRefreshButton } from "./refresh-button";
|
||||
import { UsageOptions } from "./options";
|
||||
import type { UsageHost } from "./model";
|
||||
import { hasHostChoice, type UsageHostSelection } from "./hosts";
|
||||
import { useHostUsage } from "./queries";
|
||||
import type { UsageView } from "./types";
|
||||
|
||||
/** The hosts to choose between, and which one is shown. */
|
||||
export interface UsageHostSelection {
|
||||
hosts: UsageHost[];
|
||||
serverId: string;
|
||||
onSelect: (serverId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The controls on the right of every usage title row: Refresh all and the Settings cog. A host
|
||||
* that cannot report usage has none.
|
||||
* that cannot report usage has none, unless there is another host to switch to: the host choice
|
||||
* lives in the cog, so the cog stays.
|
||||
*/
|
||||
export function UsageControls({
|
||||
view,
|
||||
serverId,
|
||||
onRefresh,
|
||||
hostSelection,
|
||||
}: {
|
||||
view: UsageView;
|
||||
serverId: string;
|
||||
onRefresh: () => void;
|
||||
hostSelection?: UsageHostSelection;
|
||||
}) {
|
||||
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
|
||||
if (view.kind === "unavailable") return null;
|
||||
if (!hasUsageControls(view, hostSelection)) return null;
|
||||
return (
|
||||
<View style={styles.controls}>
|
||||
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
|
||||
<UsageOptions serverId={serverId} />
|
||||
{view.kind === "off" || view.kind === "unavailable" ? null : (
|
||||
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
|
||||
)}
|
||||
<UsageOptions serverId={serverId} hostSelection={hostSelection} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The host whose usage shows, when there is more than one to choose from. */
|
||||
function UsageHostFilter({ hostSelection }: { hostSelection: UsageHostSelection }) {
|
||||
return (
|
||||
<HostFilter
|
||||
hosts={hostSelection.hosts}
|
||||
selectedHost={hostSelection.serverId}
|
||||
onSelectHost={hostSelection.onSelect}
|
||||
includeAllHost={false}
|
||||
hang
|
||||
triggerTestID="usage-host-filter-trigger"
|
||||
hostOptionTestID={usageHostOptionTestID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function usageHostOptionTestID(serverId: string): string {
|
||||
return `usage-host-filter-item-${serverId}`;
|
||||
function hasUsageControls(view: UsageView, hostSelection: UsageHostSelection | undefined): boolean {
|
||||
return view.kind !== "unavailable" || hasHostChoice(hostSelection);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -67,28 +49,29 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
/**
|
||||
* One host’s usage for the Usage modal: its title-row controls, null when there is nothing to
|
||||
* control so the header leaves no empty island, and the host filter for the top of the body, null
|
||||
* with a single host.
|
||||
* One host’s usage for the Usage modal, and its title-row controls: null when there is nothing to
|
||||
* control, so the header leaves no empty island.
|
||||
*/
|
||||
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
|
||||
view: UsageView;
|
||||
refresh: () => void;
|
||||
controls: ReactElement | null;
|
||||
hostFilter: ReactElement | null;
|
||||
} {
|
||||
const { view, refresh } = useHostUsage(hostSelection.serverId);
|
||||
const controls = useMemo(
|
||||
() =>
|
||||
view.kind === "unavailable" ? null : (
|
||||
<UsageControls serverId={hostSelection.serverId} view={view} onRefresh={refresh} />
|
||||
),
|
||||
[hostSelection.serverId, refresh, view],
|
||||
hasUsageControls(view, hostSelection) ? (
|
||||
// Keyed by host, so state one host's controls hold, such as a failed setting change,
|
||||
// never shows on another's.
|
||||
<UsageControls
|
||||
key={hostSelection.serverId}
|
||||
serverId={hostSelection.serverId}
|
||||
view={view}
|
||||
onRefresh={refresh}
|
||||
hostSelection={hostSelection}
|
||||
/>
|
||||
) : null,
|
||||
[hostSelection, refresh, view],
|
||||
);
|
||||
const hostFilter = useMemo(
|
||||
() =>
|
||||
hostSelection.hosts.length > 1 ? <UsageHostFilter hostSelection={hostSelection} /> : null,
|
||||
[hostSelection],
|
||||
);
|
||||
return { view, refresh, controls, hostFilter };
|
||||
return { view, refresh, controls };
|
||||
}
|
||||
|
||||
@@ -57,3 +57,17 @@ export function useUsageHostSelection(): {
|
||||
);
|
||||
return { serverId: resolveUsageModalHostId(choice), connectedHosts, select };
|
||||
}
|
||||
|
||||
/** The hosts to choose between, and which one is shown. */
|
||||
export interface UsageHostSelection {
|
||||
hosts: UsageHost[];
|
||||
serverId: string;
|
||||
onSelect: (serverId: string) => void;
|
||||
}
|
||||
|
||||
/** Whether there is more than one host to show usage for. */
|
||||
export function hasHostChoice(
|
||||
hostSelection: UsageHostSelection | undefined,
|
||||
): hostSelection is UsageHostSelection {
|
||||
return (hostSelection?.hosts.length ?? 0) > 1;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,22 @@ import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Settings } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSubTrigger,
|
||||
type MenuPageDefinition,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HostMenuItem } from "@/components/hosts/host-menu-item";
|
||||
import { getHostPickerLabel } from "@/components/hosts/host-picker";
|
||||
import {
|
||||
mutedIconColorMapping,
|
||||
useIconButtonChromeGlyphSize,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { SettingsRow, SettingsSwitch } from "@/components/settings";
|
||||
import { MenuSettingsRows, SettingsRow, SettingsSwitch } from "@/components/settings";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
@@ -17,12 +25,25 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { useUsageSetting } from "./setting";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
import { hasHostChoice, type UsageHostSelection } from "./hosts";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
|
||||
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
|
||||
export function UsageOptions({ serverId }: { serverId: string | null }) {
|
||||
const HOST_PAGE = "host";
|
||||
|
||||
/**
|
||||
* The shared usage preferences, opened from a cog as a popover or compact sheet. With more than
|
||||
* one host, it leads with the host whose usage shows: the row reads as the current host, and the
|
||||
* hosts are a page of the same surface, so a compact sheet never opens a second sheet over it.
|
||||
*/
|
||||
export function UsageOptions({
|
||||
serverId,
|
||||
hostSelection,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
hostSelection?: UsageHostSelection;
|
||||
}) {
|
||||
const setting = useUsageSetting(serverId);
|
||||
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const { t } = useTranslation();
|
||||
@@ -38,6 +59,20 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
|
||||
const glyphSize = useIconButtonChromeGlyphSize("small", compact);
|
||||
const { display } = useUsageDisplay([]);
|
||||
const { inSidebar, setInSidebar } = useUsageInSidebar();
|
||||
const hostChoice = hasHostChoice(hostSelection) ? hostSelection : null;
|
||||
const pages = useMemo<MenuPageDefinition[] | undefined>(
|
||||
() =>
|
||||
hostChoice
|
||||
? [
|
||||
{
|
||||
id: HOST_PAGE,
|
||||
title: t("providerUsage.host"),
|
||||
content: <UsageHostPage hostSelection={hostChoice} />,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
[hostChoice, t],
|
||||
);
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<ToolbarButton
|
||||
@@ -52,40 +87,72 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
|
||||
align="end"
|
||||
width={340}
|
||||
sheetTitle={t("providerUsage.options")}
|
||||
pages={pages}
|
||||
testID="usage-options-surface"
|
||||
>
|
||||
<View testID="usage-options-fields">
|
||||
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
|
||||
{setting.supported ? (
|
||||
<SettingsSwitch
|
||||
label={t("providerUsage.checkUsage")}
|
||||
hint={t("providerUsage.checkUsageHint", { host: hostLabel })}
|
||||
disabled={!setting.ready}
|
||||
value={setting.enabled}
|
||||
onValueChange={setting.setEnabled}
|
||||
/>
|
||||
) : null}
|
||||
<View style={setting.supported ? settingsStyles.rowBorder : undefined}>
|
||||
<SettingsSwitch
|
||||
label={t("providerUsage.showInSidebar")}
|
||||
hint={t("providerUsage.showInSidebarHint")}
|
||||
value={inSidebar}
|
||||
onValueChange={setInSidebar}
|
||||
testID="usage-show-in-sidebar"
|
||||
/>
|
||||
</View>
|
||||
<View style={settingsStyles.rowBorder}>
|
||||
<SettingsRow label={t("providerUsage.displayAs")}>
|
||||
<SegmentedControl
|
||||
options={displayAsOptions}
|
||||
value={display.displayAs}
|
||||
onValueChange={display.setDisplayAs}
|
||||
testID="usage-display-as"
|
||||
{hostChoice ? (
|
||||
<DropdownMenuSubTrigger
|
||||
id={HOST_PAGE}
|
||||
value={getHostPickerLabel(hostChoice.hosts, hostChoice.serverId)}
|
||||
testID="usage-options-host"
|
||||
>
|
||||
{t("providerUsage.host")}
|
||||
</DropdownMenuSubTrigger>
|
||||
) : null}
|
||||
<View
|
||||
style={hostChoice ? settingsStyles.rowBorder : undefined}
|
||||
testID="usage-options-fields"
|
||||
>
|
||||
<MenuSettingsRows>
|
||||
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
|
||||
{setting.supported ? (
|
||||
<SettingsSwitch
|
||||
label={t("providerUsage.checkUsage")}
|
||||
hint={t("providerUsage.checkUsageHint", { host: hostLabel })}
|
||||
disabled={!setting.ready}
|
||||
value={setting.enabled}
|
||||
onValueChange={setting.setEnabled}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={setting.supported ? settingsStyles.rowBorder : undefined}>
|
||||
<SettingsSwitch
|
||||
label={t("providerUsage.showInSidebar")}
|
||||
hint={t("providerUsage.showInSidebarHint")}
|
||||
value={inSidebar}
|
||||
onValueChange={setInSidebar}
|
||||
testID="usage-show-in-sidebar"
|
||||
/>
|
||||
</View>
|
||||
<View style={settingsStyles.rowBorder}>
|
||||
<SettingsRow label={t("providerUsage.displayAs")}>
|
||||
<SegmentedControl
|
||||
options={displayAsOptions}
|
||||
value={display.displayAs}
|
||||
onValueChange={display.setDisplayAs}
|
||||
testID="usage-display-as"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</View>
|
||||
</MenuSettingsRows>
|
||||
</View>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageHostPage({ hostSelection }: { hostSelection: UsageHostSelection }) {
|
||||
return (
|
||||
<>
|
||||
{hostSelection.hosts.map((host) => (
|
||||
<HostMenuItem
|
||||
key={host.serverId}
|
||||
serverId={host.serverId}
|
||||
label={host.label}
|
||||
selected={host.serverId === hostSelection.serverId}
|
||||
onSelect={hostSelection.onSelect}
|
||||
testID={`usage-options-host-${host.serverId}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ function hostUsageQueryKey(serverId: string) {
|
||||
return ["usage", serverId] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host's own reports. The key differs only by host, so it is read with `dataShape: "value"`:
|
||||
* a list shape keeps the previous key's data while the next one loads, which would show one host's
|
||||
* reports as another's.
|
||||
*/
|
||||
function usageReportsQueryKey(serverId: string) {
|
||||
return [...hostUsageQueryKey(serverId), "reports"] as const;
|
||||
}
|
||||
@@ -150,7 +155,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
|
||||
queryKey: usageReportsQueryKey(serverId),
|
||||
queryFn: () => listReports(queryClient, serverId),
|
||||
enabled: isConnected && isSupported && setting.enabled,
|
||||
dataShape: "list",
|
||||
dataShape: "value",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
});
|
||||
const refresh = useCallback(() => {
|
||||
@@ -189,7 +194,7 @@ export function useUsageHostReports({
|
||||
enabled: setting.enabled,
|
||||
queryKey: usageReportsQueryKey(serverId ?? ""),
|
||||
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
|
||||
dataShape: "list",
|
||||
dataShape: "value",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, SheetHeaderSubtitle } from "@/components/adaptive-modal-sheet";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useHostUsageWithControls } from "./controls";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useUsageHostSelection } from "./hosts";
|
||||
import { getHostPickerLabel } from "@/components/hosts/host-picker";
|
||||
import { hasHostChoice, useUsageHostSelection } from "./hosts";
|
||||
import type { UsageHost } from "./model";
|
||||
import { UsageBody, UsageMessage } from "./usage-section";
|
||||
|
||||
@@ -17,8 +18,8 @@ interface UsageModalProps {
|
||||
|
||||
/**
|
||||
* Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It
|
||||
* shows the usage host's reports with pins, Refresh all and Settings in its title row, and the
|
||||
* host filter at the top of its body.
|
||||
* shows the usage host's reports with pins, and Refresh all and Settings in its title row. With more
|
||||
* than one host, the host shown is the title's subtitle, and Settings is where it is picked.
|
||||
*/
|
||||
export function UsageModal(props: UsageModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -30,15 +31,8 @@ export function UsageModal(props: UsageModalProps) {
|
||||
</UsageModalFrame>
|
||||
);
|
||||
}
|
||||
// Keyed by host so a refresh in flight on one host never shows on another's cards.
|
||||
return (
|
||||
<HostUsageModal
|
||||
key={serverId}
|
||||
{...props}
|
||||
serverId={serverId}
|
||||
hosts={connectedHosts}
|
||||
onSelectHost={select}
|
||||
/>
|
||||
<HostUsageModal {...props} serverId={serverId} hosts={connectedHosts} onSelectHost={select} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,11 +50,17 @@ function HostUsageModal({
|
||||
() => ({ hosts, serverId, onSelect: onSelectHost }),
|
||||
[hosts, onSelectHost, serverId],
|
||||
);
|
||||
const { view, refresh, controls, hostFilter } = useHostUsageWithControls(hostSelection);
|
||||
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
|
||||
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
|
||||
const hostLabel = hasHostChoice(hostSelection)
|
||||
? getHostPickerLabel(hostSelection.hosts, hostSelection.serverId)
|
||||
: undefined;
|
||||
return (
|
||||
<UsageModalFrame {...props} actions={controls} hostFilter={hostFilter}>
|
||||
<View testID={`usage-host-${serverId}`}>
|
||||
<UsageModalFrame {...props} actions={controls} subtitle={hostLabel}>
|
||||
{/* Keyed by host so state the cards hold, such as a refresh in flight, never shows on another
|
||||
host's cards. The frame is not keyed: remounting it would dismiss a compact sheet, and Usage
|
||||
with it. */}
|
||||
<View key={serverId} testID={`usage-host-${serverId}`}>
|
||||
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
|
||||
</View>
|
||||
</UsageModalFrame>
|
||||
@@ -72,12 +72,22 @@ function UsageModalFrame({
|
||||
onClose,
|
||||
onDismiss,
|
||||
actions,
|
||||
hostFilter,
|
||||
subtitle,
|
||||
children,
|
||||
}: UsageModalProps & { actions?: ReactNode; hostFilter?: ReactNode; children: ReactNode }) {
|
||||
}: UsageModalProps & { actions?: ReactNode; subtitle?: string; children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const title = t(builtinSidebarNavLabelKey("usage"));
|
||||
const header = useMemo(() => ({ title, actions }), [actions, title]);
|
||||
const header = useMemo(
|
||||
() => ({
|
||||
title,
|
||||
titleTestID: "usage-modal-title",
|
||||
subtitle: subtitle ? (
|
||||
<SheetHeaderSubtitle text={subtitle} testID="usage-modal-host" />
|
||||
) : undefined,
|
||||
actions,
|
||||
}),
|
||||
[actions, subtitle, title],
|
||||
);
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
@@ -88,7 +98,6 @@ function UsageModalFrame({
|
||||
contextBridge={null}
|
||||
testID="usage-modal"
|
||||
>
|
||||
{hostFilter}
|
||||
<View testID="usage-modal-body">{children}</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type PluginClientBundle,
|
||||
type PluginClientBundleStore,
|
||||
} from "./daemon-client";
|
||||
import { createHostChannelTransport } from "./hosts/index.js";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { ChunkedTransport } from "@getpaseo/protocol/chunked-transport";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
@@ -9562,7 +9563,6 @@ test.each(["latency", "dictation start", "dictation finish"])(
|
||||
);
|
||||
|
||||
test("routed channels inherit an origin without drain support", async () => {
|
||||
const { createHostChannelTransport } = await import("./hosts/index.js");
|
||||
const mock = createMockTransport();
|
||||
mock.transport.supportsDrain = false;
|
||||
const client = new DaemonClient({
|
||||
@@ -10133,3 +10133,69 @@ describe("provider options across daemon versions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: "route", initialRouteId: null, link: false },
|
||||
{ name: "link", initialRouteId: null, link: true },
|
||||
{ name: "initial route", initialRouteId: "route", link: false },
|
||||
])(
|
||||
"$name fails promptly when its origin is offline during initialization",
|
||||
async ({ initialRouteId, link }) => {
|
||||
vi.useFakeTimers();
|
||||
const origin = new DaemonClient({
|
||||
url: "ws://origin",
|
||||
clientId: "offline-origin",
|
||||
transportFactory: () => createMockTransport().transport,
|
||||
reconnect: { enabled: false },
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
clients.push(origin);
|
||||
const unsubscribe = vi.fn();
|
||||
const subscribe = origin.subscribeConnectionStatus.bind(origin);
|
||||
vi.spyOn(origin, "subscribeConnectionStatus").mockImplementation((listener) => {
|
||||
const release = subscribe(listener);
|
||||
return () => {
|
||||
release();
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
const child = new DaemonClient({
|
||||
url: "ws://routed-host/ws",
|
||||
clientId: "offline-origin-child",
|
||||
transportFactory: createHostChannelTransport(origin, "child", "scope", initialRouteId, link),
|
||||
reconnect: { enabled: false },
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
clients.push(child);
|
||||
const rejected = expect(child.connect()).rejects.toThrow();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(child.getConnectionState().status).toBe("disconnected");
|
||||
await rejected;
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("disposing a route before its deferred close does not reopen it on origin recovery", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mock = createMockTransport();
|
||||
const origin = new DaemonClient({
|
||||
url: "ws://origin",
|
||||
clientId: "disposed-route-origin",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
clients.push(origin);
|
||||
const route = createHostChannelTransport(origin, "child", "scope", null)({ url: "ws://route" });
|
||||
const opened = vi.fn();
|
||||
const closed = vi.fn();
|
||||
route.onOpen(opened);
|
||||
route.onClose(closed);
|
||||
route.close();
|
||||
const connecting = origin.connect();
|
||||
mock.triggerOpen({ features: { ownedSubscriptions: true, connectedHosts: true } });
|
||||
await connecting;
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(opened).not.toHaveBeenCalled();
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -86,7 +86,8 @@ export function createHostChannelTransport(
|
||||
);
|
||||
cleanups.push(
|
||||
origin.subscribeConnectionStatus((connectionState) => {
|
||||
if (connectionState.status !== "connected") close();
|
||||
// The initial status arrives before the client installs transport handlers.
|
||||
if (connectionState.status !== "connected") queueMicrotask(close);
|
||||
}),
|
||||
);
|
||||
let opening: Promise<string>;
|
||||
|
||||
@@ -3020,6 +3020,72 @@ test("opening during reload waits for the replacement", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clearing attention during reload waits for the replacement", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-clear-attention-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldReloadCloseClient();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
try {
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.markAgentUnread(created.id);
|
||||
const reloading = manager.reloadAgentSession(created.id);
|
||||
await client.waitForCloseToStart();
|
||||
let cleared = false;
|
||||
const clearing = manager.clearAgentAttention(created.id).then(() => {
|
||||
cleared = true;
|
||||
return true;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(cleared).toBe(false);
|
||||
client.finishClosing();
|
||||
await Promise.all([reloading, clearing]);
|
||||
expect(manager.getAgent(created.id)?.attention.requiresAttention).toBe(false);
|
||||
expect((await storage.get(created.id))?.requiresAttention).toBe(false);
|
||||
await manager.closeAgent(created.id);
|
||||
} finally {
|
||||
client.finishClosing();
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resuming after a queued attention clear keeps the agent read", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-clear-attention-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const handle = manager.getAgent(created.id)!.persistence!;
|
||||
await manager.closeAgent(created.id);
|
||||
await manager.markAgentUnread(created.id);
|
||||
// A loader read the record before the clear landed, then queued the resume behind it.
|
||||
const unreadAttention = {
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
attentionTimestamp: new Date(),
|
||||
} as const;
|
||||
const clearing = manager.clearAgentAttention(created.id);
|
||||
const resumed = await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, created.id, {
|
||||
attention: unreadAttention,
|
||||
});
|
||||
await clearing;
|
||||
expect(resumed.attention.requiresAttention).toBe(false);
|
||||
expect((await storage.get(created.id))?.requiresAttention).toBe(false);
|
||||
await manager.closeAgent(created.id);
|
||||
} finally {
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("closing during reload leaves the replacement closed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-race-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
|
||||
@@ -1722,9 +1722,9 @@ export class AgentManager {
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
launchedOptions,
|
||||
...options,
|
||||
// The caller read its metadata before this turn in the lane; a setMetadata queued ahead of
|
||||
// it has saved a newer record, the one the open hooks saw.
|
||||
...(record ? { metadata: record.metadata } : {}),
|
||||
// The caller read its metadata and attention before this turn in the lane; a setMetadata
|
||||
// or attention clear queued ahead of it has saved a newer record, the one the open hooks saw.
|
||||
...(record ? { metadata: record.metadata, attention: extractAttention(record) } : {}),
|
||||
workspaceId,
|
||||
launchEnv,
|
||||
persistence: handle,
|
||||
@@ -2763,6 +2763,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async clearAgentAttention(agentId: string): Promise<void> {
|
||||
return this.runLifecycleMutation(agentId, () => this.clearAgentAttentionUnlocked(agentId));
|
||||
}
|
||||
|
||||
private async clearAgentAttentionUnlocked(agentId: string): Promise<void> {
|
||||
const agent = this.agents.get(agentId);
|
||||
if (agent) {
|
||||
if (agent.attention.requiresAttention) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/plugin-config";
|
||||
|
||||
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
|
||||
|
||||
@@ -14,6 +15,15 @@ const MARKER_COST = 32;
|
||||
const SHORT_STRING = 1024;
|
||||
const MAX_DEPTH = 64;
|
||||
|
||||
// The app registers renderer kinds under the plugin ID rule, so stored kinds must use that format.
|
||||
export function assertPluginTimelineKind(kind: string): void {
|
||||
if (!PluginIdSchema.safeParse(kind).success) {
|
||||
throw new Error(
|
||||
`Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPluginTimelineDataSize(data: JsonValue): void {
|
||||
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
|
||||
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
|
||||
|
||||
+27
-2
@@ -1,5 +1,12 @@
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
HookCallback,
|
||||
Query,
|
||||
SDKMessage,
|
||||
SDKUserMessage,
|
||||
SessionCronSummary,
|
||||
StopHookInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -884,6 +891,7 @@ test("Claude session runtime safety observes pumped background work and autonomo
|
||||
wake?.();
|
||||
};
|
||||
let nativeInput!: AsyncIterator<SDKUserMessage>;
|
||||
let stopHook: HookCallback | undefined;
|
||||
let connected!: () => void;
|
||||
const connection = new Promise<void>((resolve) => {
|
||||
connected = resolve;
|
||||
@@ -908,9 +916,10 @@ test("Claude session runtime safety observes pumped background work and autonomo
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
queryFactory: ({ prompt }) => {
|
||||
queryFactory: ({ prompt, options }) => {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
nativeInput = prompt[Symbol.asyncIterator]();
|
||||
stopHook = options?.hooks?.Stop?.[0]?.hooks[0];
|
||||
connected();
|
||||
return query;
|
||||
},
|
||||
@@ -957,6 +966,22 @@ test("Claude session runtime safety observes pumped background work and autonomo
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
// A session-only cron lives in this process, and Claude reports it only to the Stop hook.
|
||||
const stop = (session_crons: SessionCronSummary[]) => {
|
||||
const stopInput: StopHookInput = {
|
||||
hook_event_name: "Stop",
|
||||
session_id: "runtime-safety",
|
||||
transcript_path: "",
|
||||
cwd: process.cwd(),
|
||||
stop_hook_active: false,
|
||||
session_crons,
|
||||
};
|
||||
return stopHook!(stopInput, undefined, { signal: new AbortController().signal });
|
||||
};
|
||||
await stop([{ id: "99eb0b69", schedule: "0 */6 * * *", recurring: true, prompt: "say hi" }]);
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await stop([]);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.j
|
||||
import {
|
||||
type AgentDefinition,
|
||||
type CanUseTool,
|
||||
type HookCallback,
|
||||
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
||||
type PermissionMode,
|
||||
type PermissionResult,
|
||||
@@ -3804,7 +3805,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// subagents keep running. Without this declaration an interrupt kills every one of them.
|
||||
// Claude stops a helper itself with its TaskStop tool.
|
||||
perTaskStopAffordance: true,
|
||||
hooks: this.buildSubagentEffortHooks(),
|
||||
hooks: { ...this.buildSubagentEffortHooks(), Stop: [{ hooks: [this.observeStopHook] }] },
|
||||
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
|
||||
env: sdkEnv,
|
||||
};
|
||||
@@ -5480,6 +5481,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
// Observation-only, like the effort hooks: it never alters turn control.
|
||||
private readonly observeStopHook: HookCallback = async (input) => {
|
||||
if (input.hook_event_name === "Stop") this.runtimeSafety.observeStop(input.session_crons);
|
||||
return {};
|
||||
};
|
||||
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId =
|
||||
getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
||||
|
||||
@@ -46,3 +46,47 @@ test("enriches only live tasks with their correlated launch metadata", () => {
|
||||
runtime.observe(message({ type: "system", subtype: "background_tasks_changed", tasks: [] })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps the runtime while Claude's last Stop hook lists session crons", () => {
|
||||
const runtime = new ClaudeRuntimeSafety();
|
||||
runtime.observe(message({ type: "system", subtype: "init", claude_code_version: "2.1.321" }));
|
||||
const cron = { id: "99eb0b69", schedule: "0 */6 * * *", recurring: true, prompt: "say hi" };
|
||||
|
||||
runtime.observeStop([cron]);
|
||||
expect(runtime.canStopRuntime()).toBe(false);
|
||||
|
||||
runtime.observeStop(undefined);
|
||||
expect(runtime.canStopRuntime()).toBe(false);
|
||||
|
||||
runtime.observeStop([]);
|
||||
expect(runtime.canStopRuntime()).toBe(true);
|
||||
|
||||
runtime.observeStop([cron]);
|
||||
runtime.reset();
|
||||
runtime.observe(message({ type: "system", subtype: "init", claude_code_version: "2.1.321" }));
|
||||
expect(runtime.canStopRuntime()).toBe(true);
|
||||
});
|
||||
|
||||
test("holds the runtime when a turn that called a cron tool ends without a Stop hook", () => {
|
||||
const runtime = new ClaudeRuntimeSafety();
|
||||
runtime.observe(message({ type: "system", subtype: "init", claude_code_version: "2.1.321" }));
|
||||
const callTool = (name: string) =>
|
||||
runtime.observe(
|
||||
message({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "tool_use", id: `${name}-1`, name, input: {} }] },
|
||||
}),
|
||||
);
|
||||
|
||||
// The turn schedules a cron and is interrupted, so no Stop hook reports it.
|
||||
callTool("CronCreate");
|
||||
expect(runtime.canStopRuntime()).toBe(false);
|
||||
|
||||
runtime.observeStop([{ id: "99eb0b69", schedule: "0 */6 * * *", recurring: true, prompt: "hi" }]);
|
||||
callTool("CronDelete");
|
||||
runtime.observeStop([]);
|
||||
expect(runtime.canStopRuntime()).toBe(true);
|
||||
|
||||
callTool("ScheduleWakeup");
|
||||
expect(runtime.canStopRuntime()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SDKMessage, SessionCronSummary } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AgentBackgroundWorkInput } from "../../background-work/index.js";
|
||||
import { parseClaudeCodeVersion } from "./model-manifest.js";
|
||||
import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background-work.js";
|
||||
|
||||
const SESSION_CRON_TOOLS = new Set(["CronCreate", "CronDelete", "ScheduleWakeup"]);
|
||||
|
||||
/** Process-local background ownership. Completion delivery can follow an empty roster. */
|
||||
export class ClaudeRuntimeSafety {
|
||||
private inventoryState: "unknown" | "supported" | "retired" = "unknown";
|
||||
@@ -16,8 +18,15 @@ export class ClaudeRuntimeSafety {
|
||||
private readonly monitorTools = new Set<string>();
|
||||
private readonly toolInputs = new Map<string, { description?: string; command?: string }>();
|
||||
private readonly taskTools = new Map<string, string>();
|
||||
// Session-only crons and wakeups live in the process and die with it. Only the Stop hook
|
||||
// lists them, and Claude runs it only for turns that complete. A cron tool call makes the
|
||||
// count unknown until the next Stop, so an interrupted or failed turn keeps the runtime.
|
||||
private sessionCrons = 0;
|
||||
private sessionCronsUnknown = false;
|
||||
|
||||
reset(): void {
|
||||
this.sessionCrons = 0;
|
||||
this.sessionCronsUnknown = false;
|
||||
this.inventoryState = "unknown";
|
||||
this.turnSeen = false;
|
||||
this.liveTasks = [];
|
||||
@@ -38,6 +47,13 @@ export class ClaudeRuntimeSafety {
|
||||
this.turnSeen = true;
|
||||
}
|
||||
|
||||
/** Called with the session crons of each main-thread Stop hook. Older CLIs omit them. */
|
||||
observeStop(sessionCrons: readonly SessionCronSummary[] | undefined): void {
|
||||
if (this.inventoryState === "retired") return;
|
||||
this.sessionCronsUnknown = false;
|
||||
if (sessionCrons) this.sessionCrons = sessionCrons.length;
|
||||
}
|
||||
|
||||
/** Returns a full replacement only when the visible inventory changes. */
|
||||
observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined {
|
||||
if (this.inventoryState === "retired") return undefined;
|
||||
@@ -54,6 +70,8 @@ export class ClaudeRuntimeSafety {
|
||||
if (message.type === "assistant") {
|
||||
let relevant = false;
|
||||
for (const block of message.message.content) {
|
||||
if (block.type === "tool_use" && SESSION_CRON_TOOLS.has(block.name))
|
||||
this.sessionCronsUnknown = true;
|
||||
if (block.type === "tool_use" && (block.name === "Monitor" || block.name === "Bash")) {
|
||||
if (block.name === "Monitor") relevant = this.observeMonitor(block.id) || relevant;
|
||||
relevant = this.observeInput(block.id, block.input) || relevant;
|
||||
@@ -129,6 +147,8 @@ export class ClaudeRuntimeSafety {
|
||||
if (this.inventoryState === "unknown" && !this.turnSeen) return true;
|
||||
return (
|
||||
this.inventoryState === "supported" &&
|
||||
this.sessionCrons === 0 &&
|
||||
!this.sessionCronsUnknown &&
|
||||
this.liveTasks.length === 0 &&
|
||||
this.awaitingNotifications.size === 0
|
||||
);
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "../agent/agent-sdk-types.js";
|
||||
import {
|
||||
canRunRealProvider,
|
||||
createRealProviderClient,
|
||||
getRealProviderConfig,
|
||||
} from "./real-provider-test-config.js";
|
||||
|
||||
describe("real claude: idle runtime cleanup and session crons", () => {
|
||||
let canRun = false;
|
||||
beforeAll(async () => {
|
||||
canRun = await canRunRealProvider("claude");
|
||||
});
|
||||
beforeEach((context) => {
|
||||
if (!canRun) context.skip();
|
||||
});
|
||||
|
||||
test("a runtime holding a session-only cron is kept until the cron is deleted", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = createRealProviderClient("claude", logger);
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-session-cron-"));
|
||||
const session = await client.createSession({
|
||||
cwd,
|
||||
...getRealProviderConfig("claude"),
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.run(
|
||||
"Call the CronCreate tool exactly once with cron '0 */6 * * *', recurring true, durable false, prompt 'say hi'. Then reply with exactly: DONE",
|
||||
);
|
||||
const cronResult = events.find(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "CronCreate" &&
|
||||
event.item.status === "completed",
|
||||
);
|
||||
expect(cronResult, JSON.stringify(events.slice(-5))).toBeDefined();
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
|
||||
await session.run(
|
||||
"Delete every scheduled cron job with the CronDelete tool. Then reply with exactly: DONE",
|
||||
);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
test("a turn interrupted after it creates a session-only cron keeps the runtime", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = createRealProviderClient("claude", logger);
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-session-cron-"));
|
||||
const session = await client.createSession({
|
||||
cwd,
|
||||
...getRealProviderConfig("claude"),
|
||||
});
|
||||
const cronCreated = new Promise<void>((resolve) => {
|
||||
session.subscribe((event) => {
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "CronCreate" &&
|
||||
event.item.status === "completed"
|
||||
)
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
await session.startTurn(
|
||||
"Call the CronCreate tool exactly once with cron '0 */6 * * *', recurring true, durable false, prompt 'say hi'. Then run the Bash command 'sleep 120' in the foreground. Then reply with exactly: DONE",
|
||||
);
|
||||
await cronCreated;
|
||||
await session.interrupt();
|
||||
// The cancelled turn settles about a second after interrupt() resolves.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}, 240_000);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { withTimeout } from "../utils/promise-timeout.js";
|
||||
import {
|
||||
SessionInboundMessageSchema,
|
||||
type SessionInboundMessage,
|
||||
@@ -1645,6 +1646,69 @@ test("request outcomes for import and attention reach only their requesting sock
|
||||
}
|
||||
});
|
||||
|
||||
test("clearing a closed agent's attention does not resume its provider session", async () => {
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
mcpEnabled: false,
|
||||
isDev: true,
|
||||
agentClients: { mock: new MockLoadTestAgentClient() },
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
const agent = await client.createAgent({ provider: "mock", cwd: daemon.staticDir });
|
||||
await daemon.daemon.agentManager.closeAgent(agent.id);
|
||||
await daemon.daemon.agentManager.markAgentUnread(agent.id);
|
||||
await client.clearAgentAttention(agent.id);
|
||||
expect(daemon.daemon.agentManager.getAgent(agent.id)).toBeNull();
|
||||
const snapshot = await client.fetchAgent(agent.id);
|
||||
expect(snapshot?.agent.requiresAttention).toBe(false);
|
||||
expect(snapshot?.agent.status).toBe("closed");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
class SessionGoneAgentClient extends MockLoadTestAgentClient {
|
||||
resumeCalls = 0;
|
||||
|
||||
override async resumeSession(): Promise<never> {
|
||||
this.resumeCalls += 1;
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
}
|
||||
|
||||
test("clearing a closed agent's attention succeeds when its provider session is gone", async () => {
|
||||
const agentClient = new SessionGoneAgentClient();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
mcpEnabled: false,
|
||||
isDev: true,
|
||||
agentClients: { mock: agentClient },
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
const agent = await client.createAgent({ provider: "mock", cwd: daemon.staticDir });
|
||||
await daemon.daemon.agentManager.closeAgent(agent.id);
|
||||
await client.markWorkspaceUnread(agent.workspaceId!);
|
||||
expect((await client.fetchAgent(agent.id))?.agent.requiresAttention).toBe(true);
|
||||
|
||||
await withTimeout(
|
||||
client.clearAgentAttention(agent.id),
|
||||
5_000,
|
||||
"clear_agent_attention got no response",
|
||||
);
|
||||
|
||||
const snapshot = await client.fetchAgent(agent.id);
|
||||
expect(snapshot?.agent.requiresAttention).toBe(false);
|
||||
expect(snapshot?.agent.status).toBe("closed");
|
||||
expect(agentClient.resumeCalls).toBe(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("SDK import failure settles without waiting for the request timeout", async () => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
@@ -1660,6 +1660,37 @@ describe("plugin timeline append RPC", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["repro.card", "Review", "review_card", "1review", ""])(
|
||||
"rejects the kind %j that no client renderer can register",
|
||||
async (kind) => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const appendTimelineItem = vi.fn();
|
||||
const session = createSessionForTest({
|
||||
clientId: "plugin:review",
|
||||
messages,
|
||||
agentManager: { appendTimelineItem },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "agent.timeline.append.request",
|
||||
requestId: "append-kind",
|
||||
agentId: "agent-1",
|
||||
item: { type: "plugin", id: "review-1", kind, version: 1, data: {} },
|
||||
});
|
||||
|
||||
expect(appendTimelineItem).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "append-kind",
|
||||
requestType: "agent.timeline.append.request",
|
||||
error: `Request failed: Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
|
||||
code: "handler_error",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("agent detach RPC", () => {
|
||||
|
||||
@@ -144,7 +144,10 @@ import {
|
||||
updateAgentCommand,
|
||||
} from "./agent/lifecycle-command.js";
|
||||
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
|
||||
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
|
||||
import {
|
||||
assertPluginTimelineDataSize,
|
||||
assertPluginTimelineKind,
|
||||
} from "./agent/agent-timeline-content.js";
|
||||
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
|
||||
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
|
||||
import { buildAgentPrompt } from "./agent/prompt-attachments.js";
|
||||
@@ -5394,15 +5397,11 @@ export class Session {
|
||||
const agentIds = Array.isArray(agentId) ? agentId : [agentId];
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
agentIds.map((id) =>
|
||||
ensureAgentLoaded(id, {
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
logger: this.sessionLogger,
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const id of agentIds) {
|
||||
if (!this.agentManager.getAgent(id) && !(await this.agentStorage.get(id))) {
|
||||
throw new Error(`Agent not found: ${id}`);
|
||||
}
|
||||
}
|
||||
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)));
|
||||
if (requestId) {
|
||||
const agents = (
|
||||
@@ -8508,6 +8507,7 @@ export class Session {
|
||||
): Promise<void> {
|
||||
const pluginId = parsePluginClientId(this.clientId);
|
||||
if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items");
|
||||
assertPluginTimelineKind(msg.item.kind);
|
||||
assertPluginTimelineDataSize(msg.item.data);
|
||||
const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, {
|
||||
...msg.item,
|
||||
|
||||
@@ -641,7 +641,83 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
expect(workspaces.get("w1")).toMatchObject({
|
||||
archivedAt,
|
||||
branch: "new-branch",
|
||||
branch: "old-branch",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a worktree restorable when it is archived while its checkout read is in flight", async () => {
|
||||
const projectRoot = "/reconcile/archive-worktree/root";
|
||||
const worktreeCwd = "/reconcile/archive-worktree/worktrees/feature";
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
projectKey: deriveProjectKey({
|
||||
rootPath: projectRoot,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: projectRoot,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
kind: "git",
|
||||
displayName: "archive-worktree",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: worktreeCwd,
|
||||
kind: "worktree",
|
||||
displayName: "feature",
|
||||
branch: "feature",
|
||||
worktreeRoot: worktreeCwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: projectRoot,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const worktreeReadStarted = deferred();
|
||||
const allowWorktreeRead = deferred();
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
inspectDirectory: async () => "directory",
|
||||
workspaceGitService: {
|
||||
getCheckout: async (cwd) => {
|
||||
if (cwd === projectRoot) {
|
||||
return createCheckout(cwd, { isGit: true, currentBranch: "main", worktreeRoot: cwd });
|
||||
}
|
||||
worktreeReadStarted.resolve();
|
||||
await allowWorktreeRead.promise;
|
||||
// Archive deletes the worktree after archiving its record, so a read that
|
||||
// lands mid-deletion no longer sees a Git checkout there.
|
||||
return createCheckout(cwd);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reconciliation = service.runOnce();
|
||||
await worktreeReadStarted.promise;
|
||||
const archivedAt = "2025-01-02T00:00:00.000Z";
|
||||
await workspaceRegistry.archive("w1", archivedAt);
|
||||
allowWorktreeRead.resolve();
|
||||
const result = await reconciliation;
|
||||
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
expect(workspaces.get("w1")).toMatchObject({
|
||||
archivedAt,
|
||||
kind: "worktree",
|
||||
branch: "feature",
|
||||
worktreeRoot: worktreeCwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: projectRoot,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -396,8 +396,7 @@ export class WorkspaceReconciliationService {
|
||||
const workspaceCheckouts = await Promise.all(
|
||||
siblings.map(async (workspace) => {
|
||||
const current = await this.workspaceRegistry.get(workspace.workspaceId);
|
||||
if (!current || current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
|
||||
return null;
|
||||
if (!current || !isStillReconcilable(current, workspace)) return null;
|
||||
return { workspace: current, checkout: await readCheckout(current.cwd) };
|
||||
}),
|
||||
);
|
||||
@@ -446,8 +445,9 @@ export class WorkspaceReconciliationService {
|
||||
return;
|
||||
let fields: Partial<MutableWorkspacePlacement> | null = null;
|
||||
await this.workspaceRegistry.update(workspace.workspaceId, (current) => {
|
||||
if (current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
|
||||
return current;
|
||||
// Archive removes a worktree only after archiving its record, so a read
|
||||
// that raced the archive may have seen the directory mid-deletion.
|
||||
if (!isStillReconcilable(current, workspace)) return current;
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace: current,
|
||||
checkout: wsGit,
|
||||
@@ -685,3 +685,13 @@ async function mapBounded<T, R>(items: T[], visit: (item: T) => Promise<R>): Pro
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** An archived record keeps the placement its restore depends on. */
|
||||
function isStillReconcilable(
|
||||
current: PersistedWorkspaceRecord,
|
||||
scanned: PersistedWorkspaceRecord,
|
||||
): boolean {
|
||||
return (
|
||||
!current.archivedAt && current.cwd === scanned.cwd && current.projectId === scanned.projectId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1690,8 +1690,10 @@ it("leaves a worker from a newer Paseo running, reports it incompatible, and clo
|
||||
|
||||
await manager.closeTerminals();
|
||||
|
||||
expect(isProcessAlive(newer.workerPid)).toBe(false);
|
||||
expect(isProcessAlive(newer.programPid)).toBe(false);
|
||||
// Cleanup counts the worker gone once its socket refuses connections, which happens before
|
||||
// its process exits.
|
||||
await waitForCondition(() => !isProcessAlive(newer.workerPid), 5000);
|
||||
expect(await workerRecords(paseoHome)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Locator, type Page } from "./fixtures";
|
||||
import { expect, test, waitForHydration, type Locator, type Page } from "./fixtures";
|
||||
|
||||
// The page renders the repository's CHANGELOG.md, so releases are found by shape, never by version:
|
||||
// a release with highlights has the toggle, a release without them does not.
|
||||
@@ -6,12 +6,9 @@ function releases(page: Page): Locator {
|
||||
return page.getByRole("region");
|
||||
}
|
||||
|
||||
// The router resets the scroll position when the page hydrates. A click sent before that is aimed
|
||||
// at where the control was, so wait for the page to be interactive: the language button is enabled
|
||||
// once it has hydrated.
|
||||
async function openChangelog(page: Page): Promise<void> {
|
||||
await page.goto("/changelog");
|
||||
await expect(page.getByRole("button", { name: /^Language/ }).first()).toBeEnabled();
|
||||
await waitForHydration(page);
|
||||
}
|
||||
|
||||
function releaseWithHighlights(page: Page): Locator {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { test as base } from "playwright/test";
|
||||
import { expect, test as base, type Page } from "playwright/test";
|
||||
|
||||
export { expect } from "playwright/test";
|
||||
export type { Locator, Page } from "playwright/test";
|
||||
|
||||
/**
|
||||
* Waits until the page has hydrated. Server-rendered content shows before that, but it has no
|
||||
* click handlers yet, and the router scrolls the window back to the top when it hydrates.
|
||||
* The language button stays disabled until the render after that reset.
|
||||
*/
|
||||
export async function waitForHydration(page: Page) {
|
||||
await expect(page.getByRole("button", { name: /^(Language|语言)/ }).first()).toBeEnabled();
|
||||
}
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page, baseURL }, run) => {
|
||||
page.on("response", async (response) => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { expect, test, type Locator, type Page } from "./fixtures";
|
||||
import { expect, test, waitForHydration, type Locator, type Page } from "./fixtures";
|
||||
|
||||
const desktopName =
|
||||
"Paseo desktop app with coding agents, a conversation, and a code diff open side by side";
|
||||
|
||||
async function openHomepage(page: Page) {
|
||||
await page.setViewportSize({ width: 1512, height: 930 });
|
||||
/** Opens the homepage once it has hydrated, so the router's scroll reset cannot undo a later scroll. */
|
||||
async function openHomepage(page: Page, window = { width: 1512, height: 930 }) {
|
||||
await page.setViewportSize(window);
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: "Parallel agents", exact: true })).toBeVisible();
|
||||
await waitForHydration(page);
|
||||
}
|
||||
|
||||
async function viewPhones(page: Page) {
|
||||
@@ -60,6 +61,225 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page })
|
||||
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
|
||||
});
|
||||
|
||||
/** Each hero pill and one thing only its finished scene draws. */
|
||||
const HERO_SCENES = [
|
||||
{ pill: "Orchestration", shows: "Pi · GLM-5.2" },
|
||||
{ pill: "Remote", shows: "Tailscale" },
|
||||
{ pill: "Code review", shows: "Feedback sent to Codex" },
|
||||
{ pill: "Pull requests", shows: "https://github.com/getpaseo/paseo/pull/3981" },
|
||||
{ pill: "Plugins", shows: "app-tests (macos)" },
|
||||
];
|
||||
|
||||
/** Clicks a hero pill until its scene starts. A click before hydration selects nothing. */
|
||||
async function playScene(page: Page, pill: string, opensWith: Locator) {
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: pill, exact: true }).click();
|
||||
await expect(opensWith).toBeVisible({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test("each hero pill shows its finished scene when motion is reduced", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await mockup.scrollIntoViewIfNeeded();
|
||||
// Last to first, so the opening scene is also reached by a click.
|
||||
for (const scene of HERO_SCENES.toReversed()) {
|
||||
await playScene(page, scene.pill, mockup.getByText(scene.shows, { exact: true }).first());
|
||||
}
|
||||
});
|
||||
|
||||
test("the hero opens on orchestration across providers and machines", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(page.getByRole("button", { name: "Orchestration", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
for (const agent of ["Codex · GPT-5.6-Sol", "OpenCode · Kimi K3", "Pi · GLM-5.2"]) {
|
||||
await expect(mockup.getByText(agent, { exact: true })).toBeVisible();
|
||||
}
|
||||
// The task on another machine carries that host's badge in its tool call and its subagent row.
|
||||
for (const remoteTask of ["OpenCode · fix/menu-bar-icon", "OpenCode · Kimi K3"]) {
|
||||
const row = mockup.getByText(remoteTask, { exact: true }).locator("xpath=..");
|
||||
await expect(row.getByText("mac-mini", { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("the orchestration scene plays from an empty chat to the open roster of subagents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const composer = mockup.getByText("Message the agent, tag @files, or use /commands and /skills");
|
||||
await playScene(page, "Orchestration", composer);
|
||||
await expect(mockup.getByText("Create agent", { exact: true })).toHaveCount(0);
|
||||
await expect(mockup.getByText("3 subagents", { exact: true })).toHaveCount(0);
|
||||
|
||||
// The orchestrator lists the issues and the agent profiles, launches three agents and says so,
|
||||
// then the pointer clicks the pill and the roster opens.
|
||||
await expect(mockup.getByText("3 subagents", { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(mockup.getByText("gh issue list --label ready", { exact: true })).toBeVisible();
|
||||
await expect(mockup.getByText("List agent profiles", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
mockup.getByText(
|
||||
"I will launch the UI agent on your mac-mini, because that issue is specific to macOS.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(mockup.getByText("Create agent", { exact: true })).toHaveCount(3);
|
||||
await expect(
|
||||
mockup.getByText("All three are launched. Paseo will notify me when each one finishes."),
|
||||
).toBeVisible();
|
||||
await expect(mockup.getByText("Pi · GLM-5.2", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the opening scene waits until most of the mockup is on screen", async ({ page }) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const request = mockup.getByText("Fix the issues labeled ready, one worktree each.");
|
||||
await expect(mockup).not.toBeInViewport({ ratio: 0.6 });
|
||||
|
||||
// A scene that started at load sends the request within three seconds.
|
||||
await page.waitForTimeout(4500);
|
||||
await expect(request).toHaveCount(0);
|
||||
|
||||
await mockup.scrollIntoViewIfNeeded();
|
||||
await expect(request).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the opening scene starts in a window shorter than most of the mockup", async ({ page }) => {
|
||||
await openHomepage(page, { width: 1400, height: 400 });
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const request = mockup.getByText("Fix the issues labeled ready, one worktree each.");
|
||||
await mockup.scrollIntoViewIfNeeded();
|
||||
await expect(mockup).not.toBeInViewport({ ratio: 0.6 });
|
||||
await expect(request).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the plugins scene installs the panel, then changes it on request", async ({ page }) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const changesPanel = mockup.getByText("Uncommitted", { exact: true });
|
||||
const pluginPanel = mockup.getByText("Recently merged", { exact: true });
|
||||
const failingCheck = mockup.getByText("app-tests (macos)", { exact: true });
|
||||
await playScene(page, "Plugins", page.getByRole("button", { name: "Plugins", pressed: true }));
|
||||
await expect(changesPanel).toBeVisible();
|
||||
await expect(pluginPanel).toHaveCount(0);
|
||||
|
||||
// Installing the plugin swaps the docked panel for the one the agent built.
|
||||
await expect(pluginPanel).toBeVisible({ timeout: 20_000 });
|
||||
await expect(changesPanel).toHaveCount(0);
|
||||
await expect(failingCheck).toHaveCount(0);
|
||||
|
||||
// The second request reloads the plugin and the open panel lists the failing check.
|
||||
await expect(failingCheck).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
mockup.getByText("paseo plugin reload release-radar", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("the code review scene comments on a line and sends the feedback to an agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const comment = mockup.getByText("Add “write”. The agent edits files too.", { exact: true });
|
||||
const sendFeedback = mockup.getByText("Send feedback (1)", { exact: true });
|
||||
await playScene(
|
||||
page,
|
||||
"Code review",
|
||||
page.getByRole("button", { name: "Code review", pressed: true }),
|
||||
);
|
||||
await expect(comment).toHaveCount(0);
|
||||
|
||||
// The pointer opens the editor on a line, and the typed comment is saved under that line.
|
||||
await expect(sendFeedback).toBeVisible({ timeout: 10_000 });
|
||||
await expect(comment).toBeVisible();
|
||||
|
||||
// Send feedback asks which agent, and the sent comment leaves the diff.
|
||||
await expect(mockup.getByText("Claude Code", { exact: true })).toHaveCount(2, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(mockup.getByText("Feedback sent to Codex", { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(sendFeedback).toHaveCount(0);
|
||||
await expect(comment).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("picking a scene brings the pills and the whole mockup on screen", async ({ page }) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(mockup).not.toBeInViewport({ ratio: 0.9 });
|
||||
|
||||
await playScene(page, "Remote", page.getByRole("button", { name: "Remote", pressed: true }));
|
||||
await expect(mockup).toBeInViewport({ ratio: 0.99 });
|
||||
await expect(page.getByRole("button", { name: "Remote", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("picking a scene in a window too short for the mockup keeps the pills on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomepage(page);
|
||||
await page.setViewportSize({ width: 1280, height: 600 });
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(mockup).not.toBeInViewport();
|
||||
|
||||
await playScene(page, "Remote", page.getByRole("button", { name: "Remote", pressed: true }));
|
||||
await expect(mockup).toBeInViewport({ ratio: 0.7 });
|
||||
await expect(page.getByRole("button", { name: "Remote", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("picking a scene while the hero is still sliding in keeps the pills on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Reduced motion makes the scroll instant, so the test does not wait for one to settle.
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await openHomepage(page, { width: 1280, height: 700 });
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await playScene(page, "Remote", page.getByRole("button", { name: "Remote", pressed: true }));
|
||||
|
||||
// The hero slides up 40px as it enters. The test waits for that to end, puts the same 40px back
|
||||
// on the container, picks a scene, and lets the slide end again. A click during a real slide
|
||||
// does the same, but only inside the first second after load.
|
||||
const sliding = page
|
||||
.getByRole("button", { name: "Remote", exact: true })
|
||||
.locator("xpath=../../../..");
|
||||
await expect(sliding).toHaveCSS("transform", "none");
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await expect(mockup).not.toBeInViewport({ ratio: 0.99 });
|
||||
const slideBy = (pixels: number) =>
|
||||
sliding.evaluate((container, y) => {
|
||||
container.style.transform = y === 0 ? "none" : `translateY(${y}px)`;
|
||||
}, pixels);
|
||||
|
||||
const codeReview = page.getByRole("button", { name: "Code review", exact: true });
|
||||
await slideBy(40);
|
||||
await codeReview.click();
|
||||
await slideBy(0);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
|
||||
await expect(codeReview).toBeInViewport({ ratio: 1 });
|
||||
});
|
||||
|
||||
test("the remote scene lists hosts with how each connects and its round trip time", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "Remote", exact: true }).click();
|
||||
await expect(mockup.getByText("Hosts", { exact: true })).toBeVisible({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
for (const fact of ["Local", "Relay", "Tailscale", "SSH", "38ms", "mo@workstation"]) {
|
||||
await expect(mockup.getByText(fact, { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
/** Opacity as rendered: the element's own times every ancestor's. */
|
||||
async function renderedOpacity(locator: Locator) {
|
||||
return locator.evaluate((element) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||
import { expect, test, type Page, type Locator } from "./fixtures";
|
||||
import { expect, test, waitForHydration, type Page, type Locator } from "./fixtures";
|
||||
import registry from "./registry.fixture.json" with { type: "json" };
|
||||
import { CATEGORIES } from "../src/plugins/categories";
|
||||
import copy from "../src/i18n/resources/en.json" with { type: "json" };
|
||||
@@ -9,12 +9,6 @@ async function openPlugins(page: Page) {
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
|
||||
}
|
||||
|
||||
async function waitForInteractivePage(page: Page) {
|
||||
// Server-rendered content is visible before hydration attaches its click handlers.
|
||||
// The language selector stays disabled until that page has hydrated.
|
||||
await expect(page.getByRole("button", { name: "Language: English" }).first()).toBeEnabled();
|
||||
}
|
||||
|
||||
function pluginCards(page: Page, section: string): Locator {
|
||||
return page
|
||||
.getByRole("region", { name: section })
|
||||
@@ -84,18 +78,18 @@ test("browses from the directory into a category, a plugin, and its author", asy
|
||||
});
|
||||
});
|
||||
await openPlugins(page);
|
||||
await waitForInteractivePage(page);
|
||||
await waitForHydration(page);
|
||||
|
||||
await page.getByRole("region", { name: "Categories" }).getByRole("link", { name: /Git/ }).click();
|
||||
await expect(page).toHaveURL(/\/plugins\/category\/git$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible();
|
||||
await waitForInteractivePage(page);
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("link", { name: /Fresh Worktrees/ }).click();
|
||||
await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/);
|
||||
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
|
||||
await waitForInteractivePage(page);
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||
@@ -192,7 +186,7 @@ test("keeps the directory's ranking window when searching", async ({ page }) =>
|
||||
|
||||
test("submits the visible search term when its input event was missed", async ({ page }) => {
|
||||
await page.goto("/plugins?window=month");
|
||||
await expect(page.getByRole("button", { name: "Language: English" }).first()).toBeEnabled();
|
||||
await waitForHydration(page);
|
||||
await fillSearchWithMissedInputEvent(page, "graphite");
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useState } from "react";
|
||||
import { motion, useInView, useReducedMotion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DEFAULT_MOCKUP_STATE,
|
||||
createMockupPlayer,
|
||||
DESIGN_HEIGHT,
|
||||
DESIGN_WIDTH,
|
||||
MOCKUP_STATES,
|
||||
type MockupStateId,
|
||||
MockupWindow,
|
||||
ScaledMockup,
|
||||
useMockupScene,
|
||||
} from "~/components/mockup";
|
||||
|
||||
const IN_VIEW = { once: true };
|
||||
/** How far down the mockup its start line sits. */
|
||||
const START_LINE = "60%";
|
||||
/** The space left between the top or bottom of the window and the pills or the mockup. */
|
||||
const PAGE_MARGIN = 16;
|
||||
const START_LINE_STYLE = { top: START_LINE };
|
||||
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
|
||||
|
||||
export function HeroMockup() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
|
||||
const [player] = useState(createMockupPlayer);
|
||||
const state = useMockupScene(player);
|
||||
|
||||
// The opening scene plays once most of the mockup is on screen, so a visitor who scrolls to it
|
||||
// sees it from the start. "Most" is a line 60% of the way down the mockup. A window shorter than
|
||||
// 60% of the mockup still reaches the line, which a share of the mockup on screen would not.
|
||||
// With reduced motion there is nothing to miss, so the finished scene shows at once.
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const startLine = useRef<HTMLDivElement>(null);
|
||||
const reduceMotion = useReducedMotion();
|
||||
const onScreen = useInView(startLine, IN_VIEW);
|
||||
const ready = onScreen || reduceMotion === true;
|
||||
useEffect(() => {
|
||||
if (ready) player.start();
|
||||
}, [ready, player]);
|
||||
useEffect(() => player.stop, [player]);
|
||||
|
||||
// The pills can sit above a mockup that is partly below the fold, most of all on a phone.
|
||||
// Picking a scene brings the pills and the mockup on screen together. A window too short for
|
||||
// both keeps the pills at its top edge, so the next pill is still in reach.
|
||||
const select = useCallback(
|
||||
(scene: MockupStateId) => {
|
||||
player.select(scene);
|
||||
if (frame.current) scrollOnScreen(frame.current, reduceMotion ? "auto" : "smooth");
|
||||
},
|
||||
[player, reduceMotion],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={frame}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
|
||||
{MOCKUP_STATES.map((option) => (
|
||||
<StatePill
|
||||
@@ -26,24 +59,54 @@ export function HeroMockup() {
|
||||
id={option}
|
||||
label={t(`landing.mockup.${option}`)}
|
||||
selected={option === state}
|
||||
onSelect={setState}
|
||||
onSelect={select}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScaledMockup
|
||||
width={DESIGN_WIDTH}
|
||||
height={DESIGN_HEIGHT}
|
||||
borderRadius={18}
|
||||
label={t("landing.mockup.alt")}
|
||||
className="ring-1 ring-white/10"
|
||||
>
|
||||
<MockupWindow state={state} />
|
||||
</ScaledMockup>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={startLine}
|
||||
aria-hidden="true"
|
||||
style={START_LINE_STYLE}
|
||||
className="pointer-events-none absolute inset-x-0 h-px"
|
||||
/>
|
||||
<ScaledMockup
|
||||
width={DESIGN_WIDTH}
|
||||
height={DESIGN_HEIGHT}
|
||||
borderRadius={18}
|
||||
label={t("landing.mockup.alt")}
|
||||
className="ring-1 ring-white/10"
|
||||
>
|
||||
<MockupWindow player={player} />
|
||||
</ScaledMockup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where an element sits in the window, from layout alone. A slide-in that is still running does not count. */
|
||||
function layoutRect(element: HTMLElement) {
|
||||
let top = -window.scrollY;
|
||||
for (let node: Element | null = element; node instanceof HTMLElement; node = node.offsetParent) {
|
||||
top += node.offsetTop;
|
||||
}
|
||||
return { top, bottom: top + element.offsetHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the least that shows all of an element, and never past the point where its top edge is
|
||||
* at the top of the window. The element is measured where it settles, because the hero slides up
|
||||
* as it enters and a click can come before it stops.
|
||||
*/
|
||||
function scrollOnScreen(element: HTMLElement, behavior: ScrollBehavior) {
|
||||
const { top, bottom } = layoutRect(element);
|
||||
const aboveWindow = top - PAGE_MARGIN;
|
||||
const belowWindow = bottom + PAGE_MARGIN - window.innerHeight;
|
||||
const distance = aboveWindow < 0 ? aboveWindow : Math.min(Math.max(belowWindow, 0), aboveWindow);
|
||||
if (distance !== 0) window.scrollBy({ top: distance, behavior });
|
||||
}
|
||||
|
||||
function StatePill({
|
||||
id,
|
||||
label,
|
||||
|
||||
@@ -714,8 +714,8 @@ function AutomationDetail({ kind }: { kind: AutomationKind }) {
|
||||
}
|
||||
|
||||
const MCP_AGENT_CALLS: ToolCall[] = [
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #412: retry flaky relay handshake" },
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #417: group schedules by project" },
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #412: relay handshake race" },
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #417: menu bar icon on macOS" },
|
||||
];
|
||||
|
||||
function McpAutomationTranscript() {
|
||||
|
||||
@@ -18,6 +18,13 @@ export function DiffStat({ add, remove }: { add: string; remove: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The text caret of an input that has focus. */
|
||||
export function Caret({ className }: { className: string }) {
|
||||
return (
|
||||
<span className={`inline-block h-[14px] w-px translate-y-[2px] bg-mock-fg ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
export type DotTone = "success" | "danger" | "warning" | "running" | "idle";
|
||||
|
||||
const DOT_TONE: Record<DotTone, string> = {
|
||||
|
||||
@@ -15,15 +15,19 @@ import {
|
||||
SquareTerminal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiffStat } from "./atoms";
|
||||
import { CodexIcon, PaseoMark } from "./icons";
|
||||
|
||||
// Icons passed as props are hoisted so they are not rebuilt on every render.
|
||||
const MODEL_ICON = <CodexIcon size={13} />;
|
||||
const EFFORT_ICON = <Brain size={13} />;
|
||||
const ACCESS_ICON = <ShieldOff size={13} />;
|
||||
import { Caret, DiffStat } from "./atoms";
|
||||
import { HostBadge } from "./hosts";
|
||||
import { PaseoMark } from "./icons";
|
||||
import { FADE_IN, FADE_OUT } from "./motion";
|
||||
import { DELEGATED_TASKS, SUBAGENTS_CLICK, tasksDelegated } from "./orchestration";
|
||||
import { useSceneClock } from "./player";
|
||||
import { Pointer } from "./pointer";
|
||||
import { PROVIDERS, type ProviderId } from "./providers";
|
||||
import { COMPOSER, type SceneScript, type ToolCall, type TranscriptEntry } from "./script";
|
||||
import type { MockupStateId } from "./states";
|
||||
|
||||
// --- transcript -------------------------------------------------------------
|
||||
|
||||
@@ -62,6 +66,38 @@ function TurnFooter({ duration }: { duration: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const BUILT_IN_LABELS = { read: "Read", write: "Write", shell: "Shell" } as const;
|
||||
|
||||
function toolCallLabel(call: ToolCall): string {
|
||||
return call.kind === "paseo" ? call.label : BUILT_IN_LABELS[call.kind];
|
||||
}
|
||||
|
||||
function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) {
|
||||
if (kind === "read") return <Eye size={14} />;
|
||||
if (kind === "write") return <Pencil size={14} />;
|
||||
if (kind === "paseo") return <PaseoMark size={14} />;
|
||||
return <SquareTerminal size={14} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool call row, drawn like the app's collapsed timeline item
|
||||
* (`packages/app/src/components/message.tsx`): icon, label, then the summary,
|
||||
* all muted and in the UI font. A Paseo call that targets another machine ends
|
||||
* with that host's badge, which the app's row does not draw.
|
||||
*/
|
||||
export function ToolCallRow({ call }: { call: ToolCall }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[8px] py-[5px] text-[12.5px] leading-[18px] text-mock-fg-muted">
|
||||
<span className="shrink-0">
|
||||
<ToolCallIcon kind={call.kind} />
|
||||
</span>
|
||||
<span className="shrink-0">{toolCallLabel(call)}</span>
|
||||
{call.detail ? <span className="min-w-0 truncate">{call.detail}</span> : null}
|
||||
{call.kind === "paseo" && call.host ? <HostBadge host={call.host} glyph={13} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentTranscript() {
|
||||
return (
|
||||
<div className="space-y-[13px] pt-[2px]">
|
||||
@@ -88,161 +124,273 @@ export function AgentTranscript() {
|
||||
<Bullet>Includes responsive mobile proof and the supporting homepage story.</Bullet>
|
||||
<Bullet>Formatting, typecheck, and lint are clean.</Bullet>
|
||||
</div>
|
||||
<Para>Ready for review.</Para>
|
||||
<TurnFooter duration="1m 18s" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed tool call as the app's timeline shows it. Built-in tools carry a
|
||||
* fixed label; Paseo MCP tools name their action, such as "Create agent".
|
||||
*/
|
||||
export type ToolCall =
|
||||
| { kind: "read" | "write" | "shell"; detail: string }
|
||||
| { kind: "paseo"; label: string; detail: string };
|
||||
|
||||
const BUILT_IN_LABELS = { read: "Read", write: "Write", shell: "Shell" } as const;
|
||||
|
||||
function toolCallLabel(call: ToolCall): string {
|
||||
return call.kind === "paseo" ? call.label : BUILT_IN_LABELS[call.kind];
|
||||
}
|
||||
|
||||
const PLUGIN_TOOL_CALLS: ToolCall[] = [
|
||||
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
|
||||
{ kind: "shell", detail: "paseo plugin init ~/dev/release-radar" },
|
||||
{ kind: "write", detail: "~/dev/release-radar/index.ts" },
|
||||
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
|
||||
{ kind: "shell", detail: "npm install && npm run typecheck" },
|
||||
{ kind: "shell", detail: "paseo plugin install ~/dev/release-radar" },
|
||||
];
|
||||
|
||||
const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
|
||||
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
|
||||
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
|
||||
|
||||
function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) {
|
||||
if (kind === "read") return <Eye size={14} />;
|
||||
if (kind === "write") return <Pencil size={14} />;
|
||||
if (kind === "paseo") return <PaseoMark size={14} />;
|
||||
return <SquareTerminal size={14} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool call row, drawn like the app's collapsed timeline item
|
||||
* (`packages/app/src/components/message.tsx`): icon, label, then the summary,
|
||||
* all muted and in the UI font.
|
||||
*/
|
||||
export function ToolCallRow({ call }: { call: ToolCall }) {
|
||||
/** Something that fades in when the script reaches it. */
|
||||
function Entering({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[8px] py-[5px] text-[12.5px] leading-[18px] text-mock-fg-muted">
|
||||
<span className="shrink-0">
|
||||
<ToolCallIcon kind={call.kind} />
|
||||
</span>
|
||||
<span className="shrink-0">{toolCallLabel(call)}</span>
|
||||
<span className="min-w-0 truncate">{call.detail}</span>
|
||||
</div>
|
||||
<motion.div initial={FADE_OUT} animate={FADE_IN}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginToolGroup({ calls }: { calls: ToolCall[] }) {
|
||||
interface ScriptedProps {
|
||||
scene: MockupStateId;
|
||||
script: SceneScript;
|
||||
}
|
||||
|
||||
function StreamedText({ scene, script, entry }: ScriptedProps & { entry: TranscriptEntry }) {
|
||||
const text = useSceneClock(scene, (ms) => script.streamed(entry, ms));
|
||||
return <Para>{text}</Para>;
|
||||
}
|
||||
|
||||
function LandedTools({
|
||||
scene,
|
||||
script,
|
||||
entry,
|
||||
calls,
|
||||
}: ScriptedProps & { entry: TranscriptEntry; calls: ToolCall[] }) {
|
||||
const landed = useSceneClock(scene, (ms) => script.landed(entry, ms));
|
||||
return (
|
||||
<div>
|
||||
{calls.map((call) => (
|
||||
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
|
||||
{calls.slice(0, landed).map((call) => (
|
||||
<Entering key={`${call.kind}:${call.detail}`}>
|
||||
<ToolCallRow call={call} />
|
||||
</Entering>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginBuildTranscript() {
|
||||
function TranscriptEntryView({ scene, script, entry }: ScriptedProps & { entry: TranscriptEntry }) {
|
||||
const { beat } = entry;
|
||||
if (beat.kind === "agent") return <StreamedText scene={scene} script={script} entry={entry} />;
|
||||
if (beat.kind === "tools") {
|
||||
return <LandedTools scene={scene} script={script} entry={entry} calls={beat.calls} />;
|
||||
}
|
||||
if (beat.kind === "user") {
|
||||
return (
|
||||
<Entering>
|
||||
<UserBubble>{beat.text}</UserBubble>
|
||||
</Entering>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Entering>
|
||||
<TurnFooter duration={beat.worked} />
|
||||
</Entering>
|
||||
);
|
||||
}
|
||||
|
||||
/** The transcript of a scripted scene, as far as its script has played. */
|
||||
export function ScriptedTranscript({ scene, script }: ScriptedProps) {
|
||||
const entered = useSceneClock(scene, script.entered);
|
||||
return (
|
||||
<div className="space-y-[13px] pt-[2px]">
|
||||
<UserBubble>
|
||||
Build me a release radar for Paseo. I want an explorer panel showing merged PRs, changes in
|
||||
review, failing checks, and seven-day activity, accessible from the sidebar.
|
||||
</UserBubble>
|
||||
<Para>
|
||||
I’ll scaffold a local plugin, add the explorer surface, and wire it into the sidebar.
|
||||
</Para>
|
||||
<PluginToolGroup calls={PLUGIN_DISCOVERY_CALLS} />
|
||||
<Para>
|
||||
The plugin API exposes Paseo’s live project and workspace state, so the radar
|
||||
doesn’t need a separate service.
|
||||
</Para>
|
||||
<PluginToolGroup calls={PLUGIN_BUILD_CALLS} />
|
||||
<Para>The surface is wired. I’m checking the project and installing it now.</Para>
|
||||
<PluginToolGroup calls={PLUGIN_INSTALL_CALLS} />
|
||||
<Para>Release Radar is live. It should now be visible in the right sidebar.</Para>
|
||||
<TurnFooter duration="2m 14s" />
|
||||
{script.entries.slice(0, entered).map((entry) => (
|
||||
<TranscriptEntryView key={entry.id} scene={scene} script={script} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- composer ---------------------------------------------------------------
|
||||
|
||||
function ControlPill({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
/** One composer control: its icon as the child, then the current value. */
|
||||
function ControlPill({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
|
||||
{icon}
|
||||
<span className="flex items-center gap-[6px] whitespace-nowrap text-[12px] text-mock-fg-muted">
|
||||
{children}
|
||||
{label}
|
||||
<ChevronDown size={11} className="text-mock-fg-xmuted" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A rounded pill that floats above the composer — task tracker, subagents, diff. */
|
||||
function ComposerPill({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* A rounded pill that floats above the composer — task tracker, subagents, diff. An open pill is
|
||||
* the one whose panel is showing.
|
||||
*/
|
||||
function ComposerPill({ children, open = false }: { children: React.ReactNode; open?: boolean }) {
|
||||
return (
|
||||
<span className="flex items-center gap-[6px] rounded-full border border-mock-border-accent px-[11px] py-[4px] text-[11.5px] text-mock-fg-muted">
|
||||
<span
|
||||
className={`flex items-center gap-[6px] rounded-full border border-mock-border-accent px-[11px] py-[4px] text-[11.5px] ${
|
||||
open ? "bg-mock-surface2 text-mock-fg" : "text-mock-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentsPill({ count, open = false }: { count: number; open?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ComposerPill open={open}>
|
||||
<CircleDot size={12} className="text-mock-dot-running" />
|
||||
{t("landing.mockup.app.subagents", { count })}
|
||||
</ComposerPill>
|
||||
);
|
||||
}
|
||||
|
||||
/** The row of pills above the composer input. */
|
||||
function ComposerTrack({ children }: { children: React.ReactNode }) {
|
||||
return <div className="pb-[13px]">{children}</div>;
|
||||
}
|
||||
|
||||
/** The trackers of a turn in progress: tasks, subagents, and the diff so far. */
|
||||
export function ProgressTrack() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ComposerTrack>
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<ComposerPill>
|
||||
{t("landing.mockup.app.tasksProgress", { completed: 6, total: 6 })}
|
||||
</ComposerPill>
|
||||
<SubagentsPill count={DELEGATED_TASKS.length} />
|
||||
<ComposerPill>
|
||||
<DiffStat add="1.9k" remove="684" />
|
||||
</ComposerPill>
|
||||
</div>
|
||||
</ComposerTrack>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentRow({ task }: { task: (typeof DELEGATED_TASKS)[number] }) {
|
||||
const provider = PROVIDERS[task.provider];
|
||||
return (
|
||||
<div className="flex items-start gap-[9px] px-[12px] py-[6px]">
|
||||
<span className="relative mt-[2px] block size-[14px] shrink-0 text-mock-fg-muted">
|
||||
<provider.Icon size={14} />
|
||||
<span className="absolute -right-[2px] -bottom-[2px] size-[6px] rounded-full bg-mock-dot-running ring-[1.5px] ring-mock-surface1" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] leading-[18px] text-mock-fg">{task.title}</span>
|
||||
<span className="flex items-center gap-[6px] text-[11.5px] leading-[16px] text-mock-fg-muted">
|
||||
<span>
|
||||
{provider.name} · {provider.model}
|
||||
</span>
|
||||
{task.host ? (
|
||||
<>
|
||||
<span className="text-mock-fg-xmuted">·</span>
|
||||
<HostBadge host={task.host} />
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Where the pointer sets off from, in design pixels from the spot it clicks.
|
||||
const POINTER_TO_PILL = { x: 236, y: -128 };
|
||||
const POINTER_TO_COMPOSER = { x: 230, y: -140 };
|
||||
|
||||
const ROSTER_CLOSED = { opacity: 0, scale: 0.97, y: 6 };
|
||||
const ROSTER_OPEN = { opacity: 1, scale: 1, y: 0 };
|
||||
|
||||
/**
|
||||
* The Subagents track of the orchestration scene. Its pill appears with the first subagent and
|
||||
* counts each one the orchestrator launches. When the pointer clicks the pill, the roster opens:
|
||||
* one row per agent, drawn like the app's track rows (`packages/app/src/subagents/track.tsx`).
|
||||
* The title is the task and the line under it names the provider and model. An agent on another
|
||||
* machine ends that line with the host's badge, where the app writes the host's name in plain text.
|
||||
*/
|
||||
export function SubagentsTrack() {
|
||||
const launched = useSceneClock("orchestrate", tasksDelegated);
|
||||
const pressed = useSceneClock("orchestrate", SUBAGENTS_CLICK.pressed);
|
||||
const open = useSceneClock("orchestrate", SUBAGENTS_CLICK.done);
|
||||
if (launched === 0) return null;
|
||||
return (
|
||||
<ComposerTrack>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={ROSTER_CLOSED}
|
||||
animate={ROSTER_OPEN}
|
||||
className="mb-[6px] w-fit min-w-[280px] origin-bottom-left rounded-[11px] border border-mock-border-accent bg-mock-surface1 py-[4px] shadow-[0_8px_24px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
{DELEGATED_TASKS.map((task) => (
|
||||
<SubagentRow key={task.id} task={task} />
|
||||
))}
|
||||
</motion.div>
|
||||
) : null}
|
||||
<Entering>
|
||||
<span className="relative flex w-fit">
|
||||
<SubagentsPill count={launched} open={pressed || open} />
|
||||
<Pointer
|
||||
scene="orchestrate"
|
||||
click={SUBAGENTS_CLICK}
|
||||
from={POINTER_TO_PILL}
|
||||
className="top-[58%] left-[91%]"
|
||||
/>
|
||||
</span>
|
||||
</Entering>
|
||||
</ComposerTrack>
|
||||
);
|
||||
}
|
||||
|
||||
/** The amber ring the app shows while a turn is streaming. */
|
||||
function BusyRing() {
|
||||
return <LoaderCircle size={14} className="text-mock-warning" strokeWidth={2.4} />;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
showProgress = true,
|
||||
busy = true,
|
||||
track,
|
||||
agent = "codex",
|
||||
busy = false,
|
||||
draft = "",
|
||||
focused = false,
|
||||
children,
|
||||
}: {
|
||||
showProgress?: boolean;
|
||||
/** The composer track shown above the input, such as `ProgressTrack`. It brings its own spacing. */
|
||||
track?: React.ReactNode;
|
||||
/** Which provider's agent this composer talks to. */
|
||||
agent?: ProviderId;
|
||||
busy?: boolean;
|
||||
/** What the user has typed and not yet sent. Empty shows the placeholder. */
|
||||
draft?: string;
|
||||
/** Whether the caret is in the input. */
|
||||
focused?: boolean;
|
||||
/** Something drawn over the input box, such as the pointer that clicks it. */
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const provider = PROVIDERS[agent];
|
||||
return (
|
||||
<div className="shrink-0 px-[27px] pt-[16px] pb-[17px]">
|
||||
<div className={showProgress ? "pb-[13px]" : ""}>
|
||||
{showProgress ? (
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<ComposerPill>
|
||||
{t("landing.mockup.app.tasksProgress", { completed: 6, total: 6 })}
|
||||
</ComposerPill>
|
||||
<ComposerPill>
|
||||
<CircleDot size={12} className="text-mock-dot-running" />
|
||||
{t("landing.mockup.app.subagents", { count: 3 })}
|
||||
</ComposerPill>
|
||||
<ComposerPill>
|
||||
<DiffStat add="1.9k" remove="684" />
|
||||
</ComposerPill>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-[11px] border border-mock-border-accent bg-mock-surface1">
|
||||
{track}
|
||||
<div className="relative rounded-[11px] border border-mock-border-accent bg-mock-surface1">
|
||||
{children}
|
||||
<div className="px-[14px] pt-[13px] pb-[12px]">
|
||||
<div className="flex items-start gap-[10px]">
|
||||
<span className="min-w-0 flex-1 text-[13px] text-mock-surface4">
|
||||
{t("landing.mockup.app.composerPlaceholder")}
|
||||
</span>
|
||||
{draft ? (
|
||||
<span className="min-w-0 flex-1 text-[13px] text-mock-fg">
|
||||
{draft}
|
||||
<Caret className="ml-px" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 text-[13px] text-mock-surface4">
|
||||
{/* The caret takes no room, so the placeholder does not move when it appears. */}
|
||||
{focused ? <Caret className="-mr-px" /> : null}
|
||||
{t("landing.mockup.app.composerPlaceholder")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-[17px] pt-[26px]">
|
||||
<Plus size={15} className="text-mock-fg-muted" />
|
||||
<ControlPill icon={MODEL_ICON} label="GPT-5.6-Sol" />
|
||||
<ControlPill icon={EFFORT_ICON} label="Medium" />
|
||||
<ControlPill icon={ACCESS_ICON} label="Full access" />
|
||||
<ControlPill label={provider.model}>
|
||||
<provider.Icon size={13} />
|
||||
</ControlPill>
|
||||
<ControlPill label="Medium">
|
||||
<Brain size={13} />
|
||||
</ControlPill>
|
||||
<ControlPill label="Full access">
|
||||
<ShieldOff size={13} />
|
||||
</ControlPill>
|
||||
<Zap size={14} className="text-mock-fg-muted" />
|
||||
<ListTodo size={14} className="text-mock-fg-muted" />
|
||||
<span className="flex-1" />
|
||||
@@ -255,3 +403,29 @@ export function Composer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer of a scripted scene. The pointer clicks it, which puts the caret in it. Then the
|
||||
* script types into it and says when a turn is running.
|
||||
*/
|
||||
export function ScriptedComposer({
|
||||
scene,
|
||||
script,
|
||||
agent,
|
||||
track,
|
||||
}: ScriptedProps & { agent: ProviderId; track?: React.ReactNode }) {
|
||||
const click = script.click(COMPOSER);
|
||||
const draft = useSceneClock(scene, script.draft);
|
||||
const busy = useSceneClock(scene, script.busy);
|
||||
const focused = useSceneClock(scene, (ms) => click.pressed(ms) || click.done(ms));
|
||||
return (
|
||||
<Composer agent={agent} track={track} busy={busy} draft={draft} focused={focused}>
|
||||
<Pointer
|
||||
scene={scene}
|
||||
click={click}
|
||||
from={POINTER_TO_COMPOSER}
|
||||
className="top-[22px] left-[168px]"
|
||||
/>
|
||||
</Composer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
} from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Code, DiffStat } from "./atoms";
|
||||
import { TABBAR_H } from "./geometry";
|
||||
@@ -29,7 +31,7 @@ const SUBHEADER_STYLE = { height: TABBAR_H };
|
||||
export type DiffRow =
|
||||
| { kind: "file"; name: string; path?: string; add: string; remove: string }
|
||||
| { kind: "hunk"; label: string }
|
||||
| { kind: "line"; n: string; text: string; mark?: DiffMark }
|
||||
| { kind: "line"; n: string; text: string; mark?: DiffMark; reviewed?: boolean }
|
||||
| { kind: "scrollbar" }
|
||||
| { kind: "gap" };
|
||||
|
||||
@@ -47,16 +49,35 @@ const NUMBER_COLOR: Record<DiffMark, string> = {
|
||||
remove: "text-mock-danger",
|
||||
};
|
||||
|
||||
function DiffLine({ n, text, mark }: { n: string; text: string; mark?: DiffMark }) {
|
||||
/** What a review adds to the line it comments on. Rows mark that line with `reviewed`. */
|
||||
export interface LineReview {
|
||||
/** Drawn on the line's gutter, such as the button that adds a comment. */
|
||||
action: React.ReactNode;
|
||||
/** Drawn under the line: the thread of comments. */
|
||||
thread: React.ReactNode;
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
n,
|
||||
text,
|
||||
mark,
|
||||
action,
|
||||
}: {
|
||||
n: string;
|
||||
text: string;
|
||||
mark?: DiffMark;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
const fill = mark ? ROW_FILL[mark] : "";
|
||||
const number = mark ? NUMBER_COLOR[mark] : "text-mock-fg-muted";
|
||||
return (
|
||||
<div className={`flex items-center ${fill}`} style={ROW_STYLE}>
|
||||
<span
|
||||
className={`flex h-full shrink-0 items-center justify-end border-r border-mock-border pr-[10px] font-mono text-[11px] tabular-nums ${number}`}
|
||||
className={`relative flex h-full shrink-0 items-center justify-end border-r border-mock-border pr-[10px] font-mono text-[11px] tabular-nums ${number}`}
|
||||
style={GUTTER_STYLE}
|
||||
>
|
||||
{n}
|
||||
{action}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden whitespace-pre pl-[10px] font-mono text-[12px] leading-[18.5px]">
|
||||
<Code line={text} />
|
||||
@@ -88,7 +109,15 @@ function FileRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffRows({ rows, flush }: { rows: DiffRow[]; flush?: boolean }) {
|
||||
export function DiffRows({
|
||||
rows,
|
||||
flush,
|
||||
review,
|
||||
}: {
|
||||
rows: DiffRow[];
|
||||
flush?: boolean;
|
||||
review?: LineReview;
|
||||
}) {
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-mock-surface1 ${flush ? "" : "min-h-0 flex-1"}`}>
|
||||
{rows.map((row, index) => {
|
||||
@@ -110,6 +139,14 @@ export function DiffRows({ rows, flush }: { rows: DiffRow[]; flush?: boolean })
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
if (row.reviewed && review) {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<DiffLine {...row} action={review.action} />
|
||||
{review.thread}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return <DiffLine key={key} {...row} />;
|
||||
})}
|
||||
{!flush ? (
|
||||
@@ -333,6 +370,7 @@ export const REVIEW_DIFF_ROWS: DiffRow[] = [
|
||||
n: "23",
|
||||
text: 'type ToolKind = "read" | "shell";',
|
||||
mark: "add",
|
||||
reviewed: true,
|
||||
},
|
||||
{ kind: "line", n: "24", text: "" },
|
||||
{
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
* plain px number you can reason about directly, and nothing has to change when
|
||||
* the hero column gets wider or narrower.
|
||||
*
|
||||
* The numbers come from measuring public/homepage-hero.png (a 2x capture of a
|
||||
* 2249x1289 window region) and dividing by 1.5617 so the window lands at 1440
|
||||
* wide — the widest the hero column ever renders it.
|
||||
* The window is 1280 wide, the widest the hero column ever renders it, so on a
|
||||
* wide screen one design pixel is one CSS pixel and the text is as large as the
|
||||
* app's. The type and control sizes come from measuring public/homepage-hero.png
|
||||
* (a 2x capture of a 2249x1289 window region) and dividing by 1.5617.
|
||||
*/
|
||||
export const DESIGN_WIDTH = 1440;
|
||||
export const DESIGN_HEIGHT = 826;
|
||||
export const DESIGN_WIDTH = 1280;
|
||||
export const DESIGN_HEIGHT = 734;
|
||||
|
||||
export const TITLEBAR_H = 39;
|
||||
export const TABBAR_H = 31;
|
||||
export const SIDEBAR_W = 262;
|
||||
export const RIGHT_PANEL_W = 424;
|
||||
// The docked panel is as wide as the file tree needs. A pull request or a plugin panel gets more room.
|
||||
export const RIGHT_PANEL_W = 300;
|
||||
export const RIGHT_PANEL_WIDE_W = 424;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Computer, Globe, Laptop, Monitor, PcCase, Plus, Server, Terminal, X } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TailscaleMark } from "./icons";
|
||||
|
||||
/**
|
||||
* The machines the mockup's workspaces live on. A host's name is the label its owner gave it, and
|
||||
* its color is the identity color they picked for its badge. The local machine draws no badge, as
|
||||
* a host set to hide its badge does in the app.
|
||||
*/
|
||||
export type RemoteHostId = "devbox" | "mac-mini";
|
||||
|
||||
/** Badge ink per host, as the app's `HostBadge` paints glyph and label in one identity color. */
|
||||
const HOST_TONE: Record<RemoteHostId, string> = {
|
||||
devbox: "text-mock-host-teal",
|
||||
"mac-mini": "text-mock-host-violet",
|
||||
};
|
||||
|
||||
/**
|
||||
* The mark that says which machine something runs on: the server glyph and the host's name in its
|
||||
* identity color. It inherits its text size, so it sits in a sidebar row, a tool call, or a
|
||||
* subagent row alike.
|
||||
*/
|
||||
export function HostBadge({ host, glyph = 12 }: { host: RemoteHostId; glyph?: number }) {
|
||||
return (
|
||||
<span className={`flex shrink-0 items-center gap-[4px] ${HOST_TONE[host]}`}>
|
||||
<Server size={glyph} className="shrink-0" />
|
||||
{host}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- hosts modal --------------------------------------------------------------
|
||||
|
||||
type IconComponent = React.ComponentType<{ size?: number; className?: string }>;
|
||||
|
||||
/** How the app reaches a host. Tailscale is a direct TCP connection to a tailnet name. */
|
||||
type HostLink = "local" | "relay" | "tailscale" | "ssh";
|
||||
|
||||
const LINK_ICON: Record<HostLink, IconComponent> = {
|
||||
local: Monitor,
|
||||
relay: Globe,
|
||||
tailscale: TailscaleMark,
|
||||
ssh: Terminal,
|
||||
};
|
||||
|
||||
interface ConnectedHost {
|
||||
name: string;
|
||||
device: IconComponent;
|
||||
tone: string;
|
||||
link: HostLink;
|
||||
/** The address in use. The local daemon has none worth showing. */
|
||||
endpoint?: string;
|
||||
/** Round trip time, formatted as the app's `formatLatency` prints it. */
|
||||
latency: string;
|
||||
agents: number;
|
||||
}
|
||||
|
||||
const CONNECTED_HOSTS: ConnectedHost[] = [
|
||||
{
|
||||
name: "MacBook Pro",
|
||||
device: Laptop,
|
||||
tone: "text-mock-fg-muted",
|
||||
link: "local",
|
||||
latency: "1ms",
|
||||
agents: 4,
|
||||
},
|
||||
{
|
||||
name: "devbox",
|
||||
device: Server,
|
||||
tone: HOST_TONE.devbox,
|
||||
link: "relay",
|
||||
endpoint: "relay.paseo.sh",
|
||||
latency: "38ms",
|
||||
agents: 6,
|
||||
},
|
||||
{
|
||||
name: "mac-mini",
|
||||
device: Computer,
|
||||
tone: HOST_TONE["mac-mini"],
|
||||
link: "tailscale",
|
||||
endpoint: "mac-mini:6767",
|
||||
latency: "12ms",
|
||||
agents: 3,
|
||||
},
|
||||
{
|
||||
name: "workstation",
|
||||
device: PcCase,
|
||||
tone: "text-mock-host-orange",
|
||||
link: "ssh",
|
||||
endpoint: "mo@workstation",
|
||||
latency: "24ms",
|
||||
agents: 2,
|
||||
},
|
||||
];
|
||||
|
||||
function HostRow({ host }: { host: ConnectedHost }) {
|
||||
const { t } = useTranslation();
|
||||
const Device = host.device;
|
||||
const LinkIcon = LINK_ICON[host.link];
|
||||
return (
|
||||
<div className="flex items-center gap-[13px] border-t border-mock-border px-[18px] py-[13px]">
|
||||
<span
|
||||
className={`relative flex size-[36px] shrink-0 items-center justify-center rounded-[9px] bg-mock-surface2 ${host.tone}`}
|
||||
>
|
||||
<Device size={18} />
|
||||
<span className="absolute -right-[2px] -bottom-[2px] size-[9px] rounded-full bg-mock-dot-success ring-2 ring-mock-surface1" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13.5px] leading-[18px] text-mock-fg">
|
||||
{host.name}
|
||||
</span>
|
||||
<span className="mt-[3px] flex items-center gap-[6px] text-[12px] leading-[16px] text-mock-fg-muted">
|
||||
<LinkIcon size={12} className="shrink-0" />
|
||||
<span className="text-mock-fg">{t(`landing.mockup.app.hostLinks.${host.link}`)}</span>
|
||||
{host.endpoint ? <span className="min-w-0 truncate">{host.endpoint}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block text-[13px] leading-[18px] text-mock-fg tabular-nums">
|
||||
{host.latency}
|
||||
</span>
|
||||
<span className="mt-[3px] block text-[12px] leading-[16px] text-mock-fg-muted">
|
||||
{t("landing.mockup.app.agentCount", { count: host.agents })}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every machine this window is connected to: what kind of machine it is, how the app reaches it,
|
||||
* the round trip time, and how many agents it runs.
|
||||
*/
|
||||
export function HostsModal() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="w-[468px] overflow-hidden rounded-[14px] border border-mock-border-accent bg-mock-surface1 shadow-[0_24px_64px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex h-[46px] items-center justify-between px-[18px]">
|
||||
<span className="text-[14px] font-medium text-mock-fg">
|
||||
{t("landing.mockup.app.hosts")}
|
||||
</span>
|
||||
<X size={15} className="text-mock-fg-muted" />
|
||||
</div>
|
||||
{CONNECTED_HOSTS.map((host) => (
|
||||
<HostRow key={host.name} host={host} />
|
||||
))}
|
||||
<div className="flex h-[46px] items-center gap-[13px] border-t border-mock-border px-[18px] text-[13px] text-mock-fg">
|
||||
{/* The plus sits on the center line of the device tiles above it. */}
|
||||
<span className="flex w-[36px] shrink-0 justify-center">
|
||||
<Plus size={15} className="text-mock-fg-muted" />
|
||||
</span>
|
||||
{t("landing.mockup.app.addHost")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Exact SVG paths from packages/app/src/components/icons/
|
||||
|
||||
import { OpenCodeIcon as OpenCodeBrandIcon, PiIcon as PiBrandIcon } from "../agent-icons";
|
||||
|
||||
/** The Paseo brand swirl — public/logo.svg, drawn as a small sidebar avatar mark. */
|
||||
export function PaseoMark({ size = 13, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
@@ -112,3 +114,65 @@ export function SourceControlIcon({ size = 14, className }: { size?: number; cla
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenCodeIcon({ size = 13, className }: { size?: number; className?: string }) {
|
||||
return <OpenCodeBrandIcon width={size} height={size} className={className} />;
|
||||
}
|
||||
|
||||
export function PiIcon({ size = 13, className }: { size?: number; className?: string }) {
|
||||
return <PiBrandIcon width={size} height={size} className={className} />;
|
||||
}
|
||||
|
||||
const TAILSCALE_DOTS = [3, 12, 21].flatMap((cy, row) =>
|
||||
[3, 12, 21].map((cx, column) => ({
|
||||
cx,
|
||||
cy,
|
||||
// The mark is a T: the middle row and the dot under its center are solid.
|
||||
solid: row === 1 || (row === 2 && column === 1),
|
||||
})),
|
||||
);
|
||||
|
||||
/** The Tailscale mark: a three by three dot grid with a solid T. */
|
||||
export function TailscaleMark({ size = 12, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
{TAILSCALE_DOTS.map((dot) => (
|
||||
<circle
|
||||
key={`${dot.cx}:${dot.cy}`}
|
||||
cx={dot.cx}
|
||||
cy={dot.cy}
|
||||
r={3}
|
||||
opacity={dot.solid ? 1 : 0.3}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** A mouse pointer. Its tip is the top left corner of the glyph. */
|
||||
export function PointerArrow({ size = 24, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
className={`drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)] ${className ?? ""}`}
|
||||
>
|
||||
<path
|
||||
d="M1 1v14.6l3.9-3.7 2.7 6.1 2.7-1.2-2.7-6h5.5z"
|
||||
fill="#0b0b0b"
|
||||
stroke="#fff"
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
|
||||
export { type ToolCall, ToolCallRow } from "./chat";
|
||||
export { ToolCallRow } from "./chat";
|
||||
export type { ToolCall } from "./script";
|
||||
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
|
||||
export { PhoneFrame } from "./mobile/frame";
|
||||
export { MobileChat } from "./mobile/chat";
|
||||
export { MobileDiff } from "./mobile/diff";
|
||||
export { MobileSidebar } from "./mobile/sidebar";
|
||||
export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states";
|
||||
export { MockupWindow } from "./window";
|
||||
export { MOCKUP_STATES, type MockupStateId } from "./states";
|
||||
export { createMockupPlayer, MockupWindow, useMockupScene } from "./window";
|
||||
export { ScaledMockup } from "./scaled-mockup";
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RemoteHostId } from "./hosts";
|
||||
import { PROVIDERS, type ProviderId } from "./providers";
|
||||
import { COMPOSER, script, type ToolCall } from "./script";
|
||||
|
||||
/**
|
||||
* The work the orchestrating agent hands out in the orchestration scene. The sidebar draws one
|
||||
* workspace per task and the chat draws the tool calls and the subagent rows from the same list.
|
||||
*/
|
||||
export const DELEGATED_TASKS: {
|
||||
id: string;
|
||||
title: string;
|
||||
branch: string;
|
||||
provider: Exclude<ProviderId, "claude">;
|
||||
host?: RemoteHostId;
|
||||
}[] = [
|
||||
{
|
||||
id: "issue-412",
|
||||
title: "Fix #412: relay handshake race",
|
||||
branch: "fix/relay-handshake-race",
|
||||
provider: "codex",
|
||||
},
|
||||
{
|
||||
id: "issue-417",
|
||||
title: "Fix #417: menu bar icon on macOS",
|
||||
branch: "fix/menu-bar-icon",
|
||||
provider: "opencode",
|
||||
host: "mac-mini",
|
||||
},
|
||||
{
|
||||
id: "issue-421",
|
||||
title: "Fix #421: stale Docker setup docs",
|
||||
branch: "docs/docker-setup",
|
||||
provider: "pi",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The orchestrator's Paseo tool calls, one per delegated task. One call creates the worktree
|
||||
* workspace and starts the agent in it.
|
||||
*/
|
||||
const DELEGATION_CALLS: ToolCall[] = DELEGATED_TASKS.map((task) => ({
|
||||
kind: "paseo",
|
||||
label: "Create agent",
|
||||
detail: `${PROVIDERS[task.provider].name} · ${task.branch}`,
|
||||
host: task.host,
|
||||
}));
|
||||
|
||||
/**
|
||||
* What the orchestrator looks up before it delegates: the issues to fix, and the agent profiles
|
||||
* the user set up, which say which model takes which kind of work.
|
||||
*/
|
||||
const LOOKUP_CALLS: ToolCall[] = [
|
||||
{ kind: "shell", detail: "gh issue list --label ready" },
|
||||
{ kind: "paseo", label: "List agent profiles" },
|
||||
];
|
||||
|
||||
const REQUEST = "Fix the issues labeled ready, one worktree each.";
|
||||
|
||||
/**
|
||||
* The orchestration scene: the user asks Claude to fix the ready issues, each in a worktree. Claude
|
||||
* lists the issues and the user's agent profiles, picks a model for each issue, and puts the one
|
||||
* that needs macOS on another machine. It says that Paseo will notify it when the agents finish.
|
||||
* Then the pointer opens the subagents roster.
|
||||
*/
|
||||
export const ORCHESTRATION = script([
|
||||
{ kind: "wait", ms: 200 },
|
||||
{ kind: "click", target: COMPOSER },
|
||||
{ kind: "wait", ms: 250 },
|
||||
{ kind: "type", text: REQUEST },
|
||||
{ kind: "user", text: REQUEST },
|
||||
{ kind: "tools", calls: LOOKUP_CALLS },
|
||||
{
|
||||
kind: "agent",
|
||||
text: `I loaded your orchestration preferences: ${PROVIDERS.codex.model} for the hard bug, ${PROVIDERS.opencode.model} for UI, ${PROVIDERS.pi.model} for docs.`,
|
||||
},
|
||||
{
|
||||
kind: "agent",
|
||||
text: "I will launch the UI agent on your mac-mini, because that issue is specific to macOS.",
|
||||
},
|
||||
{ kind: "tools", calls: DELEGATION_CALLS },
|
||||
{
|
||||
kind: "agent",
|
||||
text: "All three are launched. Paseo will notify me when each one finishes.",
|
||||
},
|
||||
// No footer: with it, the transcript and the open roster do not both fit in the pane.
|
||||
{ kind: "turnEnd" },
|
||||
{ kind: "click", target: "subagents" },
|
||||
]);
|
||||
|
||||
/** The pointer's click on the subagents pill. It opens the roster of subagents. */
|
||||
export const SUBAGENTS_CLICK = ORCHESTRATION.click("subagents");
|
||||
|
||||
/**
|
||||
* How many tasks the orchestrator delegated so far. Each one adds a workspace to the sidebar and a
|
||||
* subagent to the composer's pill.
|
||||
*/
|
||||
export function tasksDelegated(ms: number): number {
|
||||
const delegation = ORCHESTRATION.entries.find(
|
||||
(entry) => entry.beat.kind === "tools" && entry.beat.calls === DELEGATION_CALLS,
|
||||
);
|
||||
return delegation ? ORCHESTRATION.landed(delegation, ms) : 0;
|
||||
}
|
||||
@@ -1,15 +1,86 @@
|
||||
import { DiffRows, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
|
||||
import { DiffRows, type LineReview, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
|
||||
import { AddCommentAction, ReviewFeedback, ReviewThread } from "./review-thread";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// --- review -----------------------------------------------------------------
|
||||
|
||||
/** The selected Changes-tree file, open in its own workspace tab. */
|
||||
const LINE_REVIEW: LineReview = { action: <AddCommentAction />, thread: <ReviewThread /> };
|
||||
|
||||
/**
|
||||
* The selected Changes-tree file, open in its own workspace tab. The review scene comments on one
|
||||
* of its lines and sends that feedback to an agent.
|
||||
*/
|
||||
export function DiffReviewPane() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col bg-mock-surface0">
|
||||
<ScopeRow label={t("landing.mockup.app.uncommitted")} />
|
||||
<DiffRows rows={REVIEW_DIFF_ROWS} />
|
||||
<DiffRows rows={REVIEW_DIFF_ROWS} review={LINE_REVIEW} />
|
||||
<ReviewFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- remote -----------------------------------------------------------------
|
||||
|
||||
function ShellPrompt() {
|
||||
return (
|
||||
<>
|
||||
<span className="text-mock-term-green">mo@devbox</span>:
|
||||
<span className="text-mock-term-blue">~/dev/paseo</span>${" "}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ViteArrow() {
|
||||
return <span className="text-mock-term-green">{" ➜ "}</span>;
|
||||
}
|
||||
|
||||
function HmrLine({ time, file }: { time: string; file: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-mock-fg-xmuted">{time}</span>{" "}
|
||||
<span className="text-mock-term-cyan">[vite]</span>{" "}
|
||||
<span className="text-mock-term-green">hmr update</span>{" "}
|
||||
<span className="text-mock-fg-muted">{file}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace's terminal tab. The workspace lives on devbox, so the shell does too: the prompt
|
||||
* names the remote machine while the pane sits in the same window as every local workspace.
|
||||
*/
|
||||
export function RemoteTerminalPane() {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-hidden whitespace-pre bg-mock-surface0 px-[16px] py-[12px] font-mono text-[12px] leading-[18.5px] text-mock-fg">
|
||||
<div>
|
||||
<ShellPrompt />
|
||||
npm run dev
|
||||
</div>
|
||||
<div> </div>
|
||||
<div className="text-mock-fg-muted">> @getpaseo/website dev</div>
|
||||
<div className="text-mock-fg-muted">> vite</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
{" "}
|
||||
<span className="text-mock-term-green">VITE v7.3.3</span>
|
||||
<span className="text-mock-fg-muted">{" ready in "}</span>412 ms
|
||||
</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
<ViteArrow />
|
||||
{"Local: "}
|
||||
<span className="text-mock-term-cyan">http://localhost:3000/</span>
|
||||
</div>
|
||||
<div className="text-mock-fg-muted">
|
||||
<ViteArrow />
|
||||
press h + enter to show help
|
||||
</div>
|
||||
<div> </div>
|
||||
<HmrLine time="6:53:12 PM" file="/src/components/hero-mockup.tsx" />
|
||||
<HmrLine time="6:53:40 PM" file="/src/components/mockup/window.tsx" />
|
||||
<HmrLine time="6:54:07 PM" file="/src/styles.css" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createScenePlayer, type Frames } from "./player";
|
||||
|
||||
/** A page clock that moves only when the test turns it. */
|
||||
function handClock() {
|
||||
let time = 0;
|
||||
let nextId = 0;
|
||||
const waiting = new Map<number, (now: number) => void>();
|
||||
const frames: Frames = {
|
||||
now: () => time,
|
||||
request(callback) {
|
||||
nextId += 1;
|
||||
waiting.set(nextId, callback);
|
||||
return nextId;
|
||||
},
|
||||
cancel: (id) => waiting.delete(id),
|
||||
reducedMotion: () => false,
|
||||
};
|
||||
return {
|
||||
frames,
|
||||
/** Moves the clock on and runs the frames that were waiting for it. */
|
||||
advance(ms: number) {
|
||||
time += ms;
|
||||
const due = [...waiting.values()];
|
||||
waiting.clear();
|
||||
for (const callback of due) callback(time);
|
||||
},
|
||||
framesWaiting: () => waiting.size,
|
||||
};
|
||||
}
|
||||
|
||||
function setUp() {
|
||||
const clock = handClock();
|
||||
const player = createScenePlayer("orchestrate", { orchestrate: 1000, review: 500 }, clock.frames);
|
||||
return { clock, player };
|
||||
}
|
||||
|
||||
describe("scene player", () => {
|
||||
it("plays the opening scene from the start when the page starts it", () => {
|
||||
const { clock, player } = setUp();
|
||||
player.start();
|
||||
clock.advance(16);
|
||||
clock.advance(16);
|
||||
expect(player.elapsed("orchestrate")).toBe(32);
|
||||
});
|
||||
|
||||
it("keeps the place of a scene that a pill click started before the page did", () => {
|
||||
const { clock, player } = setUp();
|
||||
player.select("review");
|
||||
clock.advance(16);
|
||||
clock.advance(16);
|
||||
expect(player.elapsed("review")).toBe(32);
|
||||
|
||||
player.start();
|
||||
expect(player.scene()).toBe("review");
|
||||
expect(player.elapsed("review")).toBe(32);
|
||||
expect(player.elapsed("orchestrate")).toBe(0);
|
||||
});
|
||||
|
||||
it("stops the clock", () => {
|
||||
const { clock, player } = setUp();
|
||||
player.start();
|
||||
clock.advance(16);
|
||||
expect(clock.framesWaiting()).toBe(1);
|
||||
|
||||
player.stop();
|
||||
expect(clock.framesWaiting()).toBe(0);
|
||||
clock.advance(16);
|
||||
expect(player.elapsed("orchestrate")).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createContext, useContext, useSyncExternalStore } from "react";
|
||||
import type { MockupStateId } from "./states";
|
||||
|
||||
/**
|
||||
* Which scene the mockup shows and how far into its script each scene is. The player owns the
|
||||
* clock. Components read the time through `useSceneClock` and never keep time themselves.
|
||||
*/
|
||||
export interface ScenePlayer {
|
||||
scene(): MockupStateId;
|
||||
/** Milliseconds into a scene's script. Each scene keeps its own, so a scene that is fading out holds its last frame. */
|
||||
elapsed(scene: MockupStateId): number;
|
||||
/** Shows a scene and plays its script from the start. */
|
||||
select(scene: MockupStateId): void;
|
||||
/**
|
||||
* Plays the opening scene from the start, unless a scene has played already. A pill click can
|
||||
* come before the page decides to start, and that scene keeps its place.
|
||||
*/
|
||||
start(): void;
|
||||
/** Stops the clock. A scene that is playing holds its last frame. */
|
||||
stop(): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
// A background tab stops animation frames. On return the script resumes where it paused.
|
||||
const LONGEST_FRAME_MS = 64;
|
||||
|
||||
/** What the player needs from the page to keep time. Tests pass a clock they turn by hand. */
|
||||
export interface Frames {
|
||||
now(): number;
|
||||
request(callback: (now: number) => void): number;
|
||||
cancel(id: number): void;
|
||||
reducedMotion(): boolean;
|
||||
}
|
||||
|
||||
const browserFrames: Frames = {
|
||||
now: () => performance.now(),
|
||||
request: (callback) => requestAnimationFrame(callback),
|
||||
cancel: (id) => cancelAnimationFrame(id),
|
||||
// Visitors who ask for reduced motion get the finished scene.
|
||||
reducedMotion: () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param durations How long each scene's script runs. A scene without a script has no entry and is
|
||||
* always at its finished state.
|
||||
*/
|
||||
export function createScenePlayer(
|
||||
initial: MockupStateId,
|
||||
durations: Partial<Record<MockupStateId, number>>,
|
||||
frames: Frames = browserFrames,
|
||||
): ScenePlayer {
|
||||
let scene = initial;
|
||||
const times = new Map<MockupStateId, number>();
|
||||
const listeners = new Set<() => void>();
|
||||
let frame = 0;
|
||||
let lastFrameAt = 0;
|
||||
let played = false;
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (frame) frames.cancel(frame);
|
||||
frame = 0;
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
const end = durations[scene] ?? 0;
|
||||
const next = Math.min(
|
||||
end,
|
||||
(times.get(scene) ?? 0) + Math.min(now - lastFrameAt, LONGEST_FRAME_MS),
|
||||
);
|
||||
lastFrameAt = now;
|
||||
times.set(scene, next);
|
||||
frame = next < end ? frames.request(tick) : 0;
|
||||
emit();
|
||||
}
|
||||
|
||||
function play() {
|
||||
played = true;
|
||||
stop();
|
||||
const end = durations[scene] ?? 0;
|
||||
const animated = end > 0 && !frames.reducedMotion();
|
||||
times.set(scene, animated ? 0 : end);
|
||||
if (animated) {
|
||||
lastFrameAt = frames.now();
|
||||
frame = frames.request(tick);
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
return {
|
||||
scene: () => scene,
|
||||
elapsed: (of) => times.get(of) ?? 0,
|
||||
select(next) {
|
||||
scene = next;
|
||||
play();
|
||||
},
|
||||
start() {
|
||||
if (!played) play();
|
||||
},
|
||||
stop,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ScenePlayerContext = createContext<ScenePlayer | null>(null);
|
||||
|
||||
/** The scene a player shows. */
|
||||
export function usePlayerScene(player: ScenePlayer): MockupStateId {
|
||||
return useSyncExternalStore(player.subscribe, player.scene, player.scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one fact about a scene at its current time, such as how many tool calls have landed.
|
||||
* The component renders again only when that fact changes, so `select` must return a primitive.
|
||||
*/
|
||||
export function useSceneClock<Fact extends string | number | boolean>(
|
||||
scene: MockupStateId,
|
||||
select: (ms: number) => Fact,
|
||||
): Fact {
|
||||
const player = useContext(ScenePlayerContext);
|
||||
if (!player) throw new Error("useSceneClock needs a ScenePlayerContext");
|
||||
const read = () => select(player.elapsed(scene));
|
||||
return useSyncExternalStore(player.subscribe, read, read);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { COMPOSER, script } from "./script";
|
||||
|
||||
const BUILD_REQUEST = "Build a release radar panel for merged PRs and failing checks.";
|
||||
const CHANGE_REQUEST = "Show which check is failing.";
|
||||
|
||||
/**
|
||||
* The plugins scene: the user asks for a panel, the agent builds and installs a plugin, and the
|
||||
* panel opens. Then the user asks for a change and the open panel shows it.
|
||||
*/
|
||||
export const PLUGIN_BUILD = script([
|
||||
{ kind: "wait", ms: 200 },
|
||||
{ kind: "click", target: COMPOSER },
|
||||
{ kind: "wait", ms: 250 },
|
||||
{ kind: "type", text: BUILD_REQUEST },
|
||||
{ kind: "user", text: BUILD_REQUEST },
|
||||
{
|
||||
kind: "agent",
|
||||
text: "I’ll scaffold a local plugin, add the explorer surface, and wire it into the sidebar.",
|
||||
},
|
||||
{
|
||||
kind: "tools",
|
||||
calls: [
|
||||
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
|
||||
{ kind: "shell", detail: "paseo plugin init ~/dev/release-radar" },
|
||||
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
|
||||
{ kind: "shell", detail: "paseo plugin install ~/dev/release-radar" },
|
||||
],
|
||||
},
|
||||
{ kind: "mark", name: "installed", ms: 500 },
|
||||
{ kind: "agent", text: "Release Radar is live. It should now be visible in the right sidebar." },
|
||||
{ kind: "turnEnd", worked: "2m 14s" },
|
||||
{ kind: "wait", ms: 900 },
|
||||
{ kind: "type", text: CHANGE_REQUEST },
|
||||
{ kind: "user", text: CHANGE_REQUEST },
|
||||
{
|
||||
kind: "tools",
|
||||
calls: [
|
||||
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
|
||||
{ kind: "shell", detail: "paseo plugin reload release-radar" },
|
||||
],
|
||||
},
|
||||
{ kind: "mark", name: "revised", ms: 500 },
|
||||
{ kind: "agent", text: "Reloaded. The radar now lists the failing check under the tiles." },
|
||||
{ kind: "turnEnd", worked: "19s" },
|
||||
]);
|
||||
|
||||
/** Whether the plugin is installed: its panel is open and its item is in the sidebar. */
|
||||
export function pluginInstalled(ms: number): boolean {
|
||||
return PLUGIN_BUILD.reached("installed", ms);
|
||||
}
|
||||
|
||||
/** Whether the reloaded plugin lists the failing check. */
|
||||
export function failingCheckListed(ms: number): boolean {
|
||||
return PLUGIN_BUILD.reached("revised", ms);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo } from "react";
|
||||
import { PointerArrow } from "./icons";
|
||||
import { useSceneClock } from "./player";
|
||||
import type { PointerClick } from "./script";
|
||||
import type { MockupStateId } from "./states";
|
||||
|
||||
/**
|
||||
* The visitor's stand-in: a mouse pointer that glides to a spot and clicks it. The spot is where
|
||||
* `className` places the pointer in its nearest positioned ancestor.
|
||||
*/
|
||||
export function Pointer({
|
||||
scene,
|
||||
click,
|
||||
from,
|
||||
className,
|
||||
}: {
|
||||
scene: MockupStateId;
|
||||
click: PointerClick;
|
||||
/** Where the pointer sets off from, in design pixels from the spot it clicks. */
|
||||
from: { x: number; y: number };
|
||||
className: string;
|
||||
}) {
|
||||
const opacity = useSceneClock(scene, click.opacity);
|
||||
const approach = useSceneClock(scene, click.approach);
|
||||
const pressed = useSceneClock(scene, click.pressed);
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
opacity,
|
||||
transform: `translate(${(1 - approach) * from.x}px, ${(1 - approach) * from.y}px)`,
|
||||
}),
|
||||
[opacity, approach, from],
|
||||
);
|
||||
if (opacity === 0) return null;
|
||||
return (
|
||||
<span
|
||||
// The arrow's tip is the glyph's top left corner, so that corner is what lands on the spot.
|
||||
className={`pointer-events-none absolute z-10 ${className}`}
|
||||
style={style}
|
||||
>
|
||||
<PointerArrow
|
||||
className={`origin-top-left transition-[scale] duration-100 ${pressed ? "scale-[0.86]" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ClaudeIcon, CodexIcon, OpenCodeIcon, PiIcon } from "./icons";
|
||||
|
||||
/** Each agent provider the mockup shows: its name, its mark, and the model it runs. */
|
||||
export const PROVIDERS = {
|
||||
claude: { name: "Claude Code", Icon: ClaudeIcon, model: "Opus 5.5" },
|
||||
codex: { name: "Codex", Icon: CodexIcon, model: "GPT-5.6-Sol" },
|
||||
opencode: { name: "OpenCode", Icon: OpenCodeIcon, model: "Kimi K3" },
|
||||
pi: { name: "Pi", Icon: PiIcon, model: "GLM-5.2" },
|
||||
};
|
||||
|
||||
export type ProviderId = keyof typeof PROVIDERS;
|
||||
@@ -0,0 +1,231 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CircleCheck, Pencil, Plus, Send, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Caret } from "./atoms";
|
||||
import { FADE_IN, FADE_OUT, MOCKUP_TRANSITION } from "./motion";
|
||||
import { useSceneClock } from "./player";
|
||||
import { Pointer } from "./pointer";
|
||||
import { PROVIDERS, type ProviderId } from "./providers";
|
||||
import {
|
||||
ADD_COMMENT_CLICK,
|
||||
lineHovered,
|
||||
RECIPIENT_CLICK,
|
||||
recipientHovered,
|
||||
REVIEW,
|
||||
REVIEW_COMMENT,
|
||||
reviewPhase,
|
||||
SEND_FEEDBACK_CLICK,
|
||||
} from "./review";
|
||||
|
||||
// The review scene, drawn like the app's review module (`packages/app/src/review`): the add
|
||||
// button of a hovered line, the inline editor, the saved comment, and Send feedback.
|
||||
|
||||
// --- on the line ------------------------------------------------------------
|
||||
|
||||
const POINTER_TO_LINE = { x: 240, y: 150 };
|
||||
|
||||
/**
|
||||
* The add button a hovered line shows across its gutter's edge, and the pointer that clicks it.
|
||||
* Goes in the line's gutter.
|
||||
*/
|
||||
export function AddCommentAction() {
|
||||
const hovered = useSceneClock("review", lineHovered);
|
||||
const pressed = useSceneClock("review", ADD_COMMENT_CLICK.pressed);
|
||||
return (
|
||||
<span className="absolute top-1/2 -right-[10px] z-10 size-[20px] -translate-y-1/2">
|
||||
{hovered ? (
|
||||
<span
|
||||
className={`flex size-full items-center justify-center rounded-[5px] bg-mock-accent text-white ${
|
||||
pressed ? "brightness-90" : ""
|
||||
}`}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.4} />
|
||||
</span>
|
||||
) : null}
|
||||
<Pointer
|
||||
scene="review"
|
||||
click={ADD_COMMENT_CLICK}
|
||||
from={POINTER_TO_LINE}
|
||||
className="top-1/2 left-1/2"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentEditor() {
|
||||
const { t } = useTranslation();
|
||||
const body = useSceneClock("review", REVIEW.draft);
|
||||
return (
|
||||
<div className="flex h-[94px] flex-col gap-[10px] rounded-[8px] border border-mock-border-accent bg-mock-surface2 p-[10px]">
|
||||
<div className="flex h-[34px] shrink-0 items-center rounded-[6px] border border-mock-accent bg-mock-surface1 px-[11px] text-[13px]">
|
||||
{body ? (
|
||||
<span className="text-mock-fg">
|
||||
{body}
|
||||
<Caret className="ml-px" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-mock-fg-muted">
|
||||
<Caret className="-mr-px" />
|
||||
{t("landing.mockup.app.reviewCommentPlaceholder")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-[8px] text-[12px] leading-[18px]">
|
||||
<span className="px-[10px] py-[5px] text-mock-fg-muted">
|
||||
{t("landing.mockup.app.reviewCancel")}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-[6px] bg-mock-accent px-[11px] py-[5px] text-white ${
|
||||
body ? "" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
{t("landing.mockup.app.reviewSave")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedComment() {
|
||||
return (
|
||||
<div className="flex h-[42px] items-center gap-[12px] rounded-[8px] border border-mock-border-accent bg-mock-surface2 px-[12px]">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-mock-fg">{REVIEW_COMMENT}</span>
|
||||
<Pencil size={13} className="shrink-0 text-mock-fg-muted" />
|
||||
<Trash2 size={13} className="shrink-0 text-mock-destructive" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The thread is as tall as what it holds plus 8 of padding above and below.
|
||||
const THREAD_CLOSED = { height: 0, opacity: 0 };
|
||||
const THREAD_WRITING = { height: 110, opacity: 1 };
|
||||
const THREAD_COMMENTED = { height: 58, opacity: 1 };
|
||||
|
||||
/** The comment thread under the line: the editor first, then the saved comment until it is sent. */
|
||||
export function ReviewThread() {
|
||||
const phase = useSceneClock("review", reviewPhase);
|
||||
const writing = phase === "writing";
|
||||
const open = writing || phase === "commented" || phase === "choosing";
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={THREAD_CLOSED}
|
||||
animate={writing ? THREAD_WRITING : THREAD_COMMENTED}
|
||||
exit={THREAD_CLOSED}
|
||||
transition={MOCKUP_TRANSITION}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-[12px] py-[8px]">{writing ? <CommentEditor /> : <SavedComment />}</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// --- over the diff ----------------------------------------------------------
|
||||
|
||||
function RecipientRow({ agent, lit = false }: { agent: ProviderId; lit?: boolean }) {
|
||||
const provider = PROVIDERS[agent];
|
||||
return (
|
||||
<span
|
||||
className={`flex h-[30px] items-center gap-[8px] rounded-[6px] px-[9px] text-[13px] whitespace-nowrap text-mock-fg ${
|
||||
lit ? "bg-mock-surface2" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-mock-fg-muted">
|
||||
<provider.Icon size={13} />
|
||||
</span>
|
||||
{provider.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Who gets the feedback. The workspace has two agent tabs, so Send feedback asks which one. */
|
||||
const RECIPIENT: ProviderId = "codex";
|
||||
|
||||
// Where the pointer sets off for Send feedback, and how far the picked agent's row is from there.
|
||||
// The button is 32 high. The list floats 6 above it, with 4 of padding around two rows of 30.
|
||||
const POINTER_TO_SEND = { x: -190, y: -170 };
|
||||
const POINTER_TO_RECIPIENT = { x: -6, y: 73 };
|
||||
|
||||
const FLOATING = "pointer-events-none absolute inset-x-0 bottom-[16px] flex justify-center";
|
||||
const FLOATING_SHADOW = "shadow-[0_8px_24px_rgba(0,0,0,0.4)]";
|
||||
|
||||
/**
|
||||
* What floats over the bottom of the diff: Send feedback while a comment waits, the list of
|
||||
* agents it opens, and the toast that says who got the feedback.
|
||||
*/
|
||||
export function ReviewFeedback() {
|
||||
const { t } = useTranslation();
|
||||
const phase = useSceneClock("review", reviewPhase);
|
||||
const pressed = useSceneClock("review", SEND_FEEDBACK_CLICK.pressed);
|
||||
const recipientLit = useSceneClock("review", recipientHovered);
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{phase === "commented" || phase === "choosing" ? (
|
||||
<motion.div
|
||||
key="send"
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
className={FLOATING}
|
||||
>
|
||||
<span className="relative">
|
||||
{phase === "choosing" ? (
|
||||
<motion.span
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
className={`absolute bottom-full left-1/2 mb-[6px] block -translate-x-1/2 rounded-[9px] border border-mock-border-accent bg-mock-surface1 p-[4px] ${FLOATING_SHADOW}`}
|
||||
>
|
||||
<RecipientRow agent={RECIPIENT} lit={recipientLit} />
|
||||
<RecipientRow agent="claude" />
|
||||
</motion.span>
|
||||
) : null}
|
||||
<span
|
||||
className={`flex h-[32px] items-center gap-[7px] rounded-[8px] bg-mock-accent px-[13px] text-[13px] whitespace-nowrap text-white ${FLOATING_SHADOW} ${
|
||||
pressed ? "brightness-90" : ""
|
||||
}`}
|
||||
>
|
||||
<Send size={13} />
|
||||
{t("landing.mockup.app.sendFeedback", { count: 1 })}
|
||||
</span>
|
||||
</span>
|
||||
</motion.div>
|
||||
) : null}
|
||||
{phase === "sent" ? (
|
||||
<motion.div
|
||||
key="sent"
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
className={FLOATING}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-[8px] rounded-[14px] border border-mock-border-accent bg-mock-surface0 px-[12px] py-[8px] text-[13px] whitespace-nowrap text-mock-fg ${FLOATING_SHADOW}`}
|
||||
>
|
||||
<CircleCheck size={16} className="text-mock-accent-bright" />
|
||||
{t("landing.mockup.app.feedbackSent", { recipient: PROVIDERS[RECIPIENT].name })}
|
||||
</span>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{/* Both spots are measured from the middle of the button's bottom edge. */}
|
||||
<span className="absolute bottom-[16px] left-1/2">
|
||||
<Pointer
|
||||
scene="review"
|
||||
click={SEND_FEEDBACK_CLICK}
|
||||
from={POINTER_TO_SEND}
|
||||
className="-top-[14px] left-[18px]"
|
||||
/>
|
||||
<Pointer
|
||||
scene="review"
|
||||
click={RECIPIENT_CLICK}
|
||||
from={POINTER_TO_RECIPIENT}
|
||||
className="-top-[87px] left-[24px]"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { script } from "./script";
|
||||
|
||||
/** What the reviewer writes on the line. */
|
||||
export const REVIEW_COMMENT = "Add “write”. The agent edits files too.";
|
||||
|
||||
/**
|
||||
* The code review scene: the pointer adds a comment on a line of the diff and the user types it.
|
||||
* Then the pointer sends the feedback and picks the agent that gets it.
|
||||
*/
|
||||
export const REVIEW = script([
|
||||
{ kind: "wait", ms: 300 },
|
||||
{ kind: "click", target: "addComment" },
|
||||
{ kind: "wait", ms: 250 },
|
||||
{ kind: "type", text: REVIEW_COMMENT },
|
||||
{ kind: "mark", name: "saved", ms: 450 },
|
||||
{ kind: "click", target: "sendFeedback" },
|
||||
{ kind: "click", target: "recipient" },
|
||||
]);
|
||||
|
||||
/** The click on the add button of the line. It opens the comment editor. */
|
||||
export const ADD_COMMENT_CLICK = REVIEW.click("addComment");
|
||||
/** The click on Send feedback. It opens the list of agents. */
|
||||
export const SEND_FEEDBACK_CLICK = REVIEW.click("sendFeedback");
|
||||
/** The click on the agent that gets the feedback. */
|
||||
export const RECIPIENT_CLICK = REVIEW.click("recipient");
|
||||
|
||||
/**
|
||||
* Where the review stands.
|
||||
*
|
||||
* - `reading`: no comment yet.
|
||||
* - `writing`: the comment editor is open.
|
||||
* - `commented`: the comment is saved and waits to be sent.
|
||||
* - `choosing`: the list of agents is open.
|
||||
* - `sent`: an agent has the feedback, and the saved comment is gone.
|
||||
*/
|
||||
export type ReviewPhase = "reading" | "writing" | "commented" | "choosing" | "sent";
|
||||
|
||||
export function reviewPhase(ms: number): ReviewPhase {
|
||||
if (RECIPIENT_CLICK.done(ms)) return "sent";
|
||||
if (SEND_FEEDBACK_CLICK.done(ms)) return "choosing";
|
||||
if (REVIEW.reached("saved", ms)) return "commented";
|
||||
if (ADD_COMMENT_CLICK.done(ms)) return "writing";
|
||||
return "reading";
|
||||
}
|
||||
|
||||
/** Whether the pointer is over the line. A hovered line shows its add button. */
|
||||
export function lineHovered(ms: number): boolean {
|
||||
return ADD_COMMENT_CLICK.approach(ms) > 0.93 && !ADD_COMMENT_CLICK.done(ms);
|
||||
}
|
||||
|
||||
/** Whether the pointer is over the agent it is about to pick. */
|
||||
export function recipientHovered(ms: number): boolean {
|
||||
return RECIPIENT_CLICK.approach(ms) > 0.8;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
FileDiff,
|
||||
@@ -14,12 +15,26 @@ import type { TFunction } from "i18next";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangesBranchRow, ChangesCommitsRow, ChangesScopeRow, ExplorerChangesTree } from "./diff";
|
||||
import { RIGHT_PANEL_W, TABBAR_H, TITLEBAR_H } from "./geometry";
|
||||
import type { MockupStateId } from "./states";
|
||||
import { RIGHT_PANEL_W, RIGHT_PANEL_WIDE_W, TABBAR_H, TITLEBAR_H } from "./geometry";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useSceneClock } from "./player";
|
||||
import { failingCheckListed } from "./plugin-build";
|
||||
|
||||
const TABBAR_STYLE = { height: TITLEBAR_H };
|
||||
const SUBHEADER_STYLE = { height: TABBAR_H };
|
||||
const PANEL_STYLE = { width: RIGHT_PANEL_W };
|
||||
|
||||
export type PanelId = "changes" | "ship" | "plugin";
|
||||
|
||||
/** How wide the docked panel is for what it shows. Only the file tree fits the narrow width. */
|
||||
export function panelWidth(panel: PanelId): number {
|
||||
return panel === "changes" ? RIGHT_PANEL_W : RIGHT_PANEL_WIDE_W;
|
||||
}
|
||||
|
||||
const PANEL_STYLES: Record<PanelId, { width: number }> = {
|
||||
changes: { width: panelWidth("changes") },
|
||||
ship: { width: panelWidth("ship") },
|
||||
plugin: { width: panelWidth("plugin") },
|
||||
};
|
||||
|
||||
interface PanelTab {
|
||||
label: string;
|
||||
@@ -52,10 +67,12 @@ function PanelTabs({ tabs, active }: { tabs: PanelTab[]; active: number }) {
|
||||
}
|
||||
|
||||
function PanelShell({
|
||||
panel,
|
||||
tabs,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
panel: PanelId;
|
||||
tabs: PanelTab[];
|
||||
active: number;
|
||||
children: React.ReactNode;
|
||||
@@ -63,7 +80,7 @@ function PanelShell({
|
||||
return (
|
||||
<div
|
||||
className="flex h-full shrink-0 flex-col overflow-hidden bg-mock-sidebar"
|
||||
style={PANEL_STYLE}
|
||||
style={PANEL_STYLES[panel]}
|
||||
>
|
||||
<PanelTabs tabs={tabs} active={active} />
|
||||
{children}
|
||||
@@ -88,7 +105,7 @@ function changesTabs(t: TFunction): PanelTab[] {
|
||||
function ChangesPanel({ selectedFile }: { selectedFile?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell tabs={changesTabs(t)} active={1}>
|
||||
<PanelShell panel="changes" tabs={changesTabs(t)} active={1}>
|
||||
<ChangesBranchRow />
|
||||
<ChangesScopeRow />
|
||||
<ExplorerChangesTree selectedFile={selectedFile} />
|
||||
@@ -118,7 +135,7 @@ function CheckGlyph() {
|
||||
function ShipPanel() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell tabs={changesTabs(t)} active={2}>
|
||||
<PanelShell panel="ship" tabs={changesTabs(t)} active={2}>
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-mock-border px-[16px]"
|
||||
style={SUBHEADER_STYLE}
|
||||
@@ -235,10 +252,38 @@ function recentlyMerged(t: TFunction): { title: string; meta: string; tone: stri
|
||||
];
|
||||
}
|
||||
|
||||
function PluginDashboard() {
|
||||
const SECTION_CLOSED = { height: 0, opacity: 0 };
|
||||
const SECTION_OPEN = { height: "auto", opacity: 1 };
|
||||
|
||||
/** The section the user asks for after the panel is live: which check is failing. */
|
||||
function FailingCheck() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell tabs={pluginTabs(t)} active={3}>
|
||||
<motion.div initial={SECTION_CLOSED} animate={SECTION_OPEN} className="overflow-hidden">
|
||||
<div className="pt-[16px] text-[11px] text-mock-fg-muted">
|
||||
{t("landing.mockup.app.failing")}
|
||||
</div>
|
||||
<div className="mt-[7px] flex items-center gap-[10px] rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[12px] py-[9px]">
|
||||
<CircleX size={15} className="shrink-0 text-mock-tile-red" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] leading-[16px] text-mock-fg">
|
||||
app-tests (macos)
|
||||
</span>
|
||||
<span className="block truncate text-[10.5px] leading-[13px] text-mock-fg-muted">
|
||||
#3976 · Honor configured commit signing
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-mock-fg-muted">5m 02s</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const listsFailingCheck = useSceneClock("extend", failingCheckListed);
|
||||
return (
|
||||
<PanelShell panel="plugin" tabs={pluginTabs(t)} active={3}>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-[8px] border-b border-mock-border px-[16px]"
|
||||
style={SUBHEADER_STYLE}
|
||||
@@ -265,6 +310,10 @@ function PluginDashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{listsFailingCheck ? <FailingCheck key="failing-check" /> : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-[16px] rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[14px] pt-[12px] pb-[13px]">
|
||||
<div className="flex items-center justify-between text-[11px] text-mock-fg-muted">
|
||||
<span>{t("landing.mockup.app.mergesWeek")}</span>
|
||||
@@ -307,8 +356,8 @@ function PluginDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
export function RightPanel({ state }: { state: MockupStateId }) {
|
||||
if (state === "ship") return <ShipPanel />;
|
||||
if (state === "extend") return <PluginDashboard />;
|
||||
export function RightPanel({ panel }: { panel: PanelId }) {
|
||||
if (panel === "ship") return <ShipPanel />;
|
||||
if (panel === "plugin") return <PluginDashboard />;
|
||||
return <ChangesPanel />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { script } from "./script";
|
||||
|
||||
const REQUEST = "Rename the panel.";
|
||||
const REPLY = "Renamed. The panel is now called Radar.";
|
||||
|
||||
const scene = script([
|
||||
{ kind: "wait", ms: 500 },
|
||||
{ kind: "type", text: REQUEST },
|
||||
{ kind: "user", text: REQUEST },
|
||||
{
|
||||
kind: "tools",
|
||||
calls: [
|
||||
{ kind: "read", detail: "panel.tsx" },
|
||||
{ kind: "write", detail: "panel.tsx" },
|
||||
],
|
||||
},
|
||||
{ kind: "mark", name: "renamed", ms: 400 },
|
||||
{ kind: "agent", text: REPLY },
|
||||
{ kind: "turnEnd", worked: "8s" },
|
||||
]);
|
||||
|
||||
const [sent, tools, reply, turnEnd] = scene.entries;
|
||||
|
||||
function midway(entry: { start: number; end: number }): number {
|
||||
return (entry.start + entry.end) / 2;
|
||||
}
|
||||
|
||||
const POINTER_ENTERS = 200;
|
||||
const PAUSE_BEFORE_TYPING = 100;
|
||||
|
||||
const pointed = script([
|
||||
{ kind: "wait", ms: POINTER_ENTERS },
|
||||
{ kind: "click", target: "composer" },
|
||||
{ kind: "wait", ms: PAUSE_BEFORE_TYPING },
|
||||
{ kind: "type", text: REQUEST },
|
||||
{ kind: "user", text: REQUEST },
|
||||
{ kind: "click", target: "pill" },
|
||||
{ kind: "click", target: "menu" },
|
||||
]);
|
||||
const composerClick = pointed.click("composer");
|
||||
const pillClick = pointed.click("pill");
|
||||
const menuClick = pointed.click("menu");
|
||||
|
||||
/** The first millisecond of the pointed scene at which something is true. */
|
||||
function firstMs(holds: (ms: number) => boolean): number {
|
||||
for (let ms = 0; ms <= pointed.duration; ms += 1) {
|
||||
if (holds(ms)) return ms;
|
||||
}
|
||||
throw new Error("It never holds");
|
||||
}
|
||||
|
||||
describe("script", () => {
|
||||
it("starts with an empty composer, an empty transcript, and no turn running", () => {
|
||||
expect(scene.draft(0)).toBe("");
|
||||
expect(scene.entered(0)).toBe(0);
|
||||
expect(scene.busy(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("types the request into the composer, then clears it when the message is sent", () => {
|
||||
const typing = { start: 500, end: sent.start };
|
||||
const partial = scene.draft(typing.start + 200);
|
||||
expect(partial.length).toBeGreaterThan(0);
|
||||
expect(partial.length).toBeLessThan(REQUEST.length);
|
||||
expect(REQUEST.startsWith(partial)).toBe(true);
|
||||
|
||||
expect(scene.draft(typing.end - 1)).toBe(REQUEST);
|
||||
expect(scene.draft(sent.start)).toBe("");
|
||||
expect(scene.entered(midway(sent))).toBe(1);
|
||||
});
|
||||
|
||||
it("runs the turn from the sent message until the turn ends", () => {
|
||||
expect(scene.busy(sent.start - 1)).toBe(false);
|
||||
expect(scene.busy(sent.start)).toBe(true);
|
||||
expect(scene.busy(midway(reply))).toBe(true);
|
||||
expect(scene.busy(turnEnd.start)).toBe(false);
|
||||
});
|
||||
|
||||
it("lands tool calls one at a time", () => {
|
||||
expect(scene.landed(tools, tools.start)).toBe(0);
|
||||
expect(scene.landed(tools, tools.start + 1)).toBe(1);
|
||||
expect(scene.landed(tools, tools.end)).toBe(2);
|
||||
});
|
||||
|
||||
it("streams the agent's text", () => {
|
||||
expect(scene.streamed(reply, reply.start)).toBe("");
|
||||
const partial = scene.streamed(reply, midway(reply));
|
||||
expect(partial.length).toBeGreaterThan(0);
|
||||
expect(partial.length).toBeLessThan(REPLY.length);
|
||||
expect(REPLY.startsWith(partial)).toBe(true);
|
||||
expect(scene.streamed(reply, reply.end)).toBe(REPLY);
|
||||
});
|
||||
|
||||
it("reaches a mark after the beats before it and reports progress through it", () => {
|
||||
expect(scene.reached("renamed", tools.end)).toBe(false);
|
||||
expect(scene.reached("renamed", tools.end + 1)).toBe(true);
|
||||
expect(scene.progress("renamed", tools.end + 100)).toBeCloseTo(0.25);
|
||||
expect(scene.progress("renamed", reply.start)).toBe(1);
|
||||
});
|
||||
|
||||
it("glides the pointer to its target, presses, and lets go", () => {
|
||||
const pressedAt = firstMs(composerClick.pressed);
|
||||
const doneAt = firstMs(composerClick.done);
|
||||
|
||||
expect(composerClick.opacity(POINTER_ENTERS)).toBe(0);
|
||||
expect(composerClick.approach(POINTER_ENTERS)).toBe(0);
|
||||
|
||||
const gliding = (POINTER_ENTERS + pressedAt) / 2;
|
||||
expect(composerClick.opacity(gliding)).toBe(1);
|
||||
expect(composerClick.approach(gliding)).toBeGreaterThan(0);
|
||||
expect(composerClick.approach(gliding)).toBeLessThan(1);
|
||||
expect(composerClick.pressed(gliding)).toBe(false);
|
||||
|
||||
expect(composerClick.approach(pressedAt)).toBe(1);
|
||||
expect(composerClick.done(pressedAt)).toBe(false);
|
||||
|
||||
expect(doneAt).toBeGreaterThan(pressedAt);
|
||||
expect(composerClick.pressed(doneAt)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the pointer where it clicked until the user types", () => {
|
||||
const typingAt = firstMs(composerClick.done) + PAUSE_BEFORE_TYPING;
|
||||
expect(composerClick.opacity(typingAt - 1)).toBe(1);
|
||||
expect(pointed.draft(typingAt - 1)).toBe("");
|
||||
|
||||
// The pointer fades away while the first characters land.
|
||||
expect(pointed.draft(typingAt + 1)).not.toBe("");
|
||||
expect(composerClick.opacity(typingAt + 1)).toBeLessThan(1);
|
||||
expect(composerClick.opacity(typingAt + 500)).toBe(0);
|
||||
expect(composerClick.done(typingAt + 500)).toBe(true);
|
||||
});
|
||||
|
||||
it("fades the pointer in when it comes back after typing", () => {
|
||||
const setsOffAt = firstMs((ms) => pillClick.approach(ms) > 0);
|
||||
expect(pillClick.opacity(setsOffAt)).toBeGreaterThan(0);
|
||||
expect(pillClick.opacity(setsOffAt)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("moves one pointer from a click to the click after it", () => {
|
||||
const handoffAt = firstMs(pillClick.done);
|
||||
expect(pillClick.opacity(handoffAt - 1)).toBe(1);
|
||||
expect(menuClick.opacity(handoffAt - 1)).toBe(0);
|
||||
|
||||
expect(pillClick.opacity(handoffAt)).toBe(0);
|
||||
expect(menuClick.opacity(handoffAt)).toBe(1);
|
||||
expect(menuClick.approach(handoffAt)).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the pointer on the last thing it clicked", () => {
|
||||
expect(menuClick.opacity(pointed.duration)).toBe(1);
|
||||
expect(menuClick.approach(pointed.duration)).toBe(1);
|
||||
expect(menuClick.done(pointed.duration)).toBe(true);
|
||||
expect(composerClick.opacity(pointed.duration)).toBe(0);
|
||||
expect(composerClick.done(pointed.duration)).toBe(true);
|
||||
});
|
||||
|
||||
it("ends a turn without a footer when the beat gives no time worked", () => {
|
||||
const quiet = script([
|
||||
{ kind: "user", text: REQUEST },
|
||||
{ kind: "agent", text: REPLY },
|
||||
{ kind: "turnEnd" },
|
||||
{ kind: "wait", ms: 300 },
|
||||
]);
|
||||
const [, quietReply] = quiet.entries;
|
||||
expect(quiet.entries.map((entry) => entry.beat.kind)).toEqual(["user", "agent"]);
|
||||
expect(quiet.busy(quietReply.end - 1)).toBe(true);
|
||||
expect(quiet.busy(quietReply.end)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the finished scene at its duration", () => {
|
||||
expect(scene.duration).toBe(turnEnd.end);
|
||||
expect(scene.entered(scene.duration)).toBe(scene.entries.length);
|
||||
expect(scene.draft(scene.duration)).toBe("");
|
||||
expect(scene.busy(scene.duration)).toBe(false);
|
||||
expect(scene.reached("renamed", scene.duration)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { RemoteHostId } from "./hosts";
|
||||
|
||||
/**
|
||||
* A collapsed tool call as the app's timeline shows it. Built-in tools carry a
|
||||
* fixed label; Paseo MCP tools name their action, such as "Create agent".
|
||||
*/
|
||||
export type ToolCall =
|
||||
| { kind: "read" | "write" | "shell"; detail: string }
|
||||
| { kind: "paseo"; label: string; detail?: string; host?: RemoteHostId };
|
||||
|
||||
/**
|
||||
* One step of a scripted scene. Beats play in order, each after the one before it ends.
|
||||
*
|
||||
* - `wait`: nothing happens.
|
||||
* - `click`: the pointer glides to a named target and clicks it.
|
||||
* - `type`: the user types into the composer.
|
||||
* - `user`: the typed message is sent and joins the transcript.
|
||||
* - `agent`: the agent's text streams into the transcript.
|
||||
* - `tools`: the agent's tool calls land one at a time.
|
||||
* - `turnEnd`: the turn finishes. With `worked`, its footer joins the transcript.
|
||||
* - `mark`: a named moment the rest of the window reacts to, such as a panel opening.
|
||||
*/
|
||||
export type Beat<Mark extends string = string, Target extends string = string> =
|
||||
| { kind: "wait"; ms: number }
|
||||
| { kind: "click"; target: Target }
|
||||
| { kind: "type"; text: string }
|
||||
| { kind: "user"; text: string }
|
||||
| { kind: "agent"; text: string }
|
||||
| { kind: "tools"; calls: ToolCall[] }
|
||||
| { kind: "turnEnd"; worked?: string }
|
||||
| { kind: "mark"; name: Mark; ms: number };
|
||||
|
||||
type TranscriptBeat =
|
||||
| Extract<Beat, { kind: "user" | "agent" | "tools" }>
|
||||
| { kind: "turnEnd"; worked: string };
|
||||
|
||||
/** A beat that leaves something in the transcript, with the span of time it plays over. */
|
||||
export interface TranscriptEntry {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
beat: TranscriptBeat;
|
||||
}
|
||||
|
||||
/** One click of the pointer: it glides to its target, presses the button, and lets go. */
|
||||
export interface PointerClick {
|
||||
/**
|
||||
* How visible the pointer is for this click, from 0 to 1. It fades in on its way, and fades out
|
||||
* when the user types. A click right after another one takes the same pointer over, with no fade.
|
||||
*/
|
||||
opacity(ms: number): number;
|
||||
/** How far the pointer has come on its way to the target, from 0 to 1. */
|
||||
approach(ms: number): number;
|
||||
/** Whether the pointer's button is down on the target. */
|
||||
pressed(ms: number): boolean;
|
||||
/** Whether the click has happened: the button went down and came back up. */
|
||||
done(ms: number): boolean;
|
||||
}
|
||||
|
||||
/** The click target every scripted chat has. Clicking it puts the caret in the composer. */
|
||||
export const COMPOSER = "composer";
|
||||
|
||||
/** What a chat pane reads from a script: the composer and the transcript at a point in time. */
|
||||
export interface SceneScript {
|
||||
/** The length of the whole script. At this time the scene shows its finished state. */
|
||||
duration: number;
|
||||
entries: TranscriptEntry[];
|
||||
/** What the user is typing: the text so far, until it is sent. */
|
||||
draft(ms: number): string;
|
||||
/** Whether a turn is running: from the user's message until the turn ends. */
|
||||
busy(ms: number): boolean;
|
||||
/** How many transcript entries have started. */
|
||||
entered(ms: number): number;
|
||||
/** How much of an agent entry's text has streamed in. */
|
||||
streamed(entry: TranscriptEntry, ms: number): string;
|
||||
/** How many of a tools entry's calls have landed. */
|
||||
landed(entry: TranscriptEntry, ms: number): number;
|
||||
/** The script's click on a target. */
|
||||
click(target: string): PointerClick;
|
||||
}
|
||||
|
||||
export interface Script<Mark extends string, Target extends string> extends SceneScript {
|
||||
click(target: Target): PointerClick;
|
||||
/** Whether a mark has started. */
|
||||
reached(mark: Mark, ms: number): boolean;
|
||||
/** How far through a mark the scene is, from 0 to 1. */
|
||||
progress(mark: Mark, ms: number): number;
|
||||
}
|
||||
|
||||
// A demo types and streams faster than a person or a model does, and still reads as both.
|
||||
const TYPING_MS_PER_CHARACTER = 14;
|
||||
const SHORTEST_TYPING_MS = 700;
|
||||
const PAUSE_BEFORE_SEND_MS = 320;
|
||||
const SEND_MS = 420;
|
||||
const STREAM_MS_PER_CHARACTER = 8;
|
||||
const TOOL_CALL_MS = 320;
|
||||
const TURN_END_MS = 420;
|
||||
const POINTER_APPROACH_MS = 900;
|
||||
const POINTER_PRESS_MS = 240;
|
||||
const POINTER_FADE_MS = 150;
|
||||
|
||||
function typingMs(text: string): number {
|
||||
return Math.max(SHORTEST_TYPING_MS, text.length * TYPING_MS_PER_CHARACTER);
|
||||
}
|
||||
|
||||
function beatMs(beat: Beat): number {
|
||||
switch (beat.kind) {
|
||||
case "wait":
|
||||
case "mark":
|
||||
return beat.ms;
|
||||
case "click":
|
||||
return POINTER_APPROACH_MS + POINTER_PRESS_MS;
|
||||
case "type":
|
||||
return typingMs(beat.text) + PAUSE_BEFORE_SEND_MS;
|
||||
case "user":
|
||||
return SEND_MS;
|
||||
case "agent":
|
||||
return beat.text.length * STREAM_MS_PER_CHARACTER;
|
||||
case "tools":
|
||||
return beat.calls.length * TOOL_CALL_MS;
|
||||
case "turnEnd":
|
||||
return TURN_END_MS;
|
||||
}
|
||||
}
|
||||
|
||||
function isTranscriptBeat(beat: Beat): beat is TranscriptBeat {
|
||||
if (beat.kind === "turnEnd") return beat.worked !== undefined;
|
||||
return beat.kind === "user" || beat.kind === "agent" || beat.kind === "tools";
|
||||
}
|
||||
|
||||
function fraction(start: number, end: number, ms: number): number {
|
||||
if (ms <= start) return 0;
|
||||
if (ms >= end) return 1;
|
||||
return (ms - start) / (end - start);
|
||||
}
|
||||
|
||||
// A hand speeds a mouse up and slows it down again.
|
||||
function easeInOut(linear: number): number {
|
||||
return linear < 0.5 ? 2 * linear * linear : 1 - (-2 * linear + 2) ** 2 / 2;
|
||||
}
|
||||
|
||||
function hundredths(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/** How a click's pointer comes and goes. */
|
||||
interface PointerPresence {
|
||||
/** When the pointer sets off. */
|
||||
start: number;
|
||||
/** Whether the pointer is already on screen, at the click before this one. */
|
||||
continues: boolean;
|
||||
/** When the pointer goes away, or `Infinity` when it stays to the end of the scene. */
|
||||
leaves: number;
|
||||
/** Whether the next click takes the pointer over. Otherwise typing fades it out. */
|
||||
handsOver: boolean;
|
||||
}
|
||||
|
||||
function pointerClick({ start, continues, leaves, handsOver }: PointerPresence): PointerClick {
|
||||
const pressStart = start + POINTER_APPROACH_MS;
|
||||
const pressEnd = pressStart + POINTER_PRESS_MS;
|
||||
return {
|
||||
opacity(ms) {
|
||||
if (ms >= leaves) {
|
||||
return handsOver ? 0 : hundredths(1 - fraction(leaves, leaves + POINTER_FADE_MS, ms));
|
||||
}
|
||||
if (continues) return ms >= start ? 1 : 0;
|
||||
return hundredths(fraction(start, start + POINTER_FADE_MS, ms));
|
||||
},
|
||||
approach: (ms) => Math.round(easeInOut(fraction(start, pressStart, ms)) * 1000) / 1000,
|
||||
pressed: (ms) => ms >= pressStart && ms < pressEnd,
|
||||
done: (ms) => ms >= pressEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lays beats end to end and answers what the scene shows at any time. */
|
||||
export function script<Mark extends string, Target extends string>(
|
||||
beats: Beat<Mark, Target>[],
|
||||
): Script<Mark, Target> {
|
||||
let clock = 0;
|
||||
const timed = beats.map((beat, index) => {
|
||||
const start = clock;
|
||||
clock += beatMs(beat);
|
||||
return { id: `${index}:${beat.kind}`, start, end: clock, beat };
|
||||
});
|
||||
const duration = clock;
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const typing: { start: number; end: number; text: string }[] = [];
|
||||
const turns: { start: number; end: number }[] = [];
|
||||
const marks = new Map<Mark, { start: number; end: number }>();
|
||||
const clicks = new Map<string, PointerClick>();
|
||||
// A hand that types is off the mouse, and the pointer has one place at a time. So typing takes
|
||||
// the pointer off screen, and a click that follows a click takes the pointer over from it.
|
||||
let pointerOnScreen = false;
|
||||
timed.forEach(({ start, beat }, index) => {
|
||||
if (beat.kind === "type") pointerOnScreen = false;
|
||||
if (beat.kind !== "click") return;
|
||||
const next = timed
|
||||
.slice(index + 1)
|
||||
.find((later) => later.beat.kind === "type" || later.beat.kind === "click");
|
||||
clicks.set(
|
||||
beat.target,
|
||||
pointerClick({
|
||||
start,
|
||||
continues: pointerOnScreen,
|
||||
leaves: next ? next.start : Infinity,
|
||||
handsOver: next?.beat.kind === "click",
|
||||
}),
|
||||
);
|
||||
pointerOnScreen = true;
|
||||
});
|
||||
for (const { id, start, end, beat } of timed) {
|
||||
if (isTranscriptBeat(beat)) entries.push({ id, start, end, beat });
|
||||
if (beat.kind === "type") typing.push({ start, end, text: beat.text });
|
||||
if (beat.kind === "user") turns.push({ start, end: duration });
|
||||
if (beat.kind === "turnEnd") {
|
||||
const turn = turns.at(-1);
|
||||
if (turn) turn.end = start;
|
||||
}
|
||||
if (beat.kind === "mark") marks.set(beat.name, { start, end });
|
||||
}
|
||||
|
||||
function span(mark: Mark) {
|
||||
const found = marks.get(mark);
|
||||
if (!found) throw new Error(`The script has no mark named ${mark}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
return {
|
||||
duration,
|
||||
entries,
|
||||
draft(ms) {
|
||||
const beat = typing.find(({ start, end }) => ms >= start && ms < end);
|
||||
if (!beat) return "";
|
||||
const typed = fraction(beat.start, beat.start + typingMs(beat.text), ms);
|
||||
return beat.text.slice(0, Math.ceil(typed * beat.text.length));
|
||||
},
|
||||
busy(ms) {
|
||||
return turns.some(({ start, end }) => ms >= start && ms < end);
|
||||
},
|
||||
entered(ms) {
|
||||
// The last entry enters at the end of a script that closes on it, so the end counts.
|
||||
return entries.filter(({ start }) => ms > start || ms >= duration).length;
|
||||
},
|
||||
streamed(entry, ms) {
|
||||
if (entry.beat.kind !== "agent") return "";
|
||||
const { text } = entry.beat;
|
||||
return text.slice(0, Math.ceil(fraction(entry.start, entry.end, ms) * text.length));
|
||||
},
|
||||
landed(entry, ms) {
|
||||
if (entry.beat.kind !== "tools") return 0;
|
||||
return Math.ceil(fraction(entry.start, entry.end, ms) * entry.beat.calls.length);
|
||||
},
|
||||
reached(mark, ms) {
|
||||
return ms > span(mark).start || ms >= duration;
|
||||
},
|
||||
progress(mark, ms) {
|
||||
const { start, end } = span(mark);
|
||||
return fraction(start, end, ms);
|
||||
},
|
||||
click(target) {
|
||||
const found = clicks.get(target);
|
||||
if (!found) throw new Error(`The script has no click on ${target}`);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CircleHelp,
|
||||
GitPullRequest,
|
||||
History,
|
||||
House,
|
||||
PanelBottom,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
@@ -18,10 +17,15 @@ import {
|
||||
SquarePlus,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SIDEBAR_W, TITLEBAR_H } from "./geometry";
|
||||
import { HostBadge, type RemoteHostId } from "./hosts";
|
||||
import { PaseoMark } from "./icons";
|
||||
import { FADE_IN, FADE_OUT } from "./motion";
|
||||
import { DELEGATED_TASKS, tasksDelegated } from "./orchestration";
|
||||
import { useSceneClock } from "./player";
|
||||
import { pluginInstalled } from "./plugin-build";
|
||||
import type { MockupStateId } from "./states";
|
||||
|
||||
const SIDEBAR_STYLE = { width: SIDEBAR_W };
|
||||
@@ -41,7 +45,7 @@ interface WorkspaceRow {
|
||||
title: string;
|
||||
avatar: Avatar;
|
||||
time: string;
|
||||
host?: string;
|
||||
host?: RemoteHostId;
|
||||
pr?: string;
|
||||
passed?: boolean;
|
||||
running?: boolean;
|
||||
@@ -65,6 +69,7 @@ const WORKING_ROWS: WorkspaceRow[] = [
|
||||
id: "safe-area",
|
||||
title: "Fix commit safe area in emulators",
|
||||
avatar: PASEO,
|
||||
host: "mac-mini",
|
||||
time: "4m",
|
||||
running: true,
|
||||
},
|
||||
@@ -82,6 +87,7 @@ const READY_TO_REVIEW_ROWS: WorkspaceRow[] = [
|
||||
id: "lineage",
|
||||
title: "Paseo homepage",
|
||||
avatar: PASEO,
|
||||
host: "devbox",
|
||||
pr: "3981",
|
||||
passed: true,
|
||||
time: "now",
|
||||
@@ -97,6 +103,16 @@ const READY_TO_REVIEW_ROWS: WorkspaceRow[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** The workspaces the orchestrating agent created, one per delegated task. */
|
||||
const DELEGATED_ROWS: WorkspaceRow[] = DELEGATED_TASKS.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
avatar: PASEO,
|
||||
host: task.host,
|
||||
time: "now",
|
||||
running: true,
|
||||
}));
|
||||
|
||||
const DONE_ROWS: WorkspaceRow[] = [
|
||||
{ id: "beta", title: "Release v0.7.0-beta.2", avatar: PASEO, time: "2h" },
|
||||
{
|
||||
@@ -111,7 +127,7 @@ const DONE_ROWS: WorkspaceRow[] = [
|
||||
id: "perms",
|
||||
title: "Design semantic permission sy…",
|
||||
avatar: PASEO,
|
||||
host: "my-server",
|
||||
host: "devbox",
|
||||
pr: "3981",
|
||||
passed: true,
|
||||
time: "7h",
|
||||
@@ -120,6 +136,7 @@ const DONE_ROWS: WorkspaceRow[] = [
|
||||
id: "modules",
|
||||
title: "Plan modules and abstractions",
|
||||
avatar: PASEO,
|
||||
host: "mac-mini",
|
||||
pr: "3884",
|
||||
passed: true,
|
||||
time: "1d",
|
||||
@@ -202,12 +219,7 @@ function RowMeta({ row }: { row: WorkspaceRow }) {
|
||||
if (row.host || row.pr) {
|
||||
return (
|
||||
<span className="mt-[3px] flex items-center gap-[6px] text-[10.5px] leading-[13px] text-mock-fg-muted">
|
||||
{row.host ? (
|
||||
<span className="flex items-center gap-[4px] text-mock-host-teal">
|
||||
<Server size={12} className="shrink-0" />
|
||||
{row.host}
|
||||
</span>
|
||||
) : null}
|
||||
{row.host ? <HostBadge host={row.host} /> : null}
|
||||
{row.host && row.pr ? <span className="text-mock-fg-xmuted">·</span> : null}
|
||||
{row.pr ? (
|
||||
<span className="flex items-center gap-[4px]">
|
||||
@@ -267,7 +279,18 @@ function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string })
|
||||
|
||||
export function Sidebar({ state }: { state: MockupStateId }) {
|
||||
const { t } = useTranslation();
|
||||
const extending = state === "extend";
|
||||
// The plugin's item joins the nav when the plugin is installed, and each delegated workspace
|
||||
// joins Working when the orchestrator creates it.
|
||||
const pluginIsInstalled = useSceneClock("extend", pluginInstalled);
|
||||
const delegated = useSceneClock("orchestrate", tasksDelegated);
|
||||
const extending = state === "extend" && pluginIsInstalled;
|
||||
const workingRows = useMemo(
|
||||
() =>
|
||||
state === "orchestrate"
|
||||
? [...DELEGATED_ROWS.slice(0, delegated), ...WORKING_ROWS]
|
||||
: WORKING_ROWS,
|
||||
[state, delegated],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col overflow-hidden bg-mock-sidebar" style={SIDEBAR_STYLE}>
|
||||
@@ -321,7 +344,7 @@ export function Sidebar({ state }: { state: MockupStateId }) {
|
||||
|
||||
<GroupHeader icon={WORKING_ICON} label={t("landing.mockup.app.working")} />
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{WORKING_ROWS.map((row) => (
|
||||
{workingRows.map((row) => (
|
||||
<motion.div key={row.id} layout initial={FADE_OUT} animate={FADE_IN} exit={FADE_OUT}>
|
||||
<Row row={row} indented />
|
||||
</motion.div>
|
||||
@@ -340,7 +363,7 @@ export function Sidebar({ state }: { state: MockupStateId }) {
|
||||
<span className="text-mock-fg">{t("landing.mockup.app.addProject")}</span>
|
||||
<span className="flex-1" />
|
||||
<PanelBottom size={14} />
|
||||
<House size={14} />
|
||||
<Server size={14} className={state === "remote" ? "text-mock-fg" : ""} />
|
||||
<CircleHelp size={14} />
|
||||
<Settings size={14} />
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
/**
|
||||
* The states the hero mockup can be in, in pill order. Labels are copy — edit
|
||||
* them here and nothing else has to change.
|
||||
*/
|
||||
/** The scenes the hero mockup can show; each pill's label is `landing.mockup.<id>`. */
|
||||
export const MOCKUP_STATES = ["build", "review", "ship", "extend"] as const;
|
||||
/** The scenes the hero mockup can show, in pill order. Each pill's label is `landing.mockup.<id>`. */
|
||||
export const MOCKUP_STATES = ["orchestrate", "remote", "review", "ship", "extend"] as const;
|
||||
|
||||
export type MockupStateId = (typeof MOCKUP_STATES)[number];
|
||||
|
||||
export const DEFAULT_MOCKUP_STATE: MockupStateId = "build";
|
||||
export const DEFAULT_MOCKUP_STATE: MockupStateId = "orchestrate";
|
||||
|
||||
/** Which states keep a panel docked to the right of the workspace. */
|
||||
export function hasRightPanel(_state: MockupStateId): boolean {
|
||||
|
||||
@@ -106,9 +106,17 @@ const WORKSPACE_TABS: Tab[] = [
|
||||
|
||||
const DIFF_TAB: Tab = { id: "diff", label: "Diff", icon: <FileDiff size={11} /> };
|
||||
|
||||
/** The tab a scene has in front. The orchestrating agent is Claude; the remote scene is a terminal. */
|
||||
function activeTabId(state: MockupStateId): string {
|
||||
if (state === "review") return DIFF_TAB.id;
|
||||
if (state === "orchestrate") return "claude";
|
||||
if (state === "remote") return "terminal";
|
||||
return "codex";
|
||||
}
|
||||
|
||||
export function TabStrip({ state }: { state: MockupStateId }) {
|
||||
const review = state === "review";
|
||||
const tabs = review ? [...WORKSPACE_TABS, DIFF_TAB] : WORKSPACE_TABS;
|
||||
const tabs = state === "review" ? [...WORKSPACE_TABS, DIFF_TAB] : WORKSPACE_TABS;
|
||||
const activeId = activeTabId(state);
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-[5px] border-b border-mock-border pr-[16px] pl-[7px]"
|
||||
@@ -118,9 +126,7 @@ export function TabStrip({ state }: { state: MockupStateId }) {
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`flex items-center gap-[7px] rounded-[7px] px-[10px] py-[5px] ${
|
||||
(review ? tab.id === "diff" : tab.id === "codex")
|
||||
? "bg-mock-surface2 text-mock-fg"
|
||||
: "text-mock-fg-muted"
|
||||
tab.id === activeId ? "bg-mock-surface2 text-mock-fg" : "text-mock-fg-muted"
|
||||
}`}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
|
||||
@@ -1,57 +1,123 @@
|
||||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";
|
||||
import { AgentTranscript, Composer, PluginBuildTranscript } from "./chat";
|
||||
import { DESIGN_HEIGHT, DESIGN_WIDTH, RIGHT_PANEL_W } from "./geometry";
|
||||
import type * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
AgentTranscript,
|
||||
Composer,
|
||||
ProgressTrack,
|
||||
ScriptedComposer,
|
||||
ScriptedTranscript,
|
||||
SubagentsTrack,
|
||||
} from "./chat";
|
||||
import { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
|
||||
import { HostsModal } from "./hosts";
|
||||
import { FADE_IN, FADE_OUT, INSTANT_TRANSITION, MOCKUP_TRANSITION } from "./motion";
|
||||
import { DiffReviewPane } from "./panes";
|
||||
import { RightPanel } from "./right-panel";
|
||||
import { ORCHESTRATION } from "./orchestration";
|
||||
import { DiffReviewPane, RemoteTerminalPane } from "./panes";
|
||||
import {
|
||||
createScenePlayer,
|
||||
type ScenePlayer,
|
||||
ScenePlayerContext,
|
||||
usePlayerScene,
|
||||
useSceneClock,
|
||||
} from "./player";
|
||||
import { PLUGIN_BUILD, pluginInstalled } from "./plugin-build";
|
||||
import { REVIEW } from "./review";
|
||||
import type { ProviderId } from "./providers";
|
||||
import { type PanelId, panelWidth, RightPanel } from "./right-panel";
|
||||
import type { SceneScript } from "./script";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { hasRightPanel, type MockupStateId } from "./states";
|
||||
import { DEFAULT_MOCKUP_STATE, hasRightPanel, type MockupStateId } from "./states";
|
||||
import { TabStrip, TitleBar } from "./titlebar";
|
||||
|
||||
const WINDOW_STYLE = { width: DESIGN_WIDTH, height: DESIGN_HEIGHT };
|
||||
const PANEL_STYLE = { width: RIGHT_PANEL_W };
|
||||
const PANEL_COLLAPSED = { width: 0, opacity: 0 };
|
||||
const PANEL_OPEN = { width: RIGHT_PANEL_W + 1, opacity: 1 };
|
||||
|
||||
/** Which center pane a state shows. States sharing a key keep it mounted. */
|
||||
function centerKey(state: MockupStateId): string {
|
||||
if (state === "review") return "review";
|
||||
if (state === "extend") return "extend";
|
||||
return "chat";
|
||||
/** Which panel is docked on the right. The plugin's panel exists once the plugin is installed. */
|
||||
function dockedPanel(state: MockupStateId, pluginIsInstalled: boolean): PanelId {
|
||||
if (state === "ship") return "ship";
|
||||
if (state === "extend" && pluginIsInstalled) return "plugin";
|
||||
return "changes";
|
||||
}
|
||||
|
||||
function panelKey(state: MockupStateId): string {
|
||||
if (state === "ship") return "ship";
|
||||
if (state === "extend") return "extend";
|
||||
return "changes";
|
||||
// Elements passed as props are hoisted so they are not rebuilt on every render.
|
||||
const PROGRESS_TRACK = <ProgressTrack />;
|
||||
|
||||
/** The scenes that play a script, with the agent the user talks to and that agent's composer track. */
|
||||
const SCRIPTED_SCENES: Partial<
|
||||
Record<MockupStateId, { script: SceneScript; agent: ProviderId; track?: React.ReactNode }>
|
||||
> = {
|
||||
orchestrate: { script: ORCHESTRATION, agent: "claude", track: <SubagentsTrack /> },
|
||||
extend: { script: PLUGIN_BUILD, agent: "codex" },
|
||||
};
|
||||
|
||||
/** A player for the mockup's scenes. It opens on the default scene. */
|
||||
export function createMockupPlayer(): ScenePlayer {
|
||||
return createScenePlayer(DEFAULT_MOCKUP_STATE, {
|
||||
orchestrate: ORCHESTRATION.duration,
|
||||
extend: PLUGIN_BUILD.duration,
|
||||
review: REVIEW.duration,
|
||||
});
|
||||
}
|
||||
|
||||
export { usePlayerScene as useMockupScene };
|
||||
|
||||
/** What the agent tab in front says in each scene. */
|
||||
function Transcript({ state }: { state: MockupStateId }) {
|
||||
const scripted = SCRIPTED_SCENES[state];
|
||||
if (scripted) return <ScriptedTranscript scene={state} script={scripted.script} />;
|
||||
return <AgentTranscript />;
|
||||
}
|
||||
|
||||
/** The composer under that transcript: whose agent it is, its track, and whether a turn runs. */
|
||||
function ChatComposer({ state }: { state: MockupStateId }) {
|
||||
const scripted = SCRIPTED_SCENES[state];
|
||||
if (scripted) {
|
||||
return (
|
||||
<ScriptedComposer
|
||||
scene={state}
|
||||
script={scripted.script}
|
||||
agent={scripted.agent}
|
||||
track={scripted.track}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Composer track={PROGRESS_TRACK} />;
|
||||
}
|
||||
|
||||
function CenterPane({ state }: { state: MockupStateId }) {
|
||||
if (state === "review") return <DiffReviewPane />;
|
||||
if (state === "remote") return <RemoteTerminalPane />;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
|
||||
{/* Bottom-anchored: the newest turn sits on the composer, older text clips. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden px-[27px] pb-[6px]">
|
||||
{state === "extend" ? <PluginBuildTranscript /> : <AgentTranscript />}
|
||||
{/* A chat scrolls like this: a short transcript starts at the top, and one taller than the
|
||||
pane keeps its newest turn on the composer while older text clips. The reversed column
|
||||
pins the bottom, and the auto margin lifts a short transcript to the top. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-hidden px-[27px] pt-[12px] pb-[6px]">
|
||||
<div className="mb-auto">
|
||||
<Transcript state={state} />
|
||||
</div>
|
||||
</div>
|
||||
<Composer showProgress={state !== "extend"} busy={state !== "extend"} />
|
||||
<ChatComposer state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The docked panel keeps its width while its contents swap. */
|
||||
function PanelCrossfade({ state }: { state: MockupStateId }) {
|
||||
/** The docked panel's contents cross-fade when the panel it shows changes. */
|
||||
function PanelCrossfade({ panel }: { panel: PanelId }) {
|
||||
return (
|
||||
<div className="relative" style={PANEL_STYLE}>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<AnimatePresence initial={false}>
|
||||
{/* Each panel keeps its own width and the dock clips it, so nothing inside a panel
|
||||
reflows while the dock resizes. */}
|
||||
<motion.div
|
||||
key={panelKey(state)}
|
||||
key={panel}
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
className="absolute inset-0"
|
||||
className="absolute inset-y-0 left-0"
|
||||
>
|
||||
<RightPanel state={state} />
|
||||
<RightPanel panel={panel} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -67,7 +133,7 @@ function CenterColumn({ state }: { state: MockupStateId }) {
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
key={centerKey(state)}
|
||||
key={state}
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
@@ -81,39 +147,59 @@ function CenterColumn({ state }: { state: MockupStateId }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The docked right panel, animating its width open and closed. */
|
||||
/** The docked right panel. Its width follows the panel it shows, plus one pixel for the divider. */
|
||||
function DockedPanel({ state }: { state: MockupStateId }) {
|
||||
const panel = dockedPanel(state, useSceneClock("extend", pluginInstalled));
|
||||
const open = useMemo(() => ({ width: panelWidth(panel) + 1, opacity: 1 }), [panel]);
|
||||
return (
|
||||
<motion.div
|
||||
initial={PANEL_COLLAPSED}
|
||||
animate={PANEL_OPEN}
|
||||
animate={open}
|
||||
exit={PANEL_COLLAPSED}
|
||||
className="relative flex shrink-0 overflow-hidden"
|
||||
>
|
||||
<div className="w-px shrink-0 bg-mock-border" />
|
||||
<PanelCrossfade state={state} />
|
||||
<PanelCrossfade panel={panel} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MockupWindow({ state }: { state: MockupStateId }) {
|
||||
export function MockupWindow({ player }: { player: ScenePlayer }) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const state = usePlayerScene(player);
|
||||
|
||||
return (
|
||||
<MotionConfig transition={reduceMotion ? INSTANT_TRANSITION : MOCKUP_TRANSITION}>
|
||||
<div
|
||||
className="relative flex select-none overflow-hidden bg-mock-surface0 text-mock-fg antialiased"
|
||||
style={WINDOW_STYLE}
|
||||
>
|
||||
{/* Three full-height columns, each owning its own header. The middle
|
||||
<ScenePlayerContext value={player}>
|
||||
<MotionConfig transition={reduceMotion ? INSTANT_TRANSITION : MOCKUP_TRANSITION}>
|
||||
<div
|
||||
className="relative flex select-none overflow-hidden bg-mock-surface0 text-mock-fg antialiased"
|
||||
style={WINDOW_STYLE}
|
||||
>
|
||||
{/* Three full-height columns, each owning its own header. The middle
|
||||
header stops at the divider — it never spans over the right panel. */}
|
||||
<Sidebar state={state} />
|
||||
<div className="w-px shrink-0 bg-mock-border" />
|
||||
<CenterColumn state={state} />
|
||||
<AnimatePresence initial={false}>
|
||||
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
<Sidebar state={state} />
|
||||
<div className="w-px shrink-0 bg-mock-border" />
|
||||
<CenterColumn state={state} />
|
||||
<AnimatePresence initial={false}>
|
||||
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
|
||||
</AnimatePresence>
|
||||
{/* The remote scene opens the hosts modal over the whole window, as the app dims
|
||||
everything behind a modal. */}
|
||||
<AnimatePresence initial={false}>
|
||||
{state === "remote" ? (
|
||||
<motion.div
|
||||
key="hosts-modal"
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/55"
|
||||
>
|
||||
<HostsModal />
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
</ScenePlayerContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -446,7 +446,8 @@
|
||||
},
|
||||
"mockup": {
|
||||
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
|
||||
"build": "Parallel agents",
|
||||
"orchestrate": "Orchestration",
|
||||
"remote": "Remote",
|
||||
"review": "Code review",
|
||||
"ship": "Pull requests",
|
||||
"extend": "Plugins",
|
||||
@@ -459,6 +460,15 @@
|
||||
"working": "Working",
|
||||
"done": "Done",
|
||||
"addProject": "Add project",
|
||||
"addHost": "Add host",
|
||||
"hosts": "Hosts",
|
||||
"agentCount": "{{count}} agents",
|
||||
"hostLinks": {
|
||||
"local": "Local",
|
||||
"relay": "Relay",
|
||||
"tailscale": "Tailscale",
|
||||
"ssh": "SSH"
|
||||
},
|
||||
"files": "Files",
|
||||
"changes": "Changes",
|
||||
"uncommitted": "Uncommitted",
|
||||
@@ -473,6 +483,11 @@
|
||||
"tasksProgress": "{{completed}}/{{total}} tasks",
|
||||
"composerPlaceholder": "Message the agent, tag @files, or use /commands and /skills",
|
||||
"mobileComposerPlaceholder": "Message, @files, /commands",
|
||||
"reviewCommentPlaceholder": "Leave a comment",
|
||||
"reviewCancel": "Cancel",
|
||||
"reviewSave": "Comment",
|
||||
"sendFeedback": "Send feedback ({{count}})",
|
||||
"feedbackSent": "Feedback sent to {{recipient}}",
|
||||
"plugin": "plugin",
|
||||
"mergedWeek": "Merged · 7d",
|
||||
"inReview": "In review",
|
||||
@@ -482,7 +497,8 @@
|
||||
"minutesAgo": "{{count}}m ago",
|
||||
"hoursAgo": "{{count}}h ago",
|
||||
"reviewing": "reviewing",
|
||||
"subagents": "{{count}} subagents",
|
||||
"subagents_one": "{{count}} subagent",
|
||||
"subagents_other": "{{count}} subagents",
|
||||
"checksPassedShort": "passed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +446,8 @@
|
||||
},
|
||||
"mockup": {
|
||||
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
|
||||
"build": "并行 Agent",
|
||||
"orchestrate": "编排",
|
||||
"remote": "远程",
|
||||
"review": "代码审查",
|
||||
"ship": "Pull Request",
|
||||
"extend": "插件",
|
||||
@@ -459,6 +460,15 @@
|
||||
"working": "运行中",
|
||||
"done": "已完成",
|
||||
"addProject": "添加 project",
|
||||
"addHost": "添加主机",
|
||||
"hosts": "主机",
|
||||
"agentCount": "{{count}} 个 Agent",
|
||||
"hostLinks": {
|
||||
"local": "本地",
|
||||
"relay": "中继",
|
||||
"tailscale": "Tailscale",
|
||||
"ssh": "SSH"
|
||||
},
|
||||
"files": "文件",
|
||||
"changes": "变更",
|
||||
"uncommitted": "未 commit",
|
||||
@@ -473,6 +483,11 @@
|
||||
"tasksProgress": "{{completed}}/{{total}} 项任务",
|
||||
"composerPlaceholder": "给 Agent 发消息,标记 @files,或使用 /commands 和 /skills",
|
||||
"mobileComposerPlaceholder": "发消息,@files,/commands",
|
||||
"reviewCommentPlaceholder": "留下评论",
|
||||
"reviewCancel": "取消",
|
||||
"reviewSave": "评论",
|
||||
"sendFeedback": "发送反馈 ({{count}})",
|
||||
"feedbackSent": "已向 {{recipient}} 发送反馈",
|
||||
"plugin": "插件",
|
||||
"mergedWeek": "已合并 · 7 天",
|
||||
"inReview": "审查中",
|
||||
@@ -482,7 +497,8 @@
|
||||
"minutesAgo": "{{count}} 分钟前",
|
||||
"hoursAgo": "{{count}} 小时前",
|
||||
"reviewing": "审查中",
|
||||
"subagents": "{{count}} 个 subagent",
|
||||
"subagents_one": "{{count}} 个 subagent",
|
||||
"subagents_other": "{{count}} 个 subagent",
|
||||
"checksPassedShort": "通过"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,7 +770,11 @@
|
||||
--color-mock-tile-violet: #7a6aa8;
|
||||
--color-mock-tile-amber: #8f7838;
|
||||
--color-mock-tile-teal: #368080;
|
||||
/* Host badge ink — the identity colors as foreground, IDENTITY_FOREGROUND_DARK in the
|
||||
same file. */
|
||||
--color-mock-host-teal: #6cacab;
|
||||
--color-mock-host-violet: #a392d5;
|
||||
--color-mock-host-orange: #cc8f64;
|
||||
|
||||
/* Status *dot* band (dark: L=0.72, chroma 90%). Sidebar row state dots only.
|
||||
theme.ts:198-204. */
|
||||
|
||||
@@ -35,7 +35,7 @@ Under **Subscribe to events**, select **Issue comment**, **Issues**, **Pull requ
|
||||
|
||||
## Connect repositories
|
||||
|
||||
After Hub verifies the App, choose **Install on GitHub**. Select the account or organization and repositories the App may access.
|
||||
After Hub verifies the App, choose **Connect GitHub**. Select the account or organization and repositories the App may access.
|
||||
|
||||
Start from Hub rather than GitHub's own install button. The round trip binds the installation to the active Hub organization.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ GitHub 会使用以下 Hub URL:
|
||||
|
||||
## 连接仓库 {#connect-repositories}
|
||||
|
||||
Hub 验证 App 之后,选择 **Install on GitHub**。选择 App 可以访问的账号或组织以及仓库。
|
||||
Hub 验证 App 之后,选择 **Connect GitHub**。选择 App 可以访问的账号或组织以及仓库。
|
||||
|
||||
请从 Hub 发起安装,而不是使用 GitHub 自己的安装按钮。这一来一回会把安装绑定到当前的 Hub 组织。
|
||||
|
||||
|
||||
@@ -1279,9 +1279,12 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
|
||||
|
||||
The daemon stamps `pluginId` from the calling plugin session and rejects this RPC from non-plugin
|
||||
sessions. The row appears live, survives timeline refetches, and keeps only the latest value for the
|
||||
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row. Daemons
|
||||
reject `data` over the limit rather than truncating it. Daemons that support this operation
|
||||
advertise `server_info.features.pluginTimelineItems`.
|
||||
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row.
|
||||
|
||||
`kind` starts with a lowercase letter and contains lowercase letters, numbers, or hyphens, the same
|
||||
rule `addTimelineRenderer` applies. Daemons reject any other `kind`. Daemons reject `data` over the
|
||||
limit rather than truncating it. Daemons that support this operation advertise
|
||||
`server_info.features.pluginTimelineItems`.
|
||||
|
||||
## Theme and layout
|
||||
|
||||
|
||||
@@ -1060,7 +1060,9 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
|
||||
| `version` | 正整数 | 是 | 选择渲染器契约的版本。 |
|
||||
| `data` | 兼容 JSON | 是 | 渲染器负载,JSON 序列化后最多 64 KiB。 |
|
||||
|
||||
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
|
||||
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。
|
||||
|
||||
`kind` 以小写字母开头,只包含小写字母、数字或连字符,与 `addTimelineRenderer` 使用的规则相同。daemon 会拒绝其他任何 `kind`。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
|
||||
|
||||
## 主题与布局 {#theme-and-layout}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user