Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/state/useCrewController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ describe("useCrewController", () => {
expect(listModelProviders).toHaveBeenCalledTimes(2);
});

it("discovers subscriptions added in the browser when the app regains focus", async () => {
let providers: Awaited<ReturnType<CloudAgentsClient["listModelProviders"]>>["providers"] = [];
const client = { listModelProviders: async () => ({ organizationId: "org-test", providers }), listAgents: async () => [] } as unknown as CloudAgentsClient;
const { result } = renderHook(() => useCrewController(client));
await waitFor(() => expect(result.current.loading).toBe(false));
providers = [{ id: "codex-1", name: "Codex subscription", protocol: "openai_responses" }];
await act(async () => { window.dispatchEvent(new Event("focus")); });
await waitFor(() => expect(result.current.modelProviders).toEqual(providers));
});

it("does not start Cloud Agents requests until authentication is enabled", async () => {
const listAgents = vi.fn(); const listModelProviders = vi.fn();
const client = { listAgents, listModelProviders } as unknown as CloudAgentsClient;
Expand Down
4 changes: 2 additions & 2 deletions src/state/useCrewController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,13 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) {
if (!enabled) return;
const refreshWhenActive = () => {
if (document.visibilityState === "hidden" || !navigator.onLine) return;
void refreshAgents().then(() => setError((current) => isTransientGatewayError(current) ? undefined : current)).catch(() => undefined);
void Promise.all([refreshAgents(), refreshModelProviders()]).then(() => setError((current) => isTransientGatewayError(current) ? undefined : current)).catch(() => undefined);
};
const onVisibilityChange = () => { if (document.visibilityState === "visible") refreshWhenActive(); };
const timer = window.setInterval(refreshWhenActive, AGENT_FALLBACK_REFRESH_MS);
window.addEventListener("focus", refreshWhenActive); window.addEventListener("online", refreshWhenActive); document.addEventListener("visibilitychange", onVisibilityChange);
return () => { window.clearInterval(timer); window.removeEventListener("focus", refreshWhenActive); window.removeEventListener("online", refreshWhenActive); document.removeEventListener("visibilitychange", onVisibilityChange); };
}, [enabled, refreshAgents]);
}, [enabled, refreshAgents, refreshModelProviders]);
useEffect(() => {
if (!enabled) return;
const cached = snapshots.current.get(selectedAgentId);
Expand Down
26 changes: 26 additions & 0 deletions src/ui/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ import { SettingsDialog } from "./components/Dialogs";
import type { AppSettings, DesktopBridge } from "@/shared/desktop";

describe("Errand authentication surfaces", () => {
it("refreshes an open provider picker even when creation started outside Errand", async () => {
const user = userEvent.setup();
let connected = false;
const openExternal = vi.fn();
window.runtaCrew = {
openExternal,
settings: { get: async () => ({ endpoint: "https://api.forge", dashboardUrl: "https://app.forge", theme: "light", notifications: true }), set: async (settings: AppSettings) => settings },
credentials: { has: async () => true },
cloud: { request: async ({ path }: { path: string }) => ({ status: 200, body: path === "/v2/me" ? { data: { display_name: "Tester" } } : path === "/v2/model-providers" ? { organization_id: "org-test", model_providers: connected ? [{ id: "codex-1", display_name: "Codex subscription", protocol: "openai_responses", base_url: "https://chatgpt.com/backend-api/codex" }] : [] } : { agents: [] } }) },
notifications: { setBadge: async () => undefined },
deepLinks: { onOpenAgent: () => () => undefined },
} as unknown as DesktopBridge;
const view = render(<App />);
try {
await user.click(await screen.findByRole("button", { name: "New agent" }));
expect(await screen.findByRole("button", { name: "Add model provider" })).toBeInTheDocument();
connected = true;
await user.click(await screen.findByRole("button", { name: "Model provider" }, { timeout: 4000 }));
expect(screen.getByRole("option", { name: "Codex subscription" })).toBeInTheDocument();
expect(openExternal).not.toHaveBeenCalled();
} finally {
view.unmount();
delete window.runtaCrew;
}
});

it("opens the dashboard add-provider page when no providers exist", async () => {
const openExternal = vi.fn(async () => undefined); const user = userEvent.setup();
window.runtaCrew = {
Expand Down
12 changes: 5 additions & 7 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function App() {
const [client, setClient] = useState<RuntaCloudAgentsClient>(() => new RuntaCloudAgentsClient()); const crew = useCrewController(client, signedIn);
const refreshModelProviders = crew.refreshModelProviders;
const crewAgents = crew.agents; const selectAgent = crew.setSelectedAgentId;
const [search, setSearch] = useState(""); const [detailsOpen, setDetailsOpen] = useState(false); const [detailsMounted, setDetailsMounted] = useState(false); const [detailPanelWidth, setDetailPanelWidth] = useState(340); const [creatingAgentName, setCreatingAgentName] = useState<string>(); const [creatingAgentPhase, setCreatingAgentPhase] = useState<"creating" | "typing">("creating"); const [creatingAgentBaselineIds, setCreatingAgentBaselineIds] = useState<ReadonlySet<string>>(() => new Set()); const [composerFocusRequest, setComposerFocusRequest] = useState(0); const [settingsOpen, setSettingsOpen] = useState(false); const [providerPolling, setProviderPolling] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [editingAgent, setEditingAgent] = useState<Agent>(); const [deletingAgent, setDeletingAgent] = useState<Agent>();
const [search, setSearch] = useState(""); const [detailsOpen, setDetailsOpen] = useState(false); const [detailsMounted, setDetailsMounted] = useState(false); const [detailPanelWidth, setDetailPanelWidth] = useState(340); const [creatingAgentName, setCreatingAgentName] = useState<string>(); const [creatingAgentPhase, setCreatingAgentPhase] = useState<"creating" | "typing">("creating"); const [creatingAgentBaselineIds, setCreatingAgentBaselineIds] = useState<ReadonlySet<string>>(() => new Set()); const [composerFocusRequest, setComposerFocusRequest] = useState(0); const [settingsOpen, setSettingsOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [editingAgent, setEditingAgent] = useState<Agent>(); const [deletingAgent, setDeletingAgent] = useState<Agent>();
const agents = crew.agents.filter((agent) => `${agent.name} ${agent.role}`.toLowerCase().includes(search.toLowerCase()));
function readableAuthError(reason: unknown) {
const raw = reason instanceof Error ? reason.message : "";
Expand Down Expand Up @@ -128,24 +128,23 @@ export function App() {
return () => window.clearTimeout(timer);
}, [detailsOpen]);
useEffect(() => {
if (!settingsOpen || !providerPolling) return;
if (!signedIn || !settingsOpen) return;
let stopped = false; let timer: number | undefined;
const poll = async () => {
try {
await refreshModelProviders();
if (document.visibilityState !== "hidden" && navigator.onLine) await refreshModelProviders();
if (stopped) return;
} catch { /* keep polling while Settings remains open */ }
if (!stopped) timer = window.setTimeout(() => void poll(), 2_000);
};
void poll();
return () => { stopped = true; if (timer !== undefined) window.clearTimeout(timer); };
}, [providerPolling, refreshModelProviders, settingsOpen]);
}, [signedIn, refreshModelProviders, settingsOpen]);
function handleAgentAction(agent: Agent, action: AgentAction) {
if (action === "edit") setEditingAgent(agent);
if (action === "delete") setDeletingAgent(agent);
}
const dismissError = () => { crew.dismissError(); setAuthError(undefined); };
const startModelProviderPolling = () => { dismissError(); setProviderPolling(true); };
async function createDefaultAgent() {
if (creatingAgentName) return;
const name = nextAgentName(crew.agents.map((agent) => agent.name));
Expand All @@ -155,7 +154,6 @@ export function App() {
const settings = await window.runtaCrew?.settings.get();
const provider = crew.modelProviders.find((item) => item.id === settings?.modelProviderId);
if (!provider) {
setAuthError("Select a model provider before creating an agent.");
setSettingsOpen(true);
return;
}
Expand Down Expand Up @@ -185,7 +183,7 @@ export function App() {
<ErrorToast message={crew.error || authError} onDismiss={dismissError} />
{editingAgent && <EditAgentDialog agent={editingAgent} onClose={() => setEditingAgent(undefined)} onSave={(input) => crew.updateAgent(editingAgent.id, input)} />}
{deletingAgent && <DeleteAgentDialog agent={deletingAgent} onClose={() => setDeletingAgent(undefined)} onDelete={() => crew.deleteAgent(deletingAgent.id)} />}
{settingsOpen && <SettingsDialog providers={crew.modelProviders} organizationId={crew.organizationId} accountName={userName} accountEmail={userEmail} onModelProviderOpen={startModelProviderPolling} onLogout={() => { setProviderPolling(false); setSettingsOpen(false); void logout(); }} onClose={() => { setProviderPolling(false); setSettingsOpen(false); }} />}
{settingsOpen && <SettingsDialog providers={crew.modelProviders} organizationId={crew.organizationId} accountName={userName} accountEmail={userEmail} onModelProviderOpen={dismissError} onLogout={() => { setSettingsOpen(false); void logout(); }} onClose={() => { setSettingsOpen(false); }} />}
<CommandPalette open={paletteOpen} agents={crew.agents} onClose={() => setPaletteOpen(false)} onSelectAgent={crew.setSelectedAgentId} onCreateAgent={() => void createDefaultAgent()} onSettings={() => setSettingsOpen(true)} onComputer={() => setDetailsOpen(true)} />
</div>;
}
2 changes: 1 addition & 1 deletion src/ui/components/Dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function SettingsDialog({ mode = "preferences", providers = [], organizat
<section><h3>Account</h3><div className="settings-card account-card"><span className="settings-account-avatar">{initials}</span><span className="settings-account-copy"><strong>{accountName}</strong>{accountEmail && <small>{accountEmail}</small>}</span>{onLogout && <button className="settings-signout" onClick={onLogout}>Sign out</button>}</div></section>
<section><h3>Agents</h3><div className="settings-card"><div className="settings-row settings-provider-row"><span>Model provider</span>{providers.length
? <Select ariaLabel="Model provider" placeholder="Select a provider" value={settings.modelProviderId ?? ""} options={[...providers.map((provider) => ({ value: provider.id, label: provider.name, icon: <ModelProviderIcon id={providerIconId(provider)} className="provider-menu-icon" /> })), { value: "__add_provider__", label: "Add model provider", icon: <Plus size={14} />, disabled: !settings.dashboardUrl || !organizationId, action: () => { onModelProviderOpen?.(); addModelProvider(); } }]} onChange={(modelProviderId) => persist({ ...settings, modelProviderId })} />
: <button type="button" className="settings-provider-action" aria-label="Add model provider" onClick={() => { onModelProviderOpen?.(); addModelProvider(); }}>Add model provider<ChevronRight size={14} /></button>}<p className="settings-provider-note">Only API model providers are supported. Subscriptions are not supported.</p></div>
: <button type="button" className="settings-provider-action" aria-label="Add model provider" onClick={() => { onModelProviderOpen?.(); addModelProvider(); }}>Add model provider<ChevronRight size={14} /></button>}<p className="settings-provider-note">Use an API provider or a connected Codex subscription.</p></div>
<div className="settings-system-prompt"><label htmlFor="agent-system-prompt">System prompt</label><p>Applies to new agents only. Use {"{agent_name}"} for the agent’s name.</p><textarea id="agent-system-prompt" rows={7} value={promptDraft} disabled={promptSaving} onChange={(event) => { setPromptDraft(event.target.value); setPromptFeedback(undefined); }} /><div className="settings-prompt-actions"><button type="button" className="settings-prompt-button" disabled={promptSaving || promptDraft === DEFAULT_AGENT_SYSTEM_PROMPT} onClick={() => { setPromptDraft(DEFAULT_AGENT_SYSTEM_PROMPT); setPromptFeedback(undefined); }}>Restore default</button><button type="button" className="settings-prompt-button is-primary" aria-label="Save prompt" disabled={promptSaving || !promptDraft.trim() || promptDraft === (settings.systemPrompt ?? DEFAULT_AGENT_SYSTEM_PROMPT)} onClick={() => void savePrompt()}>{promptSaving ? "Saving…" : "Save"}</button></div>{promptFeedback && <p role={promptFeedback.error ? "alert" : "status"}>{promptFeedback.text}</p>}</div>
</div></section>
<section><h3>Appearance</h3><div className="settings-card"><div className="settings-row"><span>Theme</span><Select ariaLabel="Theme" value={settings.theme} options={[{ value: "system", label: "Follow System" }, { value: "light", label: "Light" }, { value: "dark", label: "Dark" }]} onChange={(theme) => persist({ ...settings, theme: theme as ThemePreference })} /></div><label className="settings-row"><span>Notifications</span><input className="settings-toggle" type="checkbox" checked={settings.notifications} onChange={(event) => persist({ ...settings, notifications: event.target.checked })} /></label></div></section>
Expand Down
12 changes: 7 additions & 5 deletions src/ui/components/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { Check, ChevronDown } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";

export interface SelectOption { value: string; label: string; disabled?: boolean; icon?: ReactNode; action?: () => void }

export function Select({ value, options, ariaLabel, placeholder = "Select", onChange, onOpen }: { value: string; options: SelectOption[]; ariaLabel: string; placeholder?: string; onChange(value: string): void; onOpen?(): void }) {
const [open, setOpen] = useState(false); const [menuStyle, setMenuStyle] = useState<React.CSSProperties>(); const root = useRef<HTMLDivElement>(null); const menu = useRef<HTMLDivElement>(null);
const selected = options.find((option) => option.value === value);
useEffect(() => {
useLayoutEffect(() => {
if (!open) return;
const position = () => {
const bounds = root.current?.getBoundingClientRect(); if (!bounds) return;
const gap = 5; const margin = 8; const width = Math.max(bounds.width, 180); const desiredHeight = Math.min(220, options.length * 32 + 10); const roomBelow = window.innerHeight - bounds.bottom - margin; const openUp = roomBelow < desiredHeight && bounds.top - margin > roomBelow;
const labelOverflow = Math.max(0, ...Array.from(menu.current?.querySelectorAll<HTMLElement>(".crew-select-label") ?? []).map((label) => label.scrollWidth - label.clientWidth));
const contentWidth = (menu.current?.getBoundingClientRect().width ?? 0) + labelOverflow;
const gap = 5; const margin = 8; const width = Math.min(Math.max(bounds.width, contentWidth, 180), window.innerWidth - margin * 2); const desiredHeight = Math.min(220, options.length * 32 + 10); const roomBelow = window.innerHeight - bounds.bottom - margin; const openUp = roomBelow < desiredHeight && bounds.top - margin > roomBelow;
setMenuStyle({ position: "fixed", zIndex: 100, left: Math.max(margin, Math.min(bounds.right - width, window.innerWidth - width - margin)), top: openUp ? Math.max(margin, bounds.top - desiredHeight - gap) : bounds.bottom + gap, width, maxHeight: openUp ? Math.min(220, bounds.top - gap - margin) : Math.min(220, roomBelow) });
};
const close = (event: PointerEvent) => { const target = event.target as Node; if (!root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false); };
position(); window.addEventListener("pointerdown", close); window.addEventListener("resize", position); window.addEventListener("scroll", position, true);
return () => { window.removeEventListener("pointerdown", close); window.removeEventListener("resize", position); window.removeEventListener("scroll", position, true); };
}, [open, options.length]);
}, [open, options]);
const move = (direction: 1 | -1) => {
const available = options.filter((option) => !option.disabled && !option.action); if (!available.length) return;
const current = available.findIndex((option) => option.value === value);
Expand All @@ -30,6 +32,6 @@ export function Select({ value, options, ariaLabel, placeholder = "Select", onCh
if (event.key === "Escape") { setOpen(false); return; }
if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); move(event.key === "ArrowDown" ? 1 : -1); if (!open) onOpen?.(); setOpen(true); }
}}><span>{selected?.label ?? placeholder}</span><span className="crew-select-chevron"><ChevronDown size={15} /></span></button>
{open && menuStyle && createPortal(<div className="crew-select-menu crew-select-menu-portal" ref={menu} style={menuStyle} role="listbox" aria-label={ariaLabel}>{options.map((option) => <button type="button" className={`${option.action ? "crew-select-action" : ""} ${option.action || option.icon ? "crew-select-has-icon" : ""}`} role="option" aria-selected={!option.action && option.value === value} disabled={option.disabled} key={option.value} onClick={() => { option.action?.(); if (!option.action) onChange(option.value); setOpen(false); }}>{(option.action || option.icon) && <span className="crew-select-check" aria-hidden="true">{option.icon}</span>}<span className="crew-select-label" title={option.label}>{option.label}</span>{!option.action && <span className="crew-select-check" aria-hidden="true">{option.value === value && <Check size={14} />}</span>}</button>)}</div>, document.body)}
{open && createPortal(<div className="crew-select-menu crew-select-menu-portal" ref={menu} style={menuStyle ?? { position: "fixed", visibility: "hidden", width: "max-content" }} role="listbox" aria-label={ariaLabel}>{options.map((option) => <button type="button" className={`${option.action ? "crew-select-action" : ""} ${option.action || option.icon ? "crew-select-has-icon" : ""}`} role="option" aria-selected={!option.action && option.value === value} disabled={option.disabled} key={option.value} onClick={() => { option.action?.(); if (!option.action) onChange(option.value); setOpen(false); }}>{(option.action || option.icon) && <span className="crew-select-check" aria-hidden="true">{option.icon}</span>}<span className="crew-select-label" title={option.label}>{option.label}</span>{!option.action && <span className="crew-select-check" aria-hidden="true">{option.value === value && <Check size={14} />}</span>}</button>)}</div>, document.body)}
</div>;
}
2 changes: 2 additions & 0 deletions src/ui/providerIcon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ it("identifies the endpoint rather than the editable display name or wire protoc
expect(providerIconId({ ...provider, name: "Team model", baseUrl: "https://api.openai.com/v1/" })).toBe("openai");
expect(providerIconId({ ...provider, baseUrl: "https://proxy.example/v1" })).toBe("compatible");
expect(providerIconId(provider)).toBe("compatible");
expect(providerIconId({ ...provider, baseUrl: "https://chatgpt.com/backend-api/codex" })).toBe("codex_oauth");
expect(providerIconId({ ...provider, baseUrl: "https://chatgpt.com.evil.example/backend-api/codex" })).toBe("compatible");
});
1 change: 1 addition & 0 deletions src/ui/providerIcon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ModelProviderOption } from "@/domain/types";
export function providerIconId(provider: ModelProviderOption): string {
if (!provider.baseUrl) return "compatible";
const endpoint = provider.baseUrl.replace(/\/+$/, "");
if (endpoint === "https://chatgpt.com/backend-api/codex" && provider.protocol === "openai_responses") return "codex_oauth";
if (endpoint === "https://api.openai.com/v1" && ["openai_chat", "openai_responses"].includes(provider.protocol)) return "openai";
if (endpoint === "https://api.anthropic.com" && provider.protocol === "anthropic_messages") return "anthropic";
if (endpoint === "https://api.moonshot.ai/v1" && ["openai_chat", "openai_responses"].includes(provider.protocol)) return "kimi";
Expand Down
Loading