Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andthecardcheat 48e5a69845 Connect to the open Advanced URI in Direct connection
With Advanced open, Connect built the connection from the Host and Port
fields and ignored a tcp:// URI in the Advanced field. A blank Host
failed with "Host is required", and an edited URI connected to the
address the fields still held. Connect now uses the URI shown while
Advanced is open; the Password field still supplies a password the URI
does not carry.

Closes #5495

Co-authored-by: thecardcheat <pitt.joshua.miller@gmail.com>
2026-09-28 18:57:08 +02:00
26 changed files with 210 additions and 688 deletions
@@ -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",
},
});
}
@@ -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");
});
});
@@ -27,6 +27,8 @@ import {
openCompactSettingsHost,
expectAddHostMethodOptions,
fillDirectHostUri,
fillDirectHostFields,
connectDirectHost,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostUriValue,
@@ -112,6 +114,36 @@ test.describe("Settings sidebar navigation", () => {
await expectDirectHostUriHidden(page);
});
test("direct connection connects to the open Advanced URI when Host is blank", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await toggleHostAdvanced(page);
await fillDirectHostUri(page, `tcp://127.0.0.1:${getE2EDaemonPort()}`);
await connectDirectHost(page);
});
test("direct connection connects to an edited Advanced URI instead of the fields", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await fillDirectHostFields(page, { host: "127.0.0.1", port: "9" });
await toggleHostAdvanced(page);
await expectDirectHostUriValue(page, "tcp://127.0.0.1:9");
await fillDirectHostUri(page, `tcp://127.0.0.1:${getE2EDaemonPort()}`);
await connectDirectHost(page);
});
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
page,
}) => {
@@ -253,6 +253,19 @@ export async function fillDirectHostUri(page: Page, uri: string): Promise<void>
await page.getByTestId("direct-host-uri-input").fill(uri);
}
export async function fillDirectHostFields(
page: Page,
fields: { host: string; port: string },
): Promise<void> {
await page.getByTestId("direct-host-input").fill(fields.host);
await page.getByTestId("direct-port-input").fill(fields.port);
}
export async function connectDirectHost(page: Page): Promise<void> {
await page.getByTestId("direct-host-submit").click();
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
}
export async function expectDirectHostFormValues(
page: Page,
fields: { host: string; port: string; password: string },
+13 -7
View File
@@ -195,6 +195,12 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
};
}
// The Password field stays visible under Advanced, so it fills in a URI that carries none.
function draftFromAdvancedUri(uri: string, fieldPassword: string): DirectConnectionDraft {
const draft = draftFromConnectionUri(uri);
return draft.password ? draft : { ...draft, password: fieldPassword };
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
@@ -405,18 +411,18 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleSave = useCallback(async () => {
if (isSaving) return;
const relayUri = isAdvancedOpen ? advancedUri.trim() : "";
if (relayUri.startsWith("relay://") || relayUri.includes("#connect=")) {
await handleSaveRelay(relayUri);
const advancedInput = isAdvancedOpen ? advancedUri.trim() : "";
if (advancedInput.startsWith("relay://") || advancedInput.includes("#connect=")) {
await handleSaveRelay(advancedInput);
return;
}
let connection: PreparedDirectConnection;
try {
connection = prepareDirectConnection(
{ host, port, useTls, password },
directConnectionLabels,
);
const draft = advancedInput
? draftFromAdvancedUri(advancedInput, password)
: { host, port, useTls, password };
connection = prepareDirectConnection(draft, directConnectionLabels);
} catch (error) {
const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
+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 };
@@ -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;
}
@@ -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";
+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 {
@@ -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);
});
+2 -2
View File
@@ -611,8 +611,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>