Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 4e1eb513b6 fix(app): keep the next queued message queued when one is sent now
Sending a queued message while the agent runs can replace its turn: the
daemon cancels the running turn and starts a new one for the sent message
(Interrupt, Queue-mode send now, and Steer on providers that cannot steer).
The app read the cancel as the agent stopping and immediately sent the next
queued message, which then replaced the turn just started for the first.

The queue now only drains when the agent is idle by the same rule the UI
uses for "working": no open turn and no sent message still waiting for the
provider to pick it up.
2026-09-28 17:56:24 +02:00
46 changed files with 412 additions and 1513 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
sha256-zhJ9vf0n5wmOP3Hg20vua91TWJcOIbm6kTSXCcryOlg=
@@ -1282,6 +1282,49 @@ test.describe("Agent message submission", () => {
}
});
for (const behavior of ["Steer", "Interrupt"] as const) {
test(`keeps the next queued message queued after sending one now with ${behavior}`, async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: `queued-send-now-${behavior.toLowerCase()}-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user sends a queued message now.",
});
const firstPrompt = "Send the first queued message now.";
const secondPrompt = "Keep the second queued message queued.";
try {
await (behavior === "Steer"
? configureSteerInSettings(page)
: configureInterruptInSettings(page));
await page.goBack();
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
await queueMessage(page, firstPrompt);
await queueMessage(page, secondPrompt);
const sendNow = page.getByRole("button", { name: "Send queued message now" });
await expect(sendNow).toHaveCount(2);
await sendNow.first().click();
await expect(page.getByTestId("user-message").filter({ hasText: firstPrompt })).toHaveCount(
1,
);
await expect(
page.getByTestId("user-message").filter({ hasText: firstPrompt }),
).toHaveAttribute("aria-busy", "false");
await expectAgentReadyToInterrupt(page);
await expect(sendNow).toHaveCount(1);
await expect(
page.getByTestId("user-message").filter({ hasText: secondPrompt }),
).toHaveCount(0);
} finally {
await agent.cleanup();
}
});
}
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
@@ -1,51 +0,0 @@
import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { getServerId } from "../support/helpers/server-id";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
test("agent list age advances and command center says just now for a fresh agent", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "agent-relative-time-",
title: "Relative time agent",
});
try {
await session.client.waitForAgentUpsert(session.agentId, (agent) => agent.status === "idle");
await resetSeededPageState(page);
await page.clock.install({ time: Date.now() });
await openSessions(page);
const row = page.getByTestId(`agent-row-${getServerId()}-${session.agentId}`);
await expect(row).toContainText("just now", { timeout: 30_000 });
const commandCenter = await openCommandCenter(page);
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
const agentResult = commandCenter.getByTestId(
`command-center-agent-${getServerId()}:${session.agentId}`,
);
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
"just now",
);
await page.keyboard.press("Escape");
await page.clock.fastForward("03:00");
await expect(row).toContainText("3m ago");
await openAgentRoute(page, session);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().hover();
await expect(page.getByTestId(`workspace-tab-tooltip-agent_${session.agentId}`)).toContainText(
"3m ago",
);
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
await closeSidebarDisplayPreferences(page);
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
} finally {
await session.cleanup();
}
});
@@ -87,25 +87,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
});
test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "commit-relative-time-" });
const committedAt = new Date();
await createFeatureCommit(workspace.repoPath, committedAt.toISOString());
await page.clock.install({ time: committedAt });
await workspace.navigateTo();
await openChangesTreePanel(page);
await page.getByRole("button", { name: /Commits/i }).click();
const row = page.locator('[data-testid^="commit-row-"]').filter({ hasText: COMMIT_SUBJECT });
await expect(row).toContainText("just now", { timeout: 30_000 });
await page.clock.fastForward("03:00");
await expect(row).toContainText("3m ago");
});
async function createFeatureCommit(
repoPath: string,
authorDate = "2020-01-15T12:00:00Z",
): Promise<void> {
async function createFeatureCommit(repoPath: string): Promise<void> {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
@@ -114,8 +96,8 @@ async function createFeatureCommit(
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_DATE: authorDate,
GIT_COMMITTER_DATE: authorDate,
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
},
});
}
@@ -7,11 +7,7 @@ import {
expectFileTabOpen,
} from "../support/helpers/file-explorer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { gotoAppShell } from "../support/helpers/app";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -96,40 +92,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
const filePath = path.join(workspace.repoPath, "src", "target.ts");
try {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Absolute file link tooltip",
modeId: "load-test",
model: "e2e-fast-stream",
initialPrompt: "Show the file link",
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
);
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await workspaceRow.click();
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
const fileLink = page.getByRole("link", { name: "Open target" }).first();
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.hover();
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
} finally {
await workspace.cleanup();
}
});
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
page,
}) => {
@@ -1,111 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { openHostSection, selectSettingsHost } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import {
startRestartableHostDaemon,
type RestartableHostDaemon,
} from "../support/helpers/versioned-host-daemon";
const PREVIOUS_VERSION = "0.8.0";
const UPDATED_VERSION = "0.9.1";
const HOST_LABEL = "Version restart QA";
const NO_RELOAD_MARKER = "before-restart";
interface ReloadMarkerWindow {
__paseoE2eHostVersionMarker?: string;
}
test.describe.configure({ timeout: 120_000 });
let hostDaemon: RestartableHostDaemon | null = null;
test.afterEach(async () => {
await hostDaemon?.dispose();
hostDaemon = null;
});
test("host page shows the restarted daemon's version without reloading", async ({ page }) => {
const host = await startRestartableHostDaemon(PREVIOUS_VERSION);
hostDaemon = host;
const otherHostVersion =
await test.step("the connected host reports its current daemon version", async () => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: host.serverId,
label: HOST_LABEL,
port: host.port,
});
const version = await readHelpHostVersion(page, getServerId());
await expectHelpHostVersion(page, host.serverId, PREVIOUS_VERSION);
await openHostPage(page, host);
await expect(hostIdentity(page)).toContainText("Online");
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
await markPageForNoReloadCheck(page, NO_RELOAD_MARKER);
return version;
});
await test.step("the last known version remains on the host page when the host stops", async () => {
await host.stop();
await expect(hostIdentity(page)).toContainText("Error");
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
});
await test.step("the host daemon restarts on the new version", async () => {
await host.restartWithVersion(UPDATED_VERSION);
});
await test.step("the host page badge follows the new version", async () => {
await expect(hostVersionBadge(page, UPDATED_VERSION)).toBeVisible({ timeout: 30_000 });
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toHaveCount(0);
await page.getByRole("button", { name: "Back" }).click();
await expectHelpHostVersion(page, host.serverId, UPDATED_VERSION);
expect(await readHelpHostVersion(page, getServerId())).toBe(otherHostVersion);
await expectNoReloadSinceMarker(page);
});
});
async function readHelpHostVersion(page: Page, serverId: string): Promise<string> {
await page.getByTestId("sidebar-help").click();
const row = page.getByTestId(`sidebar-help-host-version-${serverId}`);
await expect(row).toBeVisible();
const text = await row.innerText();
await page.keyboard.press("Escape");
return text;
}
async function expectHelpHostVersion(page: Page, serverId: string, version: string): Promise<void> {
const text = await readHelpHostVersion(page, serverId);
expect(text).toContain(`v${version}`);
}
async function openHostPage(page: Page, host: RestartableHostDaemon): Promise<void> {
await openSettings(page);
await selectSettingsHost(page, host.serverId);
await openHostSection(page, host.serverId, "host");
}
function hostIdentity(page: Page) {
return page.getByTestId("host-page-identity");
}
function hostVersionBadge(page: Page, version: string) {
return hostIdentity(page).getByText(`v${version}`, { exact: true });
}
async function markPageForNoReloadCheck(page: Page, marker: string): Promise<void> {
// The marker has to cross into the page as an argument: the callback runs in the browser, so a
// captured Node-side constant is not defined there.
await page.evaluate((value) => {
(window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker = value;
}, marker);
}
async function expectNoReloadSinceMarker(page: Page): Promise<void> {
const marker = await page.evaluate(
() => (window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker ?? null,
);
expect(marker).toBe(NO_RELOAD_MARKER);
}
@@ -4,7 +4,7 @@ import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { test, type Page } from "../support/fixtures";
import { ImportSessionFlow } from "../support/helpers/import-session";
import {
connectNewWorkspaceDaemonClient,
@@ -49,7 +49,6 @@ interface ImportFlowScenario {
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
importSessionTimestamp: number;
repoCleanup(): Promise<void>;
unrelatedCleanup(): Promise<void>;
}
@@ -95,7 +94,7 @@ test.beforeAll(async () => {
};
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
const importSessionId = "fixture-custom-title";
const importSessionTimestamp = await seedClaudeSessions({
await seedClaudeSessions({
projectRoot: repo.path,
worktreeDirectory,
unrelatedDirectory: unrelated.path,
@@ -109,7 +108,6 @@ test.beforeAll(async () => {
worktreeDirectory,
unrelatedDirectory: unrelated.path,
importSessionId,
importSessionTimestamp,
repoCleanup: repo.cleanup,
unrelatedCleanup: unrelated.cleanup,
};
@@ -124,19 +122,6 @@ test.afterAll(async () => {
await rm(claudeConfigDirectory, { recursive: true, force: true });
});
test("an open Import Session row keeps its age current", async ({ page }) => {
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await flow.revealMobileEntryPoint();
await flow.openGlobally();
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
await expect(row).toContainText("1m ago");
await page.clock.fastForward("03:00");
await expect(row).toContainText("4m ago");
});
test("captures the compact import-session journey", async ({ page }, testInfo) => {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
@@ -240,7 +225,7 @@ async function seedClaudeSessions(input: {
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
}): Promise<number> {
}): Promise<void> {
const sessions = [
{
cwd: input.projectRoot,
@@ -305,5 +290,4 @@ async function seedClaudeSessions(input: {
const timestamp = new Date(newest - index * 60_000);
await utimes(sessionPath, timestamp, timestamp);
}
return newest;
}
@@ -119,25 +119,6 @@ async function exerciseProviderSettingsStack(page: Page) {
}
test.describe("provider settings overlay stack", () => {
test("provider model update age says just now", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-relative-time-",
title: "Provider relative time",
});
try {
await page.clock.install({ time: Date.now() });
await page.setViewportSize(MOBILE_VIEWPORT);
await openAgentRoute(page, session);
await expectComposerVisible(page);
await openProviderSettingsFromModelSelector(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await session.cleanup();
}
});
test("provider settings covers the desktop model selector without closing it", async ({
page,
}) => {
@@ -1,118 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { buildSchedulesRoute } from "../../src/utils/host-routes";
interface ScheduleSeedClient {
scheduleCreate(input: {
prompt: string;
name?: string;
cadence: { type: "cron"; expression: string };
target: {
type: "new-agent";
config: {
provider: "mock";
cwd: string;
model: string;
modeId: string;
title: string;
};
};
runOnCreate: boolean;
}): Promise<{ schedule: { id: string; createdAt: string } | null; error: string | null }>;
schedulePause(input: { id: string }): Promise<{ error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
}
interface SeededSchedule {
id: string;
createdAt: number;
}
async function seedPausedSchedule(
workspace: SeededWorkspace,
name: string,
): Promise<SeededSchedule> {
const client = workspace.client as unknown as ScheduleSeedClient;
const result = await client.scheduleCreate({
prompt: "Say hello from the scheduled agent.",
name,
cadence: { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: workspace.repoPath,
model: "ten-second-stream",
modeId: "load-test",
title: name,
},
},
runOnCreate: false,
});
if (!result.schedule) {
throw new Error(result.error ?? "Failed to seed schedule");
}
const { id, createdAt } = result.schedule;
const paused = await client.schedulePause({ id });
if (paused.error) {
throw new Error(paused.error);
}
return { id, createdAt: Date.parse(createdAt) };
}
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
const client = workspace.client as unknown as ScheduleSeedClient;
const result = await client.scheduleDelete({ id });
if (result.error) {
throw new Error(result.error);
}
}
/** Opens Schedules with the browser clock frozen at `time`, so the page cannot age it. */
async function openSchedulesAt(page: Page, time: number): Promise<void> {
await page.clock.install({ time });
await page.goto(buildSchedulesRoute());
}
async function letTimePass(page: Page, duration: string): Promise<void> {
await page.clock.fastForward(duration);
}
async function expectScheduleRowText(page: Page, scheduleId: string, text: string): Promise<void> {
await expect(page.getByTestId(`schedule-row-${scheduleId}`)).toContainText(text, {
timeout: 30_000,
});
}
test.describe("Schedule relative timestamps", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
const failures: unknown[] = [];
for (const cleanup of cleanupTasks.toReversed()) {
try {
await cleanup();
} catch (error) {
failures.push(error);
}
}
cleanupTasks.length = 0;
if (failures.length > 0) {
throw new AggregateError(failures, "Test cleanup failed");
}
});
test("an idle schedule row keeps its created age current", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-relative-time-", git: false });
cleanupTasks.push(() => workspace.cleanup());
const schedule = await seedPausedSchedule(workspace, `Relative time ${Date.now()}`);
cleanupTasks.push(() => deleteSeededSchedule(workspace, schedule.id));
await openSchedulesAt(page, schedule.createdAt);
await expectScheduleRowText(page, schedule.id, "Created just now");
await letTimePass(page, "03:00");
await expectScheduleRowText(page, schedule.id, "Created 3m ago");
});
});
@@ -1,149 +0,0 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
const SETTINGS_KEY = "@paseo:app-settings";
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, { workspaceId, agentId: parentId });
await expect(page.getByTestId(`workspace-tab-agent_${parentId}`)).toBeVisible();
await page.getByTestId("workspace-pane-main").getByTestId("workspace-new-tab-button").click();
await page.getByTestId("workspace-new-tab-menu-agent").click();
await page.getByTestId(`workspace-tab-agent_${parentId}`).click();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page
.getByTestId("workspace-pane-main")
.getByTestId(`workspace-tab-agent_${parentId}`)
.click();
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
const left = page.getByTestId("workspace-pane-main");
const right = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId(`workspace-tab-agent_${parentId}`) });
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
return { left, right };
}
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
});
});
test("a subagent opened from a right-pane parent stays with its parent when left has focus", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "subagent-origin-pane-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Right parent",
childTitle: "Child agent",
});
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("the side preference opens a right-pane parent's subagent in the side pane", async ({
page,
}) => {
await page.addInitScript((key) => {
localStorage.setItem(key, JSON.stringify({ openInSidePane: { subagents: true } }));
}, SETTINGS_KEY);
const workspace = await seedWorkspace({ repoPrefix: "subagent-side-preference-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Right parent",
childTitle: "Child agent",
});
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
const childPane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId(`workspace-tab-agent_${pair.child.id}`) });
await expect(childPane).toBeVisible();
expect(await childPane.getAttribute("data-testid")).not.toBe(
await right.getAttribute("data-testid"),
);
await expect(childPane).not.toHaveAttribute("data-testid", "workspace-pane-main");
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("opening an already-tabbed subagent reveals its left tab without a duplicate", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "subagent-existing-tab-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Right parent",
childTitle: "Child agent",
});
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
await page.keyboard.press("Meta+Alt+Shift+ArrowLeft");
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
await right.getByTestId(`workspace-tab-agent_${pair.parent.id}`).click();
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
await expect(page.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(1);
} finally {
await workspace.cleanup();
}
});
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
const target = "linked.ts:3";
const workspace = await seedMockAgentWorkspace({
repoPrefix: "chat-file-origin-pane-",
title: "File link parent",
initialPrompt: [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n"),
});
try {
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
const { left, right } = await openParentInRightPane(
page,
workspace.agentId,
workspace.workspaceId,
);
const fileLink = right.getByText(target, { exact: true });
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.click();
await expect(right.getByTestId("workspace-tab-file_linked.ts")).toBeVisible();
await expect(left.getByTestId("workspace-tab-file_linked.ts")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
@@ -41,13 +41,6 @@ async function getAvailablePort(): Promise<number> {
});
}
export async function getAvailableHostDaemonPort(): Promise<number> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
return port;
}
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const deadline = Date.now() + 90_000;
let lastError: unknown = null;
@@ -88,7 +81,9 @@ export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
): Promise<IsolatedHostDaemon> {
const port = await getAvailableHostDaemonPort();
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
@@ -1,176 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { getAvailableHostDaemonPort } from "./isolated-host-daemon";
import { killProcessTree, spawnTsx } from "./spawn-node";
const VERSIONED_DAEMON_ENTRYPOINT = path.resolve(
__dirname,
"../../../../server/src/server/test-utils/versioned-daemon.ts",
);
const READY_TIMEOUT_MS = 30_000;
// A host daemon that can be replaced by a daemon reporting another version under the same
// endpoint and serverId, the way an upgraded host comes back.
export interface RestartableHostDaemon {
serverId: string;
port: number;
stop(): Promise<void>;
restartWithVersion(version: string): Promise<void>;
dispose(): Promise<void>;
}
export async function startRestartableHostDaemon(version: string): Promise<RestartableHostDaemon> {
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-restartable-host-"));
const port = await getAvailableHostDaemonPort();
let current: VersionedHostDaemon | null = null;
async function dispose(): Promise<void> {
await current?.stop();
current = null;
await rm(paseoHomeRoot, { recursive: true, force: true });
}
try {
current = await startVersionedHostDaemon({ version, port, paseoHomeRoot });
} catch (error) {
await dispose();
throw error;
}
const { serverId } = current;
return {
serverId,
port,
async stop() {
await current?.stop();
current = null;
await waitForPortReleased(port);
},
async restartWithVersion(nextVersion) {
await current?.stop();
current = null;
await waitForPortReleased(port);
current = await startVersionedHostDaemon({ version: nextVersion, port, paseoHomeRoot });
if (current.serverId !== serverId) {
throw new Error(
`Restarted host daemon reported serverId ${current.serverId}, expected ${serverId}`,
);
}
},
dispose,
};
}
interface VersionedHostDaemon {
serverId: string;
port: number;
version: string;
stop(): Promise<void>;
}
interface VersionedHostDaemonOptions {
version: string;
port: number;
paseoHomeRoot: string;
}
// Starts the versioned test daemon on an explicit port with a caller-owned PASEO_HOME. The caller
// removes the home root.
async function startVersionedHostDaemon(
options: VersionedHostDaemonOptions,
): Promise<VersionedHostDaemon> {
const child = spawnTsx(
VERSIONED_DAEMON_ENTRYPOINT,
[options.version, String(options.port), options.paseoHomeRoot],
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
);
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
let output = "";
child.stdout?.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
});
child.stderr?.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
});
child.once("error", ready.reject);
child.once("exit", () => {
ready.reject(
new Error(`Versioned daemon ${options.version} exited before reporting ready:\n${output}`),
);
});
child.once("message", (message) => {
if (!isReadyMessage(message)) {
ready.reject(new Error("Invalid versioned daemon startup message"));
return;
}
ready.resolve(message);
});
const timeout = setTimeout(
() =>
ready.reject(
new Error(
`Versioned daemon ${options.version} did not report ready within ${READY_TIMEOUT_MS}ms:\n${output}`,
),
),
READY_TIMEOUT_MS,
);
let stopped = false;
async function stop(): Promise<void> {
if (stopped) return;
stopped = true;
await killProcessTree(child);
}
try {
const readyMessage = await ready.promise;
if (readyMessage.port !== options.port) {
throw new Error(
`Versioned daemon ${options.version} bound port ${readyMessage.port}, expected ${options.port}`,
);
}
return {
serverId: readyMessage.serverId,
port: readyMessage.port,
version: options.version,
stop,
};
} catch (error) {
await stop();
throw error;
} finally {
clearTimeout(timeout);
}
}
// A replacement daemon can only bind the port the stopped daemon just released.
async function waitForPortReleased(port: number, timeoutMs = 15_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isPortAvailable(port)) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Port ${port} was not released within ${timeoutMs}ms`);
}
function isReadyMessage(message: unknown): message is { port: number; serverId: string } {
return (
typeof message === "object" &&
message !== null &&
"port" in message &&
typeof message.port === "number" &&
"serverId" in message &&
typeof message.serverId === "string"
);
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
+33 -2
View File
@@ -10,7 +10,6 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
import { formatFileLinkTooltipPath } from "./tooltip-path";
import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps {
@@ -35,7 +34,7 @@ export function AssistantMarkdownLink({
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo(
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
[target, workspaceRoot],
);
const linkPress = useMemo<AssistantLinkPress>(
@@ -128,6 +127,38 @@ export function AssistantMarkdownCodeLink({
);
}
function formatInlinePathTargetForTooltip(
target: { path: string; lineStart?: number; lineEnd?: number },
workspaceRoot: string | undefined,
): string {
let result = relativizePathToWorkspace(target.path, workspaceRoot);
if (target.lineStart) {
result += `:${target.lineStart}`;
if (target.lineEnd && target.lineEnd !== target.lineStart) {
result += `-${target.lineEnd}`;
}
}
return result;
}
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
if (!workspaceRoot) {
return filePath;
}
const root = workspaceRoot.replace(/\/+$/, "");
if (!root) {
return filePath;
}
if (filePath === root) {
return ".";
}
const prefix = `${root}/`;
if (filePath.startsWith(prefix)) {
return filePath.slice(prefix.length);
}
return filePath;
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
@@ -1,59 +0,0 @@
import { describe, expect, it } from "vitest";
import { formatFileLinkTooltipPath } from "./tooltip-path";
describe("formatFileLinkTooltipPath", () => {
it("shows a Windows file path relative to its workspace regardless of separators", () => {
expect(
formatFileLinkTooltipPath({
target: {
path: "C:/Users/me/repo/src/app.ts",
lineStart: 12,
lineEnd: 20,
},
workspaceRoot: "C:\\Users\\me\\repo",
}),
).toBe("src/app.ts:12-20");
expect(
formatFileLinkTooltipPath({
target: { path: "C:/src/app.ts", lineStart: 12 },
workspaceRoot: "C:\\",
}),
).toBe("src/app.ts:12");
});
it("shows the workspace root as a dot", () => {
expect(
formatFileLinkTooltipPath({
target: { path: "/Users/me/repo" },
workspaceRoot: "/Users/me/repo",
}),
).toBe(".");
expect(
formatFileLinkTooltipPath({
target: { path: "C:/Users/Me/Repo/", lineStart: 4 },
workspaceRoot: "c:\\users\\me\\repo",
}),
).toBe(".:4");
});
it("keeps an absolute path outside the workspace", () => {
expect(
formatFileLinkTooltipPath({
target: { path: "/Users/me/notes.md" },
workspaceRoot: "/Users/me/repo",
}),
).toBe("/Users/me/notes.md");
expect(
formatFileLinkTooltipPath({
target: { path: "C:/Users/me/notes.md", lineStart: 8 },
workspaceRoot: "C:\\Users\\me\\repo",
}),
).toBe("C:/Users/me/notes.md:8");
});
it("keeps the target path when the workspace root is unavailable", () => {
expect(formatFileLinkTooltipPath({ target: { path: "src/app.ts", lineStart: 12 } })).toBe(
"src/app.ts:12",
);
});
});
@@ -1,30 +0,0 @@
import {
isWorkspaceRootPath,
resolveWorkspaceFilePaths,
type WorkspaceFileLocation,
} from "@/workspace/file-open";
interface FormatFileLinkTooltipPathInput {
target: WorkspaceFileLocation;
workspaceRoot?: string;
}
export function formatFileLinkTooltipPath({
target,
workspaceRoot,
}: FormatFileLinkTooltipPathInput): string {
const resolvedPaths = workspaceRoot
? resolveWorkspaceFilePaths({ path: target.path, workspaceRoot })
: null;
const isWorkspaceRoot = workspaceRoot
? isWorkspaceRootPath({ path: target.path, workspaceRoot })
: false;
let result = isWorkspaceRoot ? "." : (resolvedPaths?.relativePath ?? target.path);
if (target.lineStart) {
result += `:${target.lineStart}`;
if (target.lineEnd && target.lineEnd !== target.lineStart) {
result += `-${target.lineEnd}`;
}
}
return result;
}
+6 -12
View File
@@ -14,7 +14,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { formatTimeAgo } from "@/utils/time";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive, ChevronRight } from "lucide-react-native";
@@ -181,6 +181,7 @@ function SessionRow({
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const projectName = agent.projectPlacement?.projectName ?? "";
@@ -282,7 +283,7 @@ function SessionRow({
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<AgentActivityTime date={agent.lastActivityAt} isMobile />
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{showHostColumn && agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
@@ -315,7 +316,9 @@ function SessionRow({
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
/>
<AgentActivityTime date={agent.lastActivityAt} isMobile={false} />
<Text style={styles.columnMetaFixed} numberOfLines={1}>
{timeAgo}
</Text>
</View>
) : null}
<SessionRowTrailingAttention
@@ -327,15 +330,6 @@ function SessionRow({
);
}
function AgentActivityTime({ date, isMobile }: { date: Date; isMobile: boolean }) {
const label = useTimeAgo(date);
return (
<Text style={isMobile ? styles.sessionMetaText : styles.columnMetaFixed} numberOfLines={1}>
{label}
</Text>
);
}
export function AgentList({
agents,
isRefreshing = false,
@@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { formatTimeAgo } from "@/utils/time";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostProjects } from "@/projects/host-projects";
@@ -309,6 +309,7 @@ function ImportSessionSheetRow({
const { t } = useTranslation();
const title = getSessionTitle(entry);
const promptPreview = getPromptPreview(entry);
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
const accessibilityState = useMemo(
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
@@ -343,11 +344,9 @@ function ImportSessionSheetRow({
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
{importing ? (
<Text style={styles.rowMeta}>{t("importSession.row.importing")}</Text>
) : (
<ImportSessionActivityTime date={entry.lastActivityAt} />
)}
<Text style={styles.rowMeta}>
{importing ? t("importSession.row.importing") : lastActivity}
</Text>
</View>
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
@@ -366,11 +365,6 @@ function ImportSessionSheetRow({
);
}
function ImportSessionActivityTime({ date }: { date: string }) {
const label = useTimeAgo(new Date(date));
return <Text style={styles.rowMeta}>{label}</Text>;
}
function SessionRows({
serverId,
entries,
@@ -19,10 +19,10 @@ import { useToast } from "@/contexts/toast-context";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import { formatTimeAgo } from "@/utils/time";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
@@ -409,7 +409,7 @@ interface ProviderModalBodyProps {
}
interface ProviderSheetFooterInput {
fetchedAt: string | null;
fetchedAtLabel: string | null;
isCompact: boolean;
modelsRefreshing: boolean;
t: TFunction;
@@ -419,7 +419,7 @@ interface ProviderSheetFooterInput {
}
function renderProviderSheetFooter({
fetchedAt,
fetchedAtLabel,
isCompact,
modelsRefreshing,
t,
@@ -430,10 +430,16 @@ function renderProviderSheetFooter({
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
const metaStyle = isCompact
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
: sheetStyles.footerMeta;
return (
<View style={contentStyle}>
{fetchedAt || !isCompact ? (
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
{fetchedAtLabel || !isCompact ? (
<Text style={metaStyle} numberOfLines={1}>
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
</Text>
) : null}
<View style={actionsStyle}>
<Button
@@ -471,25 +477,6 @@ function renderProviderSheetFooter({
);
}
function ProviderUpdatedLabel({
fetchedAt,
isCompact,
}: {
fetchedAt: string | null;
isCompact: boolean;
}) {
const { t } = useTranslation();
const label = useTimeAgo(fetchedAt ? new Date(fetchedAt) : null);
const style = isCompact
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
: sheetStyles.footerMeta;
return (
<Text style={style} numberOfLines={1}>
{fetchedAt ? t("settings.providers.models.updated", { time: label }) : ""}
</Text>
);
}
function ProviderModalBody(props: ProviderModalBodyProps) {
const { t } = useTranslation();
const {
@@ -622,6 +609,18 @@ export function ProviderDiagnosticSheet({
});
stableDiscoveredRef.current = nextDiscoveredCache;
const [clockTick, setClockTick] = useState(0);
useEffect(() => {
if (!visible) return;
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
return () => clearInterval(id);
}, [visible]);
const fetchedAtLabel = useMemo(() => {
if (!providerEntry?.fetchedAt) return null;
void clockTick;
return formatTimeAgo(new Date(providerEntry.fetchedAt));
}, [providerEntry?.fetchedAt, clockTick]);
useEffect(() => {
if (!visible) {
setQuery("");
@@ -687,7 +686,7 @@ export function ProviderDiagnosticSheet({
onClose={onClose}
testID="provider-settings-sheet"
footer={renderProviderSheetFooter({
fetchedAt: providerEntry?.fetchedAt ?? null,
fetchedAtLabel,
isCompact,
modelsRefreshing,
t,
@@ -22,7 +22,7 @@ import {
resolveScheduleTitle,
scheduleProductName,
} from "@/utils/schedule-format";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { formatTimeAgo } from "@/utils/time";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
@@ -96,19 +96,16 @@ function stateBadge(state: ScheduleDerivedState): {
// Meta reads left-to-right as identity → history → future: how often, when it
// was created, when it last ran, and (only while it can still run) when it runs
// next. Status lives on the badge, never repeated here.
function buildMeta(input: {
schedule: ScheduleSummary;
state: ScheduleDerivedState;
createdAgo: string;
lastRunAgo: string;
serverName: string | undefined;
singleHost: boolean;
}): string {
const { schedule, state, serverName, singleHost } = input;
function buildMeta(
schedule: ScheduleSummary,
state: ScheduleDerivedState,
serverName: string | undefined,
singleHost: boolean,
): string {
const parts = [
formatCadence(schedule.cadence),
`Created ${input.createdAgo}`,
schedule.lastRunAt ? `Last run ${input.lastRunAgo}` : "Never run",
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
];
if (state === "active") {
const next = formatNextRun(schedule.nextRunAt);
@@ -122,27 +119,6 @@ function buildMeta(input: {
return parts.join(" · ");
}
function ScheduleMeta({
schedule,
state,
serverName,
singleHost,
}: {
schedule: ScheduleSummary;
state: ScheduleDerivedState;
serverName: string | undefined;
singleHost: boolean;
}) {
const createdAgo = useTimeAgo(new Date(schedule.createdAt));
const lastRunAgo = useTimeAgo(schedule.lastRunAt ? new Date(schedule.lastRunAt) : null);
const meta = buildMeta({ schedule, state, createdAgo, lastRunAgo, serverName, singleHost });
return (
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
);
}
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
function ProviderGlyph({
@@ -192,6 +168,7 @@ export function ScheduleRow({
const title = resolveScheduleTitle(schedule);
const productName = scheduleProductName(schedule);
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
const rowStyle = useCallback(
@@ -229,12 +206,9 @@ export function ScheduleRow({
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<ScheduleMeta
schedule={schedule}
state={state}
serverName={serverName}
singleHost={singleHost ?? false}
/>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
</View>
</View>
@@ -4,7 +4,7 @@ import { DiffStat } from "@/components/diff-stat";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
export type { SidebarWorkspaceTrailing };
+28 -2
View File
@@ -32,7 +32,6 @@ import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
import {
selectAgentTimelineState,
toDaemonServerInfo,
useSessionStore,
type SessionState,
} from "@/stores/session-store";
@@ -319,6 +318,24 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[serverId],
);
useEffect(() => {
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo) {
return;
}
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
}, [client, serverId, updateSessionServerInfo]);
useEffect(() => {
const unregister = voiceRuntime?.registerSession({
serverId,
@@ -551,7 +568,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
if (serverInfo) {
updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
return;
}
});
@@ -4,13 +4,14 @@ import { StyleSheet } from "react-native-unistyles";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { formatTimeAgo } from "@/utils/time";
import { CommitGraphNode } from "./commit-graph-node";
interface CommitRowProps {
commit: ClassifiedCheckoutCommit;
isFirst: boolean;
isLast: boolean;
now: Date;
onCommitPress: (sha: string) => void;
}
@@ -25,6 +26,7 @@ export const CommitRow = memo(function CommitRow({
commit,
isFirst,
isLast,
now,
onCommitPress,
}: CommitRowProps) {
const handlePress = useCallback(() => {
@@ -47,7 +49,7 @@ export const CommitRow = memo(function CommitRow({
{commit.subject}
</Text>
</View>
<CommitTimestamp authorDate={commit.authorDate} />
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
<View style={styles.caret}>
<ThemedChevron size={14} uniProps={chevronColorMapping} />
</View>
@@ -55,11 +57,6 @@ export const CommitRow = memo(function CommitRow({
);
});
function CommitTimestamp({ authorDate }: { authorDate: string }) {
const label = useTimeAgo(new Date(authorDate));
return <Text style={styles.timestamp}>{label}</Text>;
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
@@ -1,8 +1,9 @@
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { treeRowPaddingLeft } from "@/components/tree-primitives";
@@ -39,9 +40,11 @@ function CommitsSectionSkeleton() {
function CommitsSectionContent({
query,
now,
onCommitPress,
}: {
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
now: Date;
onCommitPress: (sha: string) => void;
}) {
const { t } = useTranslation();
@@ -74,6 +77,7 @@ function CommitsSectionContent({
commit={commit}
isFirst={index === 0}
isLast={index === workspaceCommits.length - 1}
now={now}
onCommitPress={onCommitPress}
/>
))}
@@ -90,6 +94,9 @@ export function CommitsSection({
}: CommitsSectionProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isPanelActive = useRetainedPanelActive();
const [now, setNow] = useState(() => new Date());
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
const query = useCheckoutCommitsQuery({
serverId,
cwd,
@@ -97,9 +104,20 @@ export function CommitsSection({
});
const handleToggleSection = useCallback(() => {
if (collapsed) {
setNow(new Date());
}
onCollapsedChange?.(!collapsed);
}, [collapsed, onCollapsedChange]);
useEffect(() => {
if (collapsed || !isPanelActive) {
return;
}
const interval = setInterval(() => setNow(new Date()), 10_000);
return () => clearInterval(interval);
}, [collapsed, isPanelActive]);
const headerChevronStyle = useMemo(
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
[collapsed],
@@ -142,7 +160,9 @@ export function CommitsSection({
</Text>
)}
</Pressable>
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
{collapsed ? null : (
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
)}
</View>
);
}
@@ -1,11 +1,6 @@
import { useEffect, useState } from "react";
import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker";
import { describeCompactTimeAgo, describeTimeAgo, type RelativeTimeLabel } from "@/utils/time";
/** A prose relative timestamp ("5m ago") that keeps itself current. See `useCompactTimeAgo`. */
export function useTimeAgo(date: Date | null): string {
return useRelativeTimeLabel(date, describeTimeAgo);
}
import { describeCompactTimeAgo } from "@/utils/time";
/**
* A compact relative timestamp that keeps itself current.
@@ -24,14 +19,7 @@ export function useTimeAgo(date: Date | null): string {
* never changes again.
*/
export function useCompactTimeAgo(date: Date | null): string {
return useRelativeTimeLabel(date, describeCompactTimeAgo);
}
function useRelativeTimeLabel(
date: Date | null,
describe: (date: Date) => RelativeTimeLabel,
): string {
const [label, setLabel] = useState(() => (date ? describe(date).label : ""));
const [label, setLabel] = useState(() => (date ? describeCompactTimeAgo(date).label : ""));
// Keyed on the instant, not the Date object: the store parses a fresh Date on every payload, so
// depending on identity would tear down and rebuild the subscription for an unchanged time.
@@ -44,13 +32,13 @@ function useRelativeTimeLabel(
}
const source = new Date(time);
let current = describe(source);
let current = describeCompactTimeAgo(source);
setLabel(current.label);
let unsubscribe: (() => void) | null = null;
const handleTick = () => {
const next = describe(source);
const next = describeCompactTimeAgo(source);
if (next.label !== current.label) {
setLabel(next.label);
}
@@ -74,7 +62,7 @@ function useRelativeTimeLabel(
return () => {
unsubscribe?.();
};
}, [time, describe]);
}, [time]);
return label;
}
+87 -43
View File
@@ -17,6 +17,7 @@ import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { defaultHostAppearance } from "@/hosts/appearance";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createUserMessage } from "@/types/stream";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
@@ -158,28 +159,17 @@ class FakeDaemonClient {
}
public ownedSubscriptions = true;
private daemonVersion: string | null = "0.8.0";
getLastServerInfoMessage(): ReturnType<DaemonClient["getLastServerInfoMessage"]> {
if (this.daemonVersion === null) return null;
return {
status: "server_info",
serverId: "srv_test",
hostname: "test",
version: this.daemonVersion,
version: "0.8.0",
features: { ownedSubscriptions: this.ownedSubscriptions },
};
}
// Like the real client, the server info is cleared while disconnected and replaced by the
// restarted daemon's handshake before the client reports connected again.
daemonRestartsAs(version: string): void {
this.daemonVersion = null;
this.setConnectionState({ status: "disconnected", reason: "Connection lost" });
this.daemonVersion = version;
this.setConnectionState({ status: "connected" });
}
subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void {
this.listeners.add(listener);
listener(this.state);
@@ -2140,37 +2130,6 @@ describe("HostRuntimeStore", () => {
expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined();
});
it("shows the restarted daemon's version after the same client reconnects", async () => {
const host = makeHost({
serverId: "srv_restarted",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host: hostProfile }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: hostProfile.serverId,
hostname: hostProfile.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.8.0");
fakeClient.daemonRestartsAs("0.9.1");
expect(store.getSnapshot(host.serverId)?.client).toBe(fakeClient);
expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.9.1");
store.syncHosts([]);
});
it("drains snapshot and buffered running transitions exactly once", async () => {
const host = makeHost({
serverId: "srv_legacy_transitions",
@@ -2841,6 +2800,91 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("keeps the next queued message while a sent message replaces the running turn", async () => {
const host = makeHost({
serverId: "srv_queue_replacement",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const running = makeFetchAgentsEntry({
id: "agent",
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [{ ...running, agent: { ...running.agent, status: "running" } }],
}),
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_queue_replacement",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
version: null,
features: { canonicalSubmittedPrompts: true },
});
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
await store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} });
store.applyAgentTurnLiveness(host.serverId, "agent", {
type: "stream_open",
turn: { turnId: "turn-1", startedAt: null },
});
sessionStore.setQueuedMessages(
host.serverId,
new Map([["agent", [{ id: "second", text: "keep me queued", attachments: [] }]]]),
);
// "Send now" on the first queued message is in flight; the daemon replaces the turn.
const sentNow = createUserMessage({
clientMessageId: "sent-now",
text: "sent now",
timestamp: new Date(),
});
sessionStore.beginAgentMessageSubmission(host.serverId, "agent", sentNow);
store.applyAgentTurnLiveness(host.serverId, "agent", {
type: "stream_close",
turnId: "turn-1",
});
store.applyAgentTurnLiveness(host.serverId, "agent", {
type: "stream_open",
turn: { turnId: "turn-2", startedAt: null },
});
expect(fakeClient.sentAgentMessages).toEqual([]);
expect(useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent")).toEqual(
[{ id: "second", text: "keep me queued", attachments: [] }],
);
// Once the replacement turn ends, the queue drains as usual.
sessionStore.acceptAgentMessageSubmission(host.serverId, "agent", "sent-now");
sessionStore.setAgentStreamState(host.serverId, "agent", {
acknowledgedClientMessageIds: ["sent-now"],
});
store.applyAgentTurnLiveness(host.serverId, "agent", {
type: "stream_close",
turnId: "turn-2",
});
await fakeClient.waitForSentMessages(1);
expect(fakeClient.sentAgentMessages.map(([, text]) => text)).toEqual(["keep me queued"]);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("submits an automatically drained message through the submission producer", async () => {
const host = makeHost({ serverId: "srv_drain_submission" });
const fakeClient = new FakeDaemonClient();
+4 -8
View File
@@ -49,8 +49,8 @@ import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-creden
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
selectAgentTurnPresentation,
useSessionStore,
toDaemonServerInfo,
type Agent,
type WorkspaceDescriptor,
type ProjectDescriptor,
@@ -2267,13 +2267,6 @@ export class HostRuntimeStore {
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
// A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can
// change its version and features) only reaches the store here. The client clears it while
// disconnected; keep the last known value until the next handshake.
const serverInfo = snapshot.client.getLastServerInfoMessage();
if (serverInfo) {
sessionStore.updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
}
}
private clearHostReplica(serverId: string): void {
@@ -2331,6 +2324,9 @@ export class HostRuntimeStore {
if (!client || !queue?.length || session.initializingAgents.get(agentId) === true) {
return;
}
// A message sent while the agent runs can replace its turn. The replaced turn closes
// before the replacement opens, but the agent is still busy with the sent message.
if (selectAgentTurnPresentation(session, agentId).isActive) return;
this.queuedAgentDrainInFlight.add(drainKey);
const next = queue[0];
void sendQueuedComposerMessageNow({
@@ -68,7 +68,7 @@ import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
import { formatCompactTimeAgoAsProse } from "@/utils/time";
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
-13
View File
@@ -285,19 +285,6 @@ export interface DaemonServerInfo {
features?: ServerInfoStatusPayload["features"];
}
export function toDaemonServerInfo(serverInfo: ServerInfoStatusPayload): DaemonServerInfo {
return {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname ?? null,
version: serverInfo.version ?? null,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
};
}
export interface AgentTimelineCursorState {
epoch: string;
startSeq: number;
+1 -1
View File
@@ -12,7 +12,7 @@ import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { StatusBadge } from "@/components/ui/status-badge";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
import { UsageBalanceBar } from "./balance-bar";
import { usageCopy } from "./copy";
import { formatUsageFreshness, type UsageRefresh } from "./model";
+1 -17
View File
@@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest";
import {
describeCompactTimeAgo,
describeTimeAgo,
formatCompactTimeAgo,
formatCompactTimeAgoAsProse,
formatDuration,
@@ -14,8 +13,7 @@ describe("formatTimeAgo", () => {
it.each([
["2026-07-16T11:59:55.000Z", "just now"],
["2026-07-16T11:59:30.000Z", "just now"],
["2026-07-16T11:59:00.000Z", "1m ago"],
["2026-07-16T11:59:30.000Z", "30s ago"],
["2026-07-16T11:55:00.000Z", "5m ago"],
["2026-07-16T10:00:00.000Z", "2h ago"],
["2026-07-13T12:00:00.000Z", "3d ago"],
@@ -25,20 +23,6 @@ describe("formatTimeAgo", () => {
});
});
describe("describeTimeAgo", () => {
const now = new Date("2026-07-16T12:00:00.000Z");
it.each([
["2026-07-16T11:59:30.000Z", "just now", "minute"],
["2026-07-16T11:55:00.000Z", "5m ago", "minute"],
["2026-07-16T10:00:00.000Z", "2h ago", "hour"],
["2026-07-13T12:00:00.000Z", "3d ago", "day"],
["2026-01-15T12:00:00.000Z", "Jan 15", "static"],
] as const)("formats %s as %s at %s resolution", (date, label, resolution) => {
expect(describeTimeAgo(new Date(date), now)).toEqual({ label, resolution });
});
});
describe("describeCompactTimeAgo", () => {
const now = new Date("2026-07-16T12:00:00.000Z");
+53 -49
View File
@@ -1,10 +1,50 @@
/**
* How often a relative label can change, which is all a caller needs to know to keep it honest.
* How long ago something was, before it's worded. The two formatters below share this so
* their thresholds can't drift apart; they differ only in how much room they have to say it.
*
* `elapsed` takes an "ago" in prose, `now` and `date` read as absolutes and never do.
*/
type Elapsed =
| { kind: "now" }
| { kind: "elapsed"; value: string }
| { kind: "date"; value: string };
function describeElapsed(date: Date, now: Date): Elapsed {
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 10) return { kind: "now" };
if (diffMin < 1) return { kind: "elapsed", value: `${diffSec}s` };
if (diffHour < 1) return { kind: "elapsed", value: `${diffMin}m` };
if (diffDay < 1) return { kind: "elapsed", value: `${diffHour}h` };
if (diffDay < 7) return { kind: "elapsed", value: `${diffDay}d` };
// For older dates, show abbreviated month and day
const month = date.toLocaleDateString("en-US", { month: "short" });
return { kind: "date", value: `${month} ${date.getDate()}` };
}
/**
* Format a date as a human-friendly relative time string
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
*/
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
const elapsed = describeElapsed(date, now);
if (elapsed.kind === "now") return "just now";
if (elapsed.kind === "elapsed") return `${elapsed.value} ago`;
return elapsed.value;
}
/**
* How often a compact label can change, which is all a caller needs to know to keep it honest.
* `static` means it never will again.
*/
export type RelativeTimeResolution = "minute" | "hour" | "day" | "static";
export interface RelativeTimeLabel {
export interface CompactTimeAgo {
label: string;
resolution: RelativeTimeResolution;
}
@@ -16,71 +56,35 @@ const DAY_MS = 24 * HOUR_MS;
const ABSOLUTE_AFTER_MS = 7 * DAY_MS;
/**
* How long ago something was, before it's worded. The prose and compact formatters share this so
* their thresholds can't drift apart; they differ only in how much room they have to say it.
*
* `elapsed` takes an "ago" in prose, `now` and `date` read as absolutes and never do.
* The same instant with the prose removed, for somewhere with no room for it — a dense list
* where the column is understood to be a timestamp and "ago" is the only word on the line.
* Examples: "now", "5m", "2h", "3d", "Jan 15".
*
* Deliberately never sub-minute. A seconds label is only correct for the second it was rendered,
* so it either lies or forces a once-a-second re-render of a list that has nothing new to say.
* Everything under a minute is "now", which is both true and stable.
*
* The resolution comes back with the value so a caller can wake at the rate the label actually
* changes instead of guessing — see `useTimeAgo`.
* The resolution comes back with the label so a caller can wake at the rate the label actually
* changes instead of guessing — see `useCompactTimeAgo`.
*/
type Age =
| { kind: "now"; resolution: "minute" }
| { kind: "elapsed"; value: string; resolution: Exclude<RelativeTimeResolution, "static"> }
| { kind: "date"; value: string; resolution: "static" };
function describeAge(date: Date, now: Date): Age {
export function describeCompactTimeAgo(date: Date, now: Date = new Date()): CompactTimeAgo {
const elapsedMs = now.getTime() - date.getTime();
if (elapsedMs < MINUTE_MS) {
return { kind: "now", resolution: "minute" };
return { label: "now", resolution: "minute" };
}
if (elapsedMs < HOUR_MS) {
return {
kind: "elapsed",
value: `${Math.floor(elapsedMs / MINUTE_MS)}m`,
resolution: "minute",
};
return { label: `${Math.floor(elapsedMs / MINUTE_MS)}m`, resolution: "minute" };
}
if (elapsedMs < DAY_MS) {
return { kind: "elapsed", value: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" };
return { label: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" };
}
if (elapsedMs < ABSOLUTE_AFTER_MS) {
return { kind: "elapsed", value: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" };
return { label: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" };
}
const month = date.toLocaleDateString("en-US", { month: "short" });
return { kind: "date", value: `${month} ${date.getDate()}`, resolution: "static" };
}
/**
* A human-friendly relative time, with the resolution it changes at.
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15".
*/
export function describeTimeAgo(date: Date, now: Date = new Date()): RelativeTimeLabel {
const age = describeAge(date, now);
if (age.kind === "now") return { label: "just now", resolution: age.resolution };
if (age.kind === "elapsed") return { label: `${age.value} ago`, resolution: age.resolution };
return { label: age.value, resolution: age.resolution };
}
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
return describeTimeAgo(date, now).label;
}
/**
* The same instant with the prose removed, for somewhere with no room for it — a dense list
* where the column is understood to be a timestamp and "ago" is the only word on the line.
* Examples: "now", "5m", "2h", "3d", "Jan 15".
*/
export function describeCompactTimeAgo(date: Date, now: Date = new Date()): RelativeTimeLabel {
const age = describeAge(date, now);
if (age.kind === "now") return { label: "now", resolution: age.resolution };
return { label: age.value, resolution: age.resolution };
return { label: `${month} ${date.getDate()}`, resolution: "static" };
}
export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string {
@@ -4,7 +4,6 @@ import {
collectAllPanes,
DEFAULT_PANE_ID,
findPaneById,
findPaneContainingTab,
useWorkspaceLayoutStore,
type WorkspaceTabPlacement,
} from "@/stores/workspace-layout-store";
@@ -110,10 +109,6 @@ export function openWorkspaceTargetAtLocation(
if (shouldOpenBeside && !targetAlreadyExists) {
const paneId = store.ensureSidePane(input.workspaceKey);
placement = paneId ? { mode: "prefer", paneId } : undefined;
} else if (!shouldOpenBeside && layout && input.parentTabId) {
// A target opened from a tab belongs in that tab's pane, whichever pane has focus.
const parentPane = findPaneContainingTab(layout.root, input.parentTabId);
placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined;
}
return store.openTab({
workspaceKey: input.workspaceKey,
@@ -136,24 +136,6 @@ describe("resolveWorkspaceFilePaths", () => {
});
});
it("resolves files under a drive-root workspace without doubling the separator", () => {
expect(resolveWorkspaceFilePaths({ path: "C:/src/app.ts", workspaceRoot: "C:\\" })).toEqual({
absolutePath: "C:/src/app.ts",
relativePath: "src/app.ts",
});
expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "C:\\" })).toEqual({
absolutePath: "C:/src/app.ts",
relativePath: "src/app.ts",
});
});
it("resolves files under the POSIX root workspace", () => {
expect(resolveWorkspaceFilePaths({ path: "/src/app.ts", workspaceRoot: "/" })).toEqual({
absolutePath: "/src/app.ts",
relativePath: "src/app.ts",
});
});
it("returns null for home-relative paths that cannot be anchored", () => {
expect(
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
+2 -13
View File
@@ -129,17 +129,6 @@ function startsWithPath(value: string, prefix: string): boolean {
: value.startsWith(prefix);
}
function pathWithinRoot(root: string, relativePath: string): string {
return root.endsWith("/") ? `${root}${relativePath}` : `${root}/${relativePath}`;
}
/** Whether an absolute path names the workspace directory itself. */
export function isWorkspaceRootPath(input: { path: string; workspaceRoot: string }): boolean {
const path = normalizeAbsolutePath(input.path.trim().replace(/\\/g, "/"));
const root = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
return path !== null && root !== null && pathsEqual(path, root);
}
export interface ResolvedWorkspaceFilePaths {
/** Absolute path on the host, suitable for opening in an editor / file manager. */
absolutePath: string;
@@ -170,7 +159,7 @@ export function resolveWorkspaceFilePaths(input: {
if (pathsEqual(normalizedFile, workspaceRoot)) {
return null;
}
const prefix = pathWithinRoot(workspaceRoot, "");
const prefix = `${workspaceRoot}/`;
const relativePath = startsWithPath(normalizedFile, prefix)
? normalizedFile.slice(prefix.length)
: null;
@@ -185,5 +174,5 @@ export function resolveWorkspaceFilePaths(input: {
if (!relativePath) {
return null;
}
return { absolutePath: pathWithinRoot(workspaceRoot, relativePath), relativePath };
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
}
@@ -60,7 +60,6 @@ const mockState = vi.hoisted(() => {
isCommandAvailable: vi.fn(async (_command: string) => false),
runtimeModels: new Map<string, AgentModelDefinition[]>(),
cursorListFeaturesConfigs: [] as AgentSessionConfig[],
codexNativeArchiveCalls: [] as Array<{ state: "archive" | "restore"; handle: unknown }>,
reset() {
this.constructorArgs.claude = [];
this.constructorArgs.codex = [];
@@ -74,7 +73,6 @@ const mockState = vi.hoisted(() => {
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
this.runtimeModels.clear();
this.cursorListFeaturesConfigs = [];
this.codexNativeArchiveCalls = [];
},
};
});
@@ -173,14 +171,6 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({
};
}
async archiveNativeSession(handle: unknown): Promise<void> {
mockState.codexNativeArchiveCalls.push({ state: "archive", handle });
}
async unarchiveNativeSession(handle: unknown): Promise<void> {
mockState.codexNativeArchiveCalls.push({ state: "restore", handle });
}
async isAvailable(): Promise<boolean> {
const command: { mode?: string; argv?: string[] } | undefined =
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
@@ -661,22 +651,6 @@ test("new provider extending claude appears in registry", () => {
expect(registry.zai.createClient(logger).provider).toBe("zai");
});
test("new provider extending codex archives and unarchives its native sessions", async () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: { "my-codex": { extends: "codex", label: "My Codex" } },
});
const client = registry["my-codex"].createClient(logger);
const handle = { provider: "my-codex", sessionId: "thread-1", nativeHandle: "thread-1" };
await client.archiveNativeSession?.(handle);
await client.unarchiveNativeSession?.(handle);
expect(mockState.codexNativeArchiveCalls).toEqual([
{ state: "archive", handle: { ...handle, provider: "codex" } },
{ state: "restore", handle: { ...handle, provider: "codex" } },
]);
});
test("built-in OMP override keeps the real OMP adapter enabled and launchable", async () => {
const omp = new FakeOmp(["custom-omp"]);
const registry = buildProviderRegistry(logger, {
@@ -488,8 +488,6 @@ function wrapClientProvider(
const listImportableSessions = inner.listImportableSessions?.bind(inner);
const importSession = inner.importSession?.bind(inner);
const listFeatures = inner.listFeatures?.bind(inner);
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
return {
provider,
@@ -578,12 +576,6 @@ function wrapClientProvider(
};
}
: undefined,
archiveNativeSession: archiveNativeSession
? async (handle) => await archiveNativeSession({ ...handle, provider: inner.provider })
: undefined,
unarchiveNativeSession: unarchiveNativeSession
? async (handle) => await unarchiveNativeSession({ ...handle, provider: inner.provider })
: undefined,
getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner),
isAvailable: (signal, options) => inner.isAvailable(signal, options),
getDiagnostic: inner.getDiagnostic?.bind(inner),
@@ -1,4 +1,4 @@
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
import type { SessionMessageInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
@@ -535,85 +535,4 @@ describe("OpenCode v2 session lifecycle", () => {
await session.close();
}
});
test("switching to a model without the current variant sends the next prompt without it", async () => {
const harness = offeringModels({
"opencode/space-bunny-free": ["low", "medium", "high"],
"opencode/longcat-2.5-preview-free": [],
"opencode/muse-spark-free": ["minimal", "medium"],
});
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
model: "opencode/space-bunny-free",
thinkingOptionId: "medium",
});
const thinkingChanges: Array<string | null> = [];
session.subscribe((event) => {
if (event.type === "thinking_option_changed") thinkingChanges.push(event.thinkingOptionId);
});
try {
await session.setModel!("opencode/muse-spark-free");
expect(await session.getRuntimeInfo!()).toMatchObject({ thinkingOptionId: "medium" });
await session.setModel!("opencode/longcat-2.5-preview-free");
await session.run("hello");
expect(harness.prompts).toEqual(["hello"]);
expect(await session.getRuntimeInfo!()).toMatchObject({
model: "opencode/longcat-2.5-preview-free",
thinkingOptionId: null,
});
expect(thinkingChanges).toEqual([null]);
} finally {
await session.close();
}
});
});
// Behaves like an OpenCode v2 server: switching to any listed model succeeds,
// and a prompt fails when the session's variant is not one its model offers.
function offeringModels(variantsByModel: Record<string, string[]>): V2Harness {
const harness = new V2Harness();
const catalog: ModelInfo[] = Object.entries(variantsByModel).map(([ref, variants]) => {
const [providerID, id] = ref.split("/");
return {
id,
modelID: id,
providerID,
name: id,
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: variants.map((variant) => ({ id: variant })),
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 1000, output: 1000 },
};
});
harness.api.model.list = async (input) => ({
location: { directory: input?.location?.directory ?? harness.info.location.directory },
data: catalog,
});
harness.api.session.create = async (input = {}) => {
harness.creates.push(input);
harness.info.model = input.model;
return harness.info;
};
harness.api.session.switchModel = async ({ model }) => {
harness.info.model = model;
};
harness.prompt = async (input) => {
const model = harness.info.model;
const offered = catalog.find(
(entry) => entry.providerID === model?.providerID && entry.id === model?.id,
);
if (model?.variant && !offered?.variants.some((variant) => variant.id === model.variant))
throw new Error(`Variant unavailable for ${model.providerID}/${model.id}: ${model.variant}`);
harness.prompts.push(input.text);
};
return harness;
}
@@ -3,7 +3,7 @@ import { SessionTurns } from "./turns.js";
import { V2Timeline } from "./timeline.js";
import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js";
import type { ModelRef, SessionInfo, SessionMessageInfo } from "@opencode/client";
import type { SessionInfo, SessionMessageInfo } from "@opencode/client";
import { setTimeout as delay } from "node:timers/promises";
import type { Logger } from "pino";
@@ -277,35 +277,24 @@ export class OpenCodeV2Session implements AgentSession {
async setModel(model: string | null) {
await this.reconnectIfExited();
const selected = model
? modelRef(model)
? modelRef(model, this.config.thinkingOptionId)
: (await this.client.model.default({ location: { directory: this.config.cwd } })).data;
if (!selected) throw new Error("OpenCode has no default model");
// OpenCode rejects a prompt whose variant the model does not offer, so the
// current variant only carries over to a model that has it.
const variant = await this.offeredVariant(selected, this.config.thinkingOptionId);
await this.client.session.switchModel({
sessionID: this.id,
model: { id: selected.id, providerID: selected.providerID, variant },
model: {
id: selected.id,
providerID: selected.providerID,
variant: this.config.thinkingOptionId,
},
});
this.config.model = model ?? undefined;
if (variant !== this.config.thinkingOptionId) {
this.config.thinkingOptionId = variant;
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: null });
}
this.emit({
type: "model_changed",
provider: "opencode",
runtimeInfo: await this.getRuntimeInfo(),
});
}
private async offeredVariant(model: ModelRef, variant: string | undefined) {
if (!variant) return undefined;
const { data } = await this.client.model.list({ location: { directory: this.config.cwd } });
const offered = data.find(
(entry) => entry.providerID === model.providerID && entry.id === model.id,
);
return offered?.variants.some((entry) => entry.id === variant) ? variant : undefined;
}
async setThinkingOption(variant: string | null) {
await this.reconnectIfExited();
const model = this.info.model;
@@ -1,135 +0,0 @@
import { once } from "node:events";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
// Runs the real `codex` binary against a local Responses endpoint standing in
// for a custom provider's OPENAI_BASE_URL, so no login is needed.
function sse(events: Array<Record<string, unknown>>): string {
return events
.map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`)
.join("");
}
function finalAnswer(): string {
return sse([
{ type: "response.created", response: { id: "resp-1" } },
{
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id: "msg-1",
content: [{ type: "output_text", text: "done" }],
},
},
{
type: "response.completed",
response: {
id: "resp-1",
usage: {
input_tokens: 0,
input_tokens_details: null,
output_tokens: 0,
output_tokens_details: null,
total_tokens: 0,
},
},
},
]);
}
async function startResponsesEndpoint(): Promise<{
baseUrl: string;
server: Server;
requestCount: () => number;
}> {
let requests = 0;
const server = createServer((req, res) => {
req.resume();
req.on("end", () => {
if (req.method !== "POST" || req.url !== "/v1/responses") {
res.statusCode = 404;
res.end();
return;
}
requests += 1;
res.setHeader("content-type", "text/event-stream");
res.end(finalAnswer());
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("no server address");
return {
baseUrl: `http://127.0.0.1:${address.port}/v1`,
server,
requestCount: () => requests,
};
}
async function closeServer(server: Server): Promise<void> {
server.close();
await once(server, "close");
}
describe("Codex custom provider archive", () => {
const cleanups: Array<() => void | Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
});
test("archiving hides the session from Import session and unarchiving lists it again", async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "codex-custom-archive-"));
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
const codexHome = path.join(root, "codex-home");
mkdirSync(codexHome);
const cwd = path.join(root, "project");
mkdirSync(cwd);
const { baseUrl, server } = await startResponsesEndpoint();
cleanups.push(() => closeServer(server));
const daemon = await createTestPaseoDaemon({
logger: createTestLogger(),
pluginsEnabled: false,
providerOverrides: {
"my-codex": {
extends: "codex",
label: "My Codex",
env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" },
},
},
});
cleanups.push(() => daemon.close());
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanups.push(() => client.close());
await client.connect();
await client.fetchAgents({ subscribe: {} });
const manager = daemon.daemon.agentManager;
const agent = await client.createAgent({
config: { provider: "my-codex", cwd, model: "mock-model", modeId: "full-access" },
});
await manager.runAgent(agent.id, "hello from the custom provider");
const importable = async () =>
(await manager.listImportableSessions({ cwd, providerFilter: new Set(["my-codex"]) }))
.sessions;
expect(await importable()).toHaveLength(1);
await client.archiveAgent(agent.id);
expect(await importable()).toEqual([]);
await client.refreshAgent(agent.id);
expect(await importable()).toHaveLength(1);
}, 120_000);
});
@@ -1,61 +0,0 @@
import { existsSync } from "node:fs";
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestPaseoDaemon } from "./paseo-daemon.js";
describe("createTestPaseoDaemon", () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
});
test("keeps a caller-owned home when startup on a taken port fails", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-owned-home-"));
cleanups.push(() => rm(paseoHomeRoot, { recursive: true, force: true }));
const ownedFile = path.join(paseoHomeRoot, "owned-by-caller");
await writeFile(ownedFile, "keep");
const takenPort = await occupyPort(cleanups);
await expect(
createTestPaseoDaemon({
listenPort: takenPort,
paseoHomeRoot,
staticDir: path.join(paseoHomeRoot, "static"),
cleanup: false,
mcpEnabled: false,
}),
).rejects.toMatchObject({ code: "EADDRINUSE" });
expect(existsSync(ownedFile)).toBe(true);
}, 60_000);
test("removes the directories it created when startup on a taken port fails", async () => {
const scratchTmp = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-scratch-"));
cleanups.push(() => rm(scratchTmp, { recursive: true, force: true }));
for (const name of ["TMPDIR", "TMP", "TEMP"]) vi.stubEnv(name, scratchTmp);
const takenPort = await occupyPort(cleanups);
await expect(
createTestPaseoDaemon({ listenPort: takenPort, cleanup: false, mcpEnabled: false }),
).rejects.toMatchObject({ code: "EADDRINUSE" });
expect(await readdir(scratchTmp)).toEqual([]);
}, 60_000);
});
async function occupyPort(cleanups: Array<() => Promise<void>>): Promise<number> {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
cleanups.push(() => new Promise<void>((resolve) => server.close(() => resolve())));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Port holder did not bind a TCP port");
}
return address.port;
}
@@ -21,7 +21,6 @@ interface TestPaseoDaemonOptions {
downloadTokenTtlMs?: number;
corsAllowedOrigins?: string[];
listen?: string;
listenPort?: number;
logger?: Parameters<typeof createPaseoDaemon>[1];
mcpEnabled?: boolean;
mcpDebug?: boolean;
@@ -100,8 +99,7 @@ export async function createTestPaseoDaemon(
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const { config, paseoHomeRoot, paseoHome, staticDir, createdDirs } =
await prepareTestDaemonConfig(options);
const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options);
const logger = options.logger ?? pino({ level: "silent" });
const daemon = await createPaseoDaemon(config, logger, {
builtinPlugins: options.builtinPlugins ?? new BuiltinPluginLoader(undefined, []),
@@ -122,7 +120,10 @@ export async function createTestPaseoDaemon(
await daemon.agentManager.flush().catch(() => undefined);
if (options.cleanup ?? true) {
await new Promise((r) => setTimeout(r, 50));
await removeDirs([paseoHomeRoot, staticDir]);
await Promise.all([
rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
]);
}
};
@@ -137,8 +138,10 @@ export async function createTestPaseoDaemon(
} catch (error) {
lastError = error;
await daemon.stop().catch(() => undefined);
// A failed attempt removes only what it created: a caller-supplied home keeps its serverId.
await removeDirs(createdDirs);
await Promise.all([
rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
]);
if (
(!isAddressInUseError(error) && !isStartupTimeoutError(error)) ||
@@ -157,33 +160,19 @@ interface PreparedTestDaemonConfig {
paseoHomeRoot: string;
paseoHome: string;
staticDir: string;
createdDirs: string[];
}
async function removeDirs(dirs: string[]): Promise<void> {
await Promise.all(
dirs.map((dir) => rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })),
);
}
async function createTempDir(prefix: string, createdDirs: string[]): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), prefix));
createdDirs.push(dir);
return dir;
}
async function prepareTestDaemonConfig(
options: TestPaseoDaemonOptions,
): Promise<PreparedTestDaemonConfig> {
const createdDirs: string[] = [];
const paseoHomeRoot = options.paseoHomeRoot ?? (await createTempDir("paseo-home-", createdDirs));
const paseoHomeRoot =
options.paseoHomeRoot ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-home-")));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
await mkdir(paseoHome, { recursive: true });
const staticDir = options.staticDir ?? (await createTempDir("paseo-static-", createdDirs));
const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-")));
const listenHost = options.listen ?? "127.0.0.1";
const listenPort = options.listenPort ?? 0;
const config: PaseoDaemonConfig = {
listen: `${listenHost}:${listenPort}`,
listen: `${listenHost}:0`,
paseoHome,
daemonVersion: options.daemonVersion,
desktopManaged: options.desktopManaged,
@@ -218,7 +207,7 @@ async function prepareTestDaemonConfig(
pluginsEnabled: options.pluginsEnabled,
plugins: options.plugins,
};
return { config, paseoHomeRoot, paseoHome, staticDir, createdDirs };
return { config, paseoHomeRoot, paseoHome, staticDir };
}
function isAddressInUseError(error: unknown): boolean {
@@ -1,28 +1,8 @@
// Browser compatibility tests run a real daemon in a separate Node runtime.
//
// argv: <daemonVersion> [port] [paseoHomeRoot]
// A fixed port and a caller-owned home let a test stop this daemon and start a
// replacement under the same endpoint and serverId.
import path from "node:path";
import { createTestPaseoDaemon } from "./paseo-daemon.js";
const [, , daemonVersion, portArg, paseoHomeRoot] = process.argv;
const listenPort = portArg ? Number(portArg) : 0;
if (!Number.isInteger(listenPort) || listenPort < 0) {
throw new Error(`Invalid versioned daemon port: ${portArg ?? ""}`);
}
const daemon = await createTestPaseoDaemon({
daemonVersion,
...(listenPort > 0 ? { listenPort } : {}),
...(paseoHomeRoot
? {
paseoHomeRoot,
staticDir: path.join(paseoHomeRoot, "static"),
// The caller removes the home so a replacement daemon keeps the same serverId.
cleanup: false,
}
: {}),
daemonVersion: process.argv[2],
pluginsEnabled: true,
mcpEnabled: false,
corsAllowedOrigins: ["*"],
+2 -2
View File
@@ -26,7 +26,7 @@
"interval": "monthly",
"intervalCount": 1,
"unitAmount": 0,
"currency": "usd",
"currency": "eur",
"tooltip": null
}
]
@@ -57,7 +57,7 @@
"interval": "monthly",
"intervalCount": 1,
"unitAmount": 1500,
"currency": "usd",
"currency": "eur",
"tooltip": "Seats are Hub members and pending invitations. People who only trigger agents through GitHub, Slack, or Discord do not count as seats."
}
]
+2 -13
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
// The documented `GET /api/billing/plans` response, copied from getpaseo/hub docs/public-api.md.
import documentedResponse from "./hub-plans.fixture.json" with { type: "json" };
import { formatPlanPrice, parseHubPlansResponse, planPriceQualifier } from "./hub-plans";
import { parseHubPlansResponse } from "./hub-plans";
describe("parseHubPlansResponse", () => {
it("reads Free and Pro, their monthly prices, and the figures each includes", () => {
@@ -18,7 +18,7 @@ describe("parseHubPlansResponse", () => {
expect(plans.paid.name).toBe("Pro");
expect(plans.paid.included).toEqual({ seats: null, executionsPerMonth: null });
expect(plans.paid.price.unitAmount).toBe(1500);
expect(plans.paid.price.currency).toBe("usd");
expect(plans.paid.price.currency).toBe("eur");
expect(plans.paid.billing.unit.label).toBe("seat");
});
@@ -52,14 +52,3 @@ describe("parseHubPlansResponse", () => {
);
});
});
describe("plan price copy", () => {
it("shows Free as $0 a month and Pro as $15 per seat a month", () => {
const plans = parseHubPlansResponse(documentedResponse);
expect(formatPlanPrice(plans.free.price)).toBe("$0");
expect(planPriceQualifier(plans.free)).toBe("/ month");
expect(formatPlanPrice(plans.paid.price)).toBe("$15");
expect(planPriceQualifier(plans.paid)).toBe("per seat / month");
});
});
-17
View File
@@ -55,23 +55,6 @@ export const getHubPlans = createServerFn({ method: "GET" }).handler(async () =>
return parseHubPlansResponse(await response.json());
});
/** The plan's monthly figure in its own currency, e.g. "$15". */
export function formatPlanPrice(price: HubPlanOffer["price"]): string {
return new Intl.NumberFormat("en", {
style: "currency",
currency: price.currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(price.unitAmount / 100);
}
/** What the figure is for: "per seat / month" when each seat is charged, "/ month" when the
* plan costs nothing, since a free plan has no per-seat charge to describe. */
export function planPriceQualifier(plan: HubPlanOffer): string {
const period = plan.price.intervalCount === 1 ? "month" : `${plan.price.intervalCount} months`;
if (plan.price.unitAmount === 0) return `/ ${period}`;
return `per ${plan.billing.unit.label} / ${period}`;
}
function selectOffer(plans: readonly HubBillingPlan[], slug: string): HubPlanOffer {
const plan = plans.find((candidate) => candidate.slug === slug);
if (plan === undefined) throw new Error(`Hub plan "${slug}" is unavailable`);
+42 -19
View File
@@ -12,13 +12,7 @@ import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
import { AGENT_PAGES } from "~/data/agent-pages";
import { FAQItem } from "~/components/faq-item";
import { SiteShell } from "~/components/site-shell";
import {
formatPlanPrice,
getHubPlans,
planPriceQualifier,
type HubPlanOffer,
type HubPlans,
} from "~/hub-plans";
import { getHubPlans, type HubPlanOffer, type HubPlans } from "~/hub-plans";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/hub")({
@@ -110,9 +104,15 @@ function Pricing({ plans }: { plans: HubPlans | null }) {
return (
<section className="space-y-6" aria-labelledby="pricing-heading">
<h2 id="pricing-heading" className="text-xl font-medium">
Choose how to run Hub
</h2>
<div className="space-y-2">
<h2 id="pricing-heading" className="text-xl font-medium">
Choose how to run Hub
</h2>
<p className="max-w-2xl leading-relaxed text-white/70">
Every option runs agents on the daemons you already have. {plans.free.name} needs no card,
and {plans.paid.name} is one click away under Billing once you are signed in.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<PlanCard
name="Self-hosted"
@@ -125,21 +125,23 @@ function Pricing({ plans }: { plans: HubPlans | null }) {
/>
<PlanCard
name={plans.free.name}
price={formatPlanPrice(plans.free.price)}
priceQualifier={planPriceQualifier(plans.free)}
price={formatPrice(plans.free.price)}
priceQualifier={planQualifier(plans.free)}
priceTooltip={plans.free.price.tooltip}
features={planFeatures(plans.free)}
actionHref={HOSTED_HUB_URL}
actionLabel="Get started"
actionLabel="Create a free account"
actionNote="No card, never expires."
/>
<PlanCard
name={plans.paid.name}
price={formatPlanPrice(plans.paid.price)}
priceQualifier={planPriceQualifier(plans.paid)}
price={formatPrice(plans.paid.price)}
priceQualifier={planQualifier(plans.paid)}
priceTooltip={plans.paid.price.tooltip}
features={planFeatures(plans.paid)}
actionHref={HOSTED_HUB_URL}
actionLabel="Get started"
actionLabel={`Go to Hub for ${plans.paid.name}`}
actionNote={`Upgrade to ${plans.paid.name} from Billing, whether you work alone or with a team.`}
featured
/>
</div>
@@ -182,6 +184,7 @@ function PlanCard({
features,
actionHref,
actionLabel,
actionNote,
featured = false,
}: {
name: string;
@@ -191,6 +194,7 @@ function PlanCard({
features: readonly PlanFeature[];
actionHref: string;
actionLabel: string;
actionNote?: string;
featured?: boolean;
}) {
return (
@@ -216,9 +220,12 @@ function PlanCard({
))}
</ul>
)}
{actionNote !== undefined && (
<p className="mt-8 text-center text-xs leading-relaxed text-white/35">{actionNote}</p>
)}
<a
href={actionHref}
className={`rounded-md px-4 py-2 text-center text-sm font-medium transition-colors mt-8 ${featured ? "bg-white text-black hover:bg-white/90" : "border border-white/15 text-white/80 hover:border-white/25 hover:text-white"}`}
className={`rounded-md px-4 py-2 text-center text-sm font-medium transition-colors ${actionNote === undefined ? "mt-8" : "mt-3"} ${featured ? "bg-white text-black hover:bg-white/90" : "border border-white/15 text-white/80 hover:border-white/25 hover:text-white"}`}
>
{actionLabel}
</a>
@@ -240,6 +247,22 @@ function InfoTip({ text }: { text: string }) {
);
}
function planQualifier(plan: HubPlanOffer): string {
return `per ${plan.billing.unit.label} / ${formatBillingPeriod(plan.price)}`;
}
function formatPrice(price: HubPlanOffer["price"]): string {
return new Intl.NumberFormat("en", {
style: "currency",
currency: price.currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(price.unitAmount / 100);
}
function formatBillingPeriod(price: HubPlanOffer["price"]): string {
return price.intervalCount === 1 ? "month" : `${price.intervalCount} months`;
}
const TRIGGER_SURFACES = [
{ name: "GitHub", icon: <GitHubIcon className="h-6 w-6" />, body: "Issues, PRs, and reviews." },
{ name: "Slack", icon: <SlackIcon className="h-6 w-6" />, body: "Channels and threads." },
@@ -611,8 +634,8 @@ function FaqSection() {
<a href={HOSTED_HUB_URL} className={LINK_CLASS}>
sign in to Hosted Hub
</a>{" "}
for a free account. When you want more agent runs or more seats, upgrade from Billing
inside Hub.
for a free account. It needs no card and does not expire. When you want more agent runs or
more seats, upgrade from Billing inside Hub.
</FAQItem>
</div>
</section>