Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 78cc05516a Rerun CI after the website dev server timed out at startup 2026-09-28 21:56:14 +02:00
paseo-bot[bot] 4e21643e3a Keep OpenCode chats working after switching to a model without the current variant
Switching an OpenCode v2 chat to another model carried the previous
model's variant over. OpenCode rejects a prompt whose variant the model
does not offer, so every message after the switch failed with
"Variant unavailable for <model>: medium". The variant now carries over
only when the new model offers it; otherwise it is cleared and the
cleared thinking option is reported to the agent.

Closes #5578
2026-09-28 21:36:21 +02:00
paseo-bot[bot] 9d010211a2 Archive a custom Codex provider's session when its agent is archived (#5572)
A provider that extends Codex runs through the registry's client wrapper,
which forwarded session listing and import but not native archive or
unarchive. Archiving such an agent left its Codex thread unarchived, so
the session stayed in Import session and could be imported again. The
wrapper now forwards both, the same as stock Codex.

Closes #5454

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 21:14:58 +02:00
Mohamed Boudra 8b5201fedf Remove the no-card line from the Hub access FAQ (#5575) 2026-09-28 18:59:24 +01:00
paseo-bot[bot]andMohamed Boudra 05874e289a Keep relative timestamps current in agent, schedule, and import-session rows (#5340)
* Keep relative timestamps current in agent, schedule, and import-session rows

Agent list rows, schedule rows, and import-session rows formatted their
"5m ago" labels once at render, so an idle row kept its first label until
something else re-rendered it. They now use useTimeAgo, which rides the
same shared ticker the sidebar and desktop tabs already use.

Closes #4625

* Say "just now" for the whole first minute so fresh labels stay honest

The shared ticker wakes once a minute, so a seconds label could only go
stale. The prose and compact formatters now share one age description,
and the schedule spec anchors its clock to the schedule's creation time.

* Run every cleanup in the schedule relative-time spec and name its steps

* Keep relative labels current with focused subscriptions

* Update usage card after relative-time hook rename

* Anchor import-session age spec to fixture time

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 18:00:54 +01:00
26 changed files with 688 additions and 210 deletions
@@ -0,0 +1,51 @@
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,7 +87,25 @@ test("commit history shows dates and shares diff layout preferences", async ({
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
});
async function createFeatureCommit(repoPath: string): Promise<void> {
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> {
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" });
@@ -96,8 +114,8 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
GIT_AUTHOR_DATE: authorDate,
GIT_COMMITTER_DATE: authorDate,
},
});
}
@@ -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 { test, type Page } from "../support/fixtures";
import { expect, test, type Page } from "../support/fixtures";
import { ImportSessionFlow } from "../support/helpers/import-session";
import {
connectNewWorkspaceDaemonClient,
@@ -49,6 +49,7 @@ interface ImportFlowScenario {
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
importSessionTimestamp: number;
repoCleanup(): Promise<void>;
unrelatedCleanup(): Promise<void>;
}
@@ -94,7 +95,7 @@ test.beforeAll(async () => {
};
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
const importSessionId = "fixture-custom-title";
await seedClaudeSessions({
const importSessionTimestamp = await seedClaudeSessions({
projectRoot: repo.path,
worktreeDirectory,
unrelatedDirectory: unrelated.path,
@@ -108,6 +109,7 @@ test.beforeAll(async () => {
worktreeDirectory,
unrelatedDirectory: unrelated.path,
importSessionId,
importSessionTimestamp,
repoCleanup: repo.cleanup,
unrelatedCleanup: unrelated.cleanup,
};
@@ -122,6 +124,19 @@ 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 });
@@ -225,7 +240,7 @@ async function seedClaudeSessions(input: {
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
}): Promise<void> {
}): Promise<number> {
const sessions = [
{
cwd: input.projectRoot,
@@ -290,4 +305,5 @@ async function seedClaudeSessions(input: {
const timestamp = new Date(newest - index * 60_000);
await utimes(sessionPath, timestamp, timestamp);
}
return newest;
}
@@ -119,6 +119,25 @@ 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,
}) => {
@@ -0,0 +1,118 @@
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,8 +27,6 @@ import {
openCompactSettingsHost,
expectAddHostMethodOptions,
fillDirectHostUri,
fillDirectHostFields,
connectDirectHost,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostUriValue,
@@ -114,36 +112,6 @@ 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,19 +253,6 @@ 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 },
+7 -13
View File
@@ -195,12 +195,6 @@ 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();
@@ -411,18 +405,18 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleSave = useCallback(async () => {
if (isSaving) return;
const advancedInput = isAdvancedOpen ? advancedUri.trim() : "";
if (advancedInput.startsWith("relay://") || advancedInput.includes("#connect=")) {
await handleSaveRelay(advancedInput);
const relayUri = isAdvancedOpen ? advancedUri.trim() : "";
if (relayUri.startsWith("relay://") || relayUri.includes("#connect=")) {
await handleSaveRelay(relayUri);
return;
}
let connection: PreparedDirectConnection;
try {
const draft = advancedInput
? draftFromAdvancedUri(advancedInput, password)
: { host, port, useTls, password };
connection = prepareDirectConnection(draft, directConnectionLabels);
connection = prepareDirectConnection(
{ host, port, useTls, password },
directConnectionLabels,
);
} catch (error) {
const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
+12 -6
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 { formatTimeAgo } from "@/utils/time";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive, ChevronRight } from "lucide-react-native";
@@ -181,7 +181,6 @@ 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 ?? "";
@@ -283,7 +282,7 @@ function SessionRow({
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
<AgentActivityTime date={agent.lastActivityAt} isMobile />
{showHostColumn && agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
@@ -316,9 +315,7 @@ function SessionRow({
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
/>
<Text style={styles.columnMetaFixed} numberOfLines={1}>
{timeAgo}
</Text>
<AgentActivityTime date={agent.lastActivityAt} isMobile={false} />
</View>
) : null}
<SessionRowTrailingAttention
@@ -330,6 +327,15 @@ 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 { formatTimeAgo } from "@/utils/time";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostProjects } from "@/projects/host-projects";
@@ -309,7 +309,6 @@ 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),
@@ -344,9 +343,11 @@ function ImportSessionSheetRow({
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.rowMeta}>
{importing ? t("importSession.row.importing") : lastActivity}
</Text>
{importing ? (
<Text style={styles.rowMeta}>{t("importSession.row.importing")}</Text>
) : (
<ImportSessionActivityTime date={entry.lastActivityAt} />
)}
</View>
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
@@ -365,6 +366,11 @@ 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 {
fetchedAtLabel: string | null;
fetchedAt: string | null;
isCompact: boolean;
modelsRefreshing: boolean;
t: TFunction;
@@ -419,7 +419,7 @@ interface ProviderSheetFooterInput {
}
function renderProviderSheetFooter({
fetchedAtLabel,
fetchedAt,
isCompact,
modelsRefreshing,
t,
@@ -430,16 +430,10 @@ 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}>
{fetchedAtLabel || !isCompact ? (
<Text style={metaStyle} numberOfLines={1}>
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
</Text>
{fetchedAt || !isCompact ? (
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
) : null}
<View style={actionsStyle}>
<Button
@@ -477,6 +471,25 @@ 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 {
@@ -609,18 +622,6 @@ 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("");
@@ -686,7 +687,7 @@ export function ProviderDiagnosticSheet({
onClose={onClose}
testID="provider-settings-sheet"
footer={renderProviderSheetFooter({
fetchedAtLabel,
fetchedAt: providerEntry?.fetchedAt ?? null,
isCompact,
modelsRefreshing,
t,
@@ -22,7 +22,7 @@ import {
resolveScheduleTitle,
scheduleProductName,
} from "@/utils/schedule-format";
import { formatTimeAgo } from "@/utils/time";
import { useTimeAgo } from "@/hooks/use-time-ago";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
@@ -96,16 +96,19 @@ 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(
schedule: ScheduleSummary,
state: ScheduleDerivedState,
serverName: string | undefined,
singleHost: boolean,
): string {
function buildMeta(input: {
schedule: ScheduleSummary;
state: ScheduleDerivedState;
createdAgo: string;
lastRunAgo: string;
serverName: string | undefined;
singleHost: boolean;
}): string {
const { schedule, state, serverName, singleHost } = input;
const parts = [
formatCadence(schedule.cadence),
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
`Created ${input.createdAgo}`,
schedule.lastRunAt ? `Last run ${input.lastRunAgo}` : "Never run",
];
if (state === "active") {
const next = formatNextRun(schedule.nextRunAt);
@@ -119,6 +122,27 @@ function buildMeta(
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({
@@ -168,7 +192,6 @@ 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(
@@ -206,9 +229,12 @@ export function ScheduleRow({
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
<ScheduleMeta
schedule={schedule}
state={state}
serverName={serverName}
singleHost={singleHost ?? false}
/>
</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-compact-time-ago";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
export type { SidebarWorkspaceTrailing };
@@ -4,14 +4,13 @@ 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 { formatTimeAgo } from "@/utils/time";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { CommitGraphNode } from "./commit-graph-node";
interface CommitRowProps {
commit: ClassifiedCheckoutCommit;
isFirst: boolean;
isLast: boolean;
now: Date;
onCommitPress: (sha: string) => void;
}
@@ -26,7 +25,6 @@ export const CommitRow = memo(function CommitRow({
commit,
isFirst,
isLast,
now,
onCommitPress,
}: CommitRowProps) {
const handlePress = useCallback(() => {
@@ -49,7 +47,7 @@ export const CommitRow = memo(function CommitRow({
{commit.subject}
</Text>
</View>
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
<CommitTimestamp authorDate={commit.authorDate} />
<View style={styles.caret}>
<ThemedChevron size={14} uniProps={chevronColorMapping} />
</View>
@@ -57,6 +55,11 @@ 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,9 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo } 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";
@@ -40,11 +39,9 @@ function CommitsSectionSkeleton() {
function CommitsSectionContent({
query,
now,
onCommitPress,
}: {
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
now: Date;
onCommitPress: (sha: string) => void;
}) {
const { t } = useTranslation();
@@ -77,7 +74,6 @@ function CommitsSectionContent({
commit={commit}
isFirst={index === 0}
isLast={index === workspaceCommits.length - 1}
now={now}
onCommitPress={onCommitPress}
/>
))}
@@ -94,9 +90,6 @@ 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,
@@ -104,20 +97,9 @@ 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],
@@ -160,9 +142,7 @@ export function CommitsSection({
</Text>
)}
</Pressable>
{collapsed ? null : (
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
)}
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
</View>
);
}
@@ -1,6 +1,11 @@
import { useEffect, useState } from "react";
import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker";
import { describeCompactTimeAgo } from "@/utils/time";
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);
}
/**
* A compact relative timestamp that keeps itself current.
@@ -19,7 +24,14 @@ import { describeCompactTimeAgo } from "@/utils/time";
* never changes again.
*/
export function useCompactTimeAgo(date: Date | null): string {
const [label, setLabel] = useState(() => (date ? describeCompactTimeAgo(date).label : ""));
return useRelativeTimeLabel(date, describeCompactTimeAgo);
}
function useRelativeTimeLabel(
date: Date | null,
describe: (date: Date) => RelativeTimeLabel,
): string {
const [label, setLabel] = useState(() => (date ? describe(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.
@@ -32,13 +44,13 @@ export function useCompactTimeAgo(date: Date | null): string {
}
const source = new Date(time);
let current = describeCompactTimeAgo(source);
let current = describe(source);
setLabel(current.label);
let unsubscribe: (() => void) | null = null;
const handleTick = () => {
const next = describeCompactTimeAgo(source);
const next = describe(source);
if (next.label !== current.label) {
setLabel(next.label);
}
@@ -62,7 +74,7 @@ export function useCompactTimeAgo(date: Date | null): string {
return () => {
unsubscribe?.();
};
}, [time]);
}, [time, describe]);
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-compact-time-ago";
import { useCompactTimeAgo } from "@/hooks/use-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-compact-time-ago";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import { UsageBalanceBar } from "./balance-bar";
import { usageCopy } from "./copy";
import { formatUsageFreshness, type UsageRefresh } from "./model";
+17 -1
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
describeCompactTimeAgo,
describeTimeAgo,
formatCompactTimeAgo,
formatCompactTimeAgoAsProse,
formatDuration,
@@ -13,7 +14,8 @@ describe("formatTimeAgo", () => {
it.each([
["2026-07-16T11:59:55.000Z", "just now"],
["2026-07-16T11:59:30.000Z", "30s ago"],
["2026-07-16T11:59:30.000Z", "just now"],
["2026-07-16T11:59:00.000Z", "1m 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"],
@@ -23,6 +25,20 @@ 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");
+49 -53
View File
@@ -1,50 +1,10 @@
/**
* 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.
* How often a relative 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 CompactTimeAgo {
export interface RelativeTimeLabel {
label: string;
resolution: RelativeTimeResolution;
}
@@ -56,35 +16,71 @@ const DAY_MS = 24 * HOUR_MS;
const ABSOLUTE_AFTER_MS = 7 * DAY_MS;
/**
* 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".
* 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.
*
* 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 label so a caller can wake at the rate the label actually
* changes instead of guessing — see `useCompactTimeAgo`.
* The resolution comes back with the value so a caller can wake at the rate the label actually
* changes instead of guessing — see `useTimeAgo`.
*/
export function describeCompactTimeAgo(date: Date, now: Date = new Date()): CompactTimeAgo {
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 {
const elapsedMs = now.getTime() - date.getTime();
if (elapsedMs < MINUTE_MS) {
return { label: "now", resolution: "minute" };
return { kind: "now", resolution: "minute" };
}
if (elapsedMs < HOUR_MS) {
return { label: `${Math.floor(elapsedMs / MINUTE_MS)}m`, resolution: "minute" };
return {
kind: "elapsed",
value: `${Math.floor(elapsedMs / MINUTE_MS)}m`,
resolution: "minute",
};
}
if (elapsedMs < DAY_MS) {
return { label: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" };
return { kind: "elapsed", value: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" };
}
if (elapsedMs < ABSOLUTE_AFTER_MS) {
return { label: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" };
return { kind: "elapsed", value: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" };
}
const month = date.toLocaleDateString("en-US", { month: "short" });
return { label: `${month} ${date.getDate()}`, resolution: "static" };
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 };
}
export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string {
@@ -60,6 +60,7 @@ 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 = [];
@@ -73,6 +74,7 @@ const mockState = vi.hoisted(() => {
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
this.runtimeModels.clear();
this.cursorListFeaturesConfigs = [];
this.codexNativeArchiveCalls = [];
},
};
});
@@ -171,6 +173,14 @@ 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
@@ -651,6 +661,22 @@ 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,6 +488,8 @@ 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,
@@ -576,6 +578,12 @@ 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 { SessionMessageInfo } from "@opencode/client";
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
@@ -535,4 +535,85 @@ 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 { SessionInfo, SessionMessageInfo } from "@opencode/client";
import type { ModelRef, SessionInfo, SessionMessageInfo } from "@opencode/client";
import { setTimeout as delay } from "node:timers/promises";
import type { Logger } from "pino";
@@ -277,24 +277,35 @@ export class OpenCodeV2Session implements AgentSession {
async setModel(model: string | null) {
await this.reconnectIfExited();
const selected = model
? modelRef(model, this.config.thinkingOptionId)
? modelRef(model)
: (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: this.config.thinkingOptionId,
},
model: { id: selected.id, providerID: selected.providerID, variant },
});
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;
@@ -0,0 +1,135 @@
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. It needs no card and does not expire. When you want more agent runs or
more seats, upgrade from Billing inside Hub.
for a free account. When you want more agent runs or more seats, upgrade from Billing
inside Hub.
</FAQItem>
</div>
</section>