From 89b0948ab2eee3d6f65c87ca1008bd038ef4b758 Mon Sep 17 00:00:00 2001 From: Shiqi Mei Date: Tue, 1 Sep 2026 12:04:10 +0800 Subject: [PATCH 01/78] fix: darken settings overlay --- src/ui/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/styles.css b/src/ui/styles.css index f8cce17..e3e86d5 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -51,7 +51,7 @@ code, pre, kbd, samp { font-family:var(--font-mono); } .dialog-backdrop{position:fixed;z-index:20;inset:0;display:grid;place-items:center;padding:30px;background:rgb(30 25 21/.28);backdrop-filter:blur(4px)}.dialog{width:min(520px,100%);max-height:calc(100vh - 60px);overflow:visible;border:1px solid var(--border);border-radius:10px;background:var(--surface-raised);box-shadow:var(--shadow);-webkit-app-region:no-drag}.dialog *{-webkit-app-region:no-drag}.dialog>header{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid var(--border)}.dialog>header .icon-button{position:relative;z-index:1;pointer-events:auto}.dialog h2{margin:0;font-size:14px}.dialog-lead{margin:18px 20px 0;color:var(--muted);font:12px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.dialog-form,.settings-form{display:flex;flex-direction:column;gap:16px;padding:20px}.dialog-form label,.settings-form label{display:flex;flex-direction:column;gap:7px;color:var(--muted);font-size:10px}.dialog-form input,.dialog-form textarea,.settings-form input,.settings-form select{width:100%;min-height:39px;padding:9px 11px;border:1px solid var(--border);border-radius:6px;outline:0;background:var(--surface)}.dialog-form input:focus,.dialog-form textarea:focus,.settings-form input:focus{border-color:var(--primary)}.dialog-form textarea{min-height:90px;resize:vertical}.dialog-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:3px}.settings-form section{display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border)}.settings-form h3{margin:0;font-size:11px}.settings-form small{color:var(--faint);font-size:8px}.settings-form label.checkbox{flex-direction:row;align-items:center}.checkbox input{width:auto;min-height:auto}.about-row{flex-direction:row!important;justify-content:space-between;align-items:center}.about-row p{margin:4px 0 0;color:var(--muted);font-size:9px}.about-row>span{color:var(--muted);font-size:9px}.saved-label{margin-right:auto;color:var(--green);font-size:10px}.compact-settings{gap:14px}.compact-settings .dialog-actions{margin-top:6px}.compact-settings label.checkbox{padding:4px 0} .dialog h2{font-size:16px;font-weight:500}.dialog-lead{font:13px/1.55 var(--font-sans)}.dialog-form label,.settings-form label{gap:8px;font-size:13px}.dialog-form input,.dialog-form textarea,.dialog-form select,.settings-form input,.settings-form select{min-height:42px;padding:10px 12px;font-size:14px}.dialog-form textarea{min-height:96px}.dialog-actions button{font-size:14px}.settings-form h3{font-size:14px;font-weight:500}.settings-form small{font-size:11px}.about-row p,.about-row>span{font-size:12px}.saved-label{font-size:12px} .delete-agent-copy{padding:20px;font:12px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.delete-agent-copy p{margin:0 0 10px;color:var(--muted)}.delete-agent-copy strong{color:var(--red);font-size:11px}.delete-actions{padding:0 20px 20px}.destructive-button{background:var(--red);color:white}.destructive-button:hover{background:color-mix(in srgb,var(--red) 84%,black)} -.settings-backdrop{padding:30px}.settings-window{width:min(660px,100%);max-height:calc(100vh - 60px);overflow:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface-raised);box-shadow:var(--shadow);-webkit-app-region:no-drag}.settings-window *{-webkit-app-region:no-drag}.settings-content{min-width:0;padding:0 24px 26px}.settings-content>header{display:flex;height:68px;align-items:center;justify-content:space-between}.settings-content>header .icon-button{pointer-events:auto}.settings-content h2{margin:0;font-size:15px}.settings-content>section{margin-bottom:24px}.settings-content>section:last-child{margin-bottom:0}.settings-content>section>h3{margin:0 0 9px 2px;color:var(--muted);font-size:10px;font-weight:400}.settings-card{border-radius:10px;background:var(--bg)}.account-card{display:flex;min-height:82px;align-items:center;padding:14px 16px}.settings-account-avatar{display:grid;flex:0 0 auto;width:42px;height:42px;place-items:center;border:1px solid var(--account-avatar-border);border-radius:50%;background:var(--account-avatar-bg);color:var(--account-avatar-fg);font-size:13px}.settings-account-copy{display:flex;min-width:0;flex-direction:column;gap:4px;margin-left:12px}.settings-account-copy strong{font-size:12px;font-weight:600}.settings-account-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:10px}.settings-signout{height:32px;margin-left:auto;padding:0 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);font-size:10px}.settings-row{display:flex!important;min-height:50px;align-items:center!important;justify-content:space-between;padding:0 14px!important;border-bottom:1px solid var(--border);color:var(--text)!important;font-size:11px!important}.settings-row:first-child{border-radius:10px 10px 0 0}.settings-row:last-child{border-bottom:0;border-radius:0 0 10px 10px}.settings-toggle{position:relative;width:34px!important;height:20px!important;min-height:0!important;appearance:none;border:0!important;border-radius:999px!important;background:var(--border)!important;cursor:pointer}.settings-toggle:after{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--surface);box-shadow:0 1px 3px rgb(0 0 0/.18);content:"";transition:transform .15s}.settings-toggle:checked{background:var(--text)!important}.settings-toggle:checked:after{transform:translateX(14px)} +.settings-backdrop{padding:30px;background:rgb(0 0 0/.52)}.settings-window{width:min(660px,100%);max-height:calc(100vh - 60px);overflow:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface-raised);box-shadow:var(--shadow);-webkit-app-region:no-drag}.settings-window *{-webkit-app-region:no-drag}.settings-content{min-width:0;padding:0 24px 26px}.settings-content>header{display:flex;height:68px;align-items:center;justify-content:space-between}.settings-content>header .icon-button{pointer-events:auto}.settings-content h2{margin:0;font-size:15px}.settings-content>section{margin-bottom:24px}.settings-content>section:last-child{margin-bottom:0}.settings-content>section>h3{margin:0 0 9px 2px;color:var(--muted);font-size:10px;font-weight:400}.settings-card{border-radius:10px;background:var(--bg)}.account-card{display:flex;min-height:82px;align-items:center;padding:14px 16px}.settings-account-avatar{display:grid;flex:0 0 auto;width:42px;height:42px;place-items:center;border:1px solid var(--account-avatar-border);border-radius:50%;background:var(--account-avatar-bg);color:var(--account-avatar-fg);font-size:13px}.settings-account-copy{display:flex;min-width:0;flex-direction:column;gap:4px;margin-left:12px}.settings-account-copy strong{font-size:12px;font-weight:600}.settings-account-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:10px}.settings-signout{height:32px;margin-left:auto;padding:0 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);font-size:10px}.settings-row{display:flex!important;min-height:50px;align-items:center!important;justify-content:space-between;padding:0 14px!important;border-bottom:1px solid var(--border);color:var(--text)!important;font-size:11px!important}.settings-row:first-child{border-radius:10px 10px 0 0}.settings-row:last-child{border-bottom:0;border-radius:0 0 10px 10px}.settings-toggle{position:relative;width:34px!important;height:20px!important;min-height:0!important;appearance:none;border:0!important;border-radius:999px!important;background:var(--border)!important;cursor:pointer}.settings-toggle:after{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--surface);box-shadow:0 1px 3px rgb(0 0 0/.18);content:"";transition:transform .15s}.settings-toggle:checked{background:var(--text)!important}.settings-toggle:checked:after{transform:translateX(14px)} .crew-select{position:relative;min-width:132px;color:var(--text);font-size:10px}.dialog-form .crew-select{width:100%;font-size:11px}.crew-select-trigger{position:relative;display:flex;width:100%;height:32px;align-items:center;padding:0 34px 0 10px;border:0;border-radius:7px;background:var(--surface);text-align:left}.dialog-form .crew-select-trigger{height:39px;border:1px solid var(--border)}.crew-select-trigger>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.crew-select-chevron{position:absolute;top:0;right:0;display:grid;width:32px;height:100%;place-items:center;color:var(--muted);pointer-events:none}.crew-select.open .crew-select-chevron svg{transform:rotate(180deg)}.crew-select-menu{position:absolute;z-index:60;top:calc(100% + 5px);right:0;min-width:max(100%,180px);max-height:220px;overflow-y:auto;padding:5px;border:1px solid var(--border);border-radius:9px;background:var(--surface-raised);box-shadow:var(--shadow)}.crew-select-menu-portal{right:auto;min-width:0}.crew-select-menu button{display:grid;width:100%;height:32px;grid-template-columns:20px minmax(0,1fr);align-items:center;padding:0 8px;border:0;border-radius:6px;background:transparent;color:var(--text);font-size:11px;text-align:left}.crew-select-menu button>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.crew-select-menu button:hover,.crew-select-menu button[aria-selected="true"]{background:var(--interactive-hover)!important}.crew-select-check{display:grid;width:16px;place-items:center}.crew-select-menu button:disabled{opacity:.45} .palette-backdrop{position:fixed;z-index:40;inset:0;display:flex;align-items:flex-start;justify-content:center;padding-top:min(16vh,140px);background:rgb(30 25 21/.24);backdrop-filter:blur(3px)}.command-palette{width:min(620px,calc(100vw - 48px));overflow:hidden;border:1px solid var(--border);border-radius:10px;background:var(--surface-raised);box-shadow:0 24px 70px rgb(46 34 24/.2)}.command-palette>label{display:flex;align-items:center;gap:10px;height:54px;padding:0 14px;border-bottom:1px solid var(--border);color:var(--muted)}.command-palette>label input{flex:1;border:0;outline:0;background:transparent;font-size:12px}.palette-results{max-height:360px;overflow:auto;padding:7px}.palette-results button{display:flex;align-items:center;gap:11px;width:100%;padding:9px;border:0;border-radius:6px;background:transparent;text-align:left}.palette-results button:hover{background:var(--interactive-hover)}.palette-results button.active{background:var(--interactive-selected)}.palette-results button>span{display:grid;width:31px;height:31px;place-items:center;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--muted)}.palette-results button>div{display:flex;flex:1;flex-direction:column;gap:4px}.palette-results strong{font-size:10px}.palette-results small{color:var(--muted);font-size:8px;text-transform:capitalize}.palette-results>p{padding:26px;text-align:center;color:var(--muted);font-size:10px}.command-palette footer{display:flex;justify-content:space-between;padding:9px 12px;border-top:1px solid var(--border);color:var(--faint);font-size:8px}.command-palette footer>span:last-child{display:flex;align-items:center;gap:3px} .error-toast{position:fixed;z-index:30;right:20px;bottom:20px;display:flex;align-items:center;gap:10px;max-width:430px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--red) 30%,var(--border));border-radius:7px;background:var(--surface);box-shadow:var(--shadow);font-size:10px}.error-toast svg{color:var(--red)}.error-toast button{margin-left:8px;border:0;background:transparent;color:var(--primary);font-size:9px}.loading-screen,.empty-state{display:flex;height:100%;align-items:center;justify-content:center;gap:18px;color:var(--muted);font-size:11px}.loading-logo{width:34px;height:34px;object-fit:contain} From 98f279f1231fab5ef5097cfe11492c0874da09ad Mon Sep 17 00:00:00 2001 From: Shiqi Mei Date: Tue, 1 Sep 2026 12:10:51 +0800 Subject: [PATCH 02/78] feat: link empty provider picker to dashboard --- src/clients/http/RuntaCloudAgentsClient.test.ts | 4 ++-- src/clients/http/RuntaCloudAgentsClient.ts | 8 ++++---- src/domain/CloudAgentsClient.ts | 4 ++-- src/domain/types.ts | 1 + src/state/useCrewController.test.tsx | 14 +++++++------- src/state/useCrewController.ts | 7 ++++--- src/ui/App.test.tsx | 16 +++++++++++++++- src/ui/App.tsx | 2 +- src/ui/components/Dialogs.tsx | 9 +++++++-- src/ui/components/Select.test.tsx | 8 ++++++++ src/ui/components/Select.tsx | 6 +++--- 11 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/clients/http/RuntaCloudAgentsClient.test.ts b/src/clients/http/RuntaCloudAgentsClient.test.ts index 508a0ee..549c42f 100644 --- a/src/clients/http/RuntaCloudAgentsClient.test.ts +++ b/src/clients/http/RuntaCloudAgentsClient.test.ts @@ -15,7 +15,7 @@ describe("RuntaCloudAgentsClient", () => { it("maps the current Cloud Agents envelope and uses the managed provider for creation", async () => { const request = vi.fn(async ({ method, path }: CloudRequest) => { if (path.startsWith("/v1/agents?")) return { status: 200, body: { agents: [{ id: "agent-1", runtime_id: "agent-1", name: "Builder", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 2, latest_reply: { run_id: "run-1", text: "Latest agent reply", created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" } }] } }; - if (path === "/v1/model-providers") return { status: 200, body: { model_providers: [{ id: "provider-1", display_name: "Kimi", protocol: "openai_responses", default_model: "k3" }] } }; + if (path === "/v1/model-providers") return { status: 200, body: { organization_id: "org-1", model_providers: [{ id: "provider-1", display_name: "Kimi", protocol: "openai_responses", default_model: "k3" }] } }; if (method === "GET" && path === "/v1/agents/agent-1/runs?limit=100") return { status: 200, body: [ { id: "run-2", agent_id: "agent-1", status: "failed", prompt: "Break it", result: null, error: "Tool failed", created_at: "2026-08-26T02:00:00Z", updated_at: "2026-08-26T02:00:01Z" }, { id: "run-1", agent_id: "agent-1", status: "finished", prompt: "Build it", result: "Done", error: null, created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" }, @@ -28,7 +28,7 @@ describe("RuntaCloudAgentsClient", () => { window.runtaCrew = { cloud: { request, subscribe }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; const client = new RuntaCloudAgentsClient(); expect((await client.listAgents())[0]).toEqual(expect.objectContaining({ id: "agent-1", name: "Builder", status: "idle", lastMessagePreview: "Latest agent reply", lastActiveAt: "2026-08-26T01:00:01Z" })); - expect(await client.listModelProviders()).toEqual([{ id: "provider-1", name: "Kimi", protocol: "openai_responses", defaultModel: "k3" }]); + expect(await client.listModelProviders()).toEqual({ organizationId: "org-1", providers: [{ id: "provider-1", name: "Kimi", protocol: "openai_responses", defaultModel: "k3" }] }); expect((await client.getConversation("conversation-agent-1")).messages).toEqual([ expect.objectContaining({ id: "run-1:user", role: "user", parts: [{ type: "text", text: "Build it" }] }), expect.objectContaining({ id: "run-1:agent", role: "agent", parts: [{ type: "text", text: "Done" }], streaming: false }), diff --git a/src/clients/http/RuntaCloudAgentsClient.ts b/src/clients/http/RuntaCloudAgentsClient.ts index 36395d0..f723221 100644 --- a/src/clients/http/RuntaCloudAgentsClient.ts +++ b/src/clients/http/RuntaCloudAgentsClient.ts @@ -1,5 +1,5 @@ import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; -import { CrewError, type ActivityEvent, type Agent, type ApprovalRequest, type CloudComputer, type ConversationEvent, type CreateAgentInput, type Message, type ModelProviderOption, type RespondApprovalInput, type SendMessageInput, type Subscription, type UpdateAgentInput } from "@/domain/types"; +import { CrewError, type ActivityEvent, type Agent, type ApprovalRequest, type CloudComputer, type ConversationEvent, type CreateAgentInput, type Message, type ModelProviderCatalog, type RespondApprovalInput, type SendMessageInput, type Subscription, type UpdateAgentInput } from "@/domain/types"; import type { CloudRequest, CloudStreamEvent } from "@/shared/desktop"; interface RuntaAgent { id: string; runtime_id: string; name: string; status: string; created_at_unix_seconds: number; updated_at_unix_seconds: number; latest_reply?: { run_id: string; text: string; created_at?: string | null; updated_at?: string | null } | null } @@ -95,10 +95,10 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { return { id: value.id, name: value.name, role: "Cloud coding agent", goal: value.name, status: status(value.status), avatar: value.name.slice(0, 1).toUpperCase(), lastActiveAt: value.latest_reply?.updated_at ?? iso(value.updated_at_unix_seconds), unreadCount: 0, computerId: value.runtime_id, lastMessagePreview: value.latest_reply?.text }; } - async listModelProviders(_signal?: AbortSignal): Promise { + async listModelProviders(_signal?: AbortSignal): Promise { void _signal; - const response = await this.request<{ model_providers: ModelProvider[] }>({ method: "GET", path: "/v1/model-providers" }); - return response.model_providers.map((provider) => ({ id: provider.id, name: provider.display_name, protocol: provider.protocol, defaultModel: provider.default_model ?? undefined })); + const response = await this.request<{ organization_id: string; model_providers: ModelProvider[] }>({ method: "GET", path: "/v1/model-providers" }); + return { organizationId: response.organization_id, providers: response.model_providers.map((provider) => ({ id: provider.id, name: provider.display_name, protocol: provider.protocol, defaultModel: provider.default_model ?? undefined })) }; } async listAgents(_signal?: AbortSignal) { diff --git a/src/domain/CloudAgentsClient.ts b/src/domain/CloudAgentsClient.ts index b5474ab..6f6304c 100644 --- a/src/domain/CloudAgentsClient.ts +++ b/src/domain/CloudAgentsClient.ts @@ -1,7 +1,7 @@ -import type { Agent, ApprovalRequest, CloudComputer, Conversation, ConversationEvent, CreateAgentInput, Message, ModelProviderOption, RespondApprovalInput, SendMessageInput, Subscription, UpdateAgentInput } from "./types"; +import type { Agent, ApprovalRequest, CloudComputer, Conversation, ConversationEvent, CreateAgentInput, Message, ModelProviderCatalog, RespondApprovalInput, SendMessageInput, Subscription, UpdateAgentInput } from "./types"; export interface CloudAgentsClient { - listModelProviders(signal?: AbortSignal): Promise; + listModelProviders(signal?: AbortSignal): Promise; listAgents(signal?: AbortSignal): Promise; getAgent(agentId: string, signal?: AbortSignal): Promise; createAgent(input: CreateAgentInput, signal?: AbortSignal): Promise; diff --git a/src/domain/types.ts b/src/domain/types.ts index 0079783..c1e8e5c 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -18,6 +18,7 @@ export interface ActivityEvent { id: string; conversationId: string; kind: "brow export interface ApprovalRequest { id: string; agentId: string; conversationId: string; title: string; description: string; scope: string[]; status: "pending" | "allowed" | "denied"; createdAt: string; responseNote?: string } export interface CloudComputer { id: string; agentId: string; runtimeName: string; status: "online" | "starting" | "offline"; activeApp?: string; previewUrl?: string; capabilities: Array<"open" | "takeover"> } export interface ModelProviderOption { id: string; name: string; protocol: string; defaultModel?: string } +export interface ModelProviderCatalog { organizationId: string; providers: ModelProviderOption[] } export interface CreateAgentInput { name: string; modelProviderId: string } export interface UpdateAgentInput { name?: string; role?: string; goal?: string; pinned?: boolean } export interface SendMessageInput { conversationId: string; text: string; attachments?: Attachment[]; signal?: AbortSignal } diff --git a/src/state/useCrewController.test.tsx b/src/state/useCrewController.test.tsx index 31f8b42..1addb45 100644 --- a/src/state/useCrewController.test.tsx +++ b/src/state/useCrewController.test.tsx @@ -22,7 +22,7 @@ describe("useCrewController", () => { const atlasConversation = new Promise[0]>((resolve) => { resolveAtlas = resolve; }); const listeners = new Map void>(); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation: async (id) => id === "conversation-atlas" ? atlasConversation : { conversation: { id, agentId: "scout", title: "Scout", updatedAt: new Date(0).toISOString() }, messages: [] }, @@ -46,7 +46,7 @@ describe("useCrewController", () => { it("restores a fresh per-agent snapshot without fetching the conversation again", async () => { const getConversation = vi.fn(async (id: string) => ({ conversation: { id, agentId: id.endsWith("atlas") ? "atlas" : "scout", title: id, updatedAt: new Date(0).toISOString() }, messages: [{ id: `${id}-message`, conversationId: id, role: "agent" as const, parts: [{ type: "text" as const, text: id.endsWith("atlas") ? "Atlas cached" : "Scout cached" }], createdAt: new Date(0).toISOString() }] })); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation, sendMessage: async (input) => ({ id: "sent", conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date(0).toISOString() }), subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, @@ -66,7 +66,7 @@ describe("useCrewController", () => { const sendMessage = vi.fn(async () => pendingSend); const listeners = new Map void>(); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: "Old server reply" }], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: "Old server reply" }], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation: async (id) => ({ conversation: { id, agentId: "atlas", title: "Atlas", updatedAt: new Date(0).toISOString() }, messages: [] }), sendMessage, subscribeToConversationEvents: (id, listener) => { listeners.set(id, listener); return { unsubscribe: () => undefined }; }, listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, @@ -110,7 +110,7 @@ describe("useCrewController", () => { it("treats a newly created agent as a known empty conversation", async () => { let created = false; const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => created ? [agent("new-agent", "Atlas")] : [], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => created ? [agent("new-agent", "Atlas")] : [], getAgent: async (id) => agent(id, id), createAgent: async (input) => { created = true; return agent("new-agent", input.name); }, updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, }; @@ -126,7 +126,7 @@ describe("useCrewController", () => { let rejectDelete!: (reason: Error) => void; const pendingDelete = new Promise((_resolve, reject) => { rejectDelete = reject; }); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => pendingDelete, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, }; @@ -146,7 +146,7 @@ describe("useCrewController", () => { it("keeps an accepted deletion tombstoned until the server list confirms removal", async () => { let serverAgents = [agent("atlas", "Atlas"), agent("scout", "Scout")]; const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => serverAgents, + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => serverAgents, getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, }; @@ -165,7 +165,7 @@ describe("useCrewController", () => { const server = { latestReply: undefined as string | undefined }; const getConversation = vi.fn(async (id: string) => ({ conversation: { id, agentId: "atlas", title: "Atlas", updatedAt: new Date().toISOString() }, messages: server.latestReply ? [{ id: "reply", conversationId: id, role: "agent" as const, parts: [{ type: "text" as const, text: server.latestReply }], createdAt: new Date().toISOString() }] : [] })); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: server.latestReply }], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: server.latestReply }], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date().toISOString() }], getConversation, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, }; diff --git a/src/state/useCrewController.ts b/src/state/useCrewController.ts index a5018d0..85cb81f 100644 --- a/src/state/useCrewController.ts +++ b/src/state/useCrewController.ts @@ -27,6 +27,7 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { const [messages, setMessages] = useState([]); const [activities, setActivities] = useState([]); const [approvals, setApprovals] = useState([]); const [computer, setComputer] = useState(); const [connection, setConnection] = useState("connecting"); const [modelProviders, setModelProviders] = useState([]); + const [organizationId, setOrganizationId] = useState(""); const [loading, setLoading] = useState(enabled); const [error, setError] = useState(); const [loadedAgentIds, setLoadedAgentIds] = useState>(() => new Set()); const [liveAgentId, setLiveAgentId] = useState(""); const [revalidateVersion, setRevalidateVersion] = useState(0); @@ -55,9 +56,9 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { setSelectedAgentId((current) => current && next.some((agent) => agent.id === current) ? current : next[0]?.id || ""); return next; }, [client]); useEffect(() => { - if (!enabled) { setAgents([]); setModelProviders([]); setSelectedAgentId(""); setMessages([]); setActivities([]); setApprovals([]); setComputer(undefined); setConnection("disconnected"); setLoading(false); setError(undefined); return; } + if (!enabled) { setAgents([]); setModelProviders([]); setOrganizationId(""); setSelectedAgentId(""); setMessages([]); setActivities([]); setApprovals([]); setComputer(undefined); setConnection("disconnected"); setLoading(false); setError(undefined); return; } let alive = true; setLoading(true); - void Promise.all([refreshAgents(), client.listModelProviders()]).then(([, providers]) => { if (alive) { setModelProviders(providers); setConnection("connected"); setLoading(false); } }).catch((reason: unknown) => { if (alive) { setConnection("error"); setError(reason instanceof Error ? reason.message : "Could not load agents"); setLoading(false); } }); + void Promise.all([refreshAgents(), client.listModelProviders()]).then(([, catalog]) => { if (alive) { setModelProviders(catalog.providers); setOrganizationId(catalog.organizationId); setConnection("connected"); setLoading(false); } }).catch((reason: unknown) => { if (alive) { setConnection("error"); setError(reason instanceof Error ? reason.message : "Could not load agents"); setLoading(false); } }); return () => { alive = false; }; }, [client, enabled, refreshAgents]); useEffect(() => { @@ -157,7 +158,7 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { }, [client, conversationId, enabled, liveAgentId, selectedAgent?.name, selectedAgentId]); return { - agents, modelProviders, selectedAgent, selectedAgentId, setSelectedAgentId, messages, activities, approvals, computer, connection, loading, conversationLoading, error, + agents, modelProviders, organizationId, selectedAgent, selectedAgentId, setSelectedAgentId, messages, activities, approvals, computer, connection, loading, conversationLoading, error, dismissError: () => setError(undefined), createAgent: async (input: CreateAgentInput) => { const agent = await client.createAgent(input); snapshots.current.set(agent.id, { messages: [], approvals: [], cachedAt: 0 }); setLoadedAgentIds((current) => new Set(current).add(agent.id)); await refreshAgents(); setSelectedAgentId(agent.id); return agent; }, updateAgent: async (agentId: string, input: UpdateAgentInput) => { await client.updateAgent(agentId, input); await refreshAgents(); }, diff --git a/src/ui/App.test.tsx b/src/ui/App.test.tsx index 990edee..5591412 100644 --- a/src/ui/App.test.tsx +++ b/src/ui/App.test.tsx @@ -6,9 +6,23 @@ import { accountDisplayName } from "./accountDisplayName"; import { nextAgentName } from "@/domain/agentName"; import { AgentList } from "./components/AgentList"; import { LoginPage } from "./components/LoginPage"; -import type { DesktopBridge } from "@/shared/desktop"; +import { SettingsDialog } from "./components/Dialogs"; +import type { AppSettings, DesktopBridge } from "@/shared/desktop"; describe("Runta Crew authentication surfaces", () => { + it("opens the dashboard add-provider page when no providers exist", async () => { + const openExternal = vi.fn(async () => undefined); const user = userEvent.setup(); + window.runtaCrew = { + openExternal, + settings: { get: async () => ({ endpoint: "https://api.forge", dashboardUrl: "https://app.forge", theme: "light", notifications: true }), set: async (settings: AppSettings) => settings }, + } as unknown as DesktopBridge; + render( undefined} />); + await user.click(screen.getByRole("button", { name: "Model provider" })); + await user.click(screen.getByRole("option", { name: "Add model provider" })); + expect(openExternal).toHaveBeenCalledWith("https://app.forge/org/org-a%2Fb/secrets/providers/new"); + delete window.runtaCrew; + }); + it("adapts the landing copy to whether agents exist", () => { const { rerender } = render(); expect(screen.getByText("Create your first agent to get started.")).toBeInTheDocument(); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 26edd33..bbfc091 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -148,7 +148,7 @@ export function App() { {(crew.error || authError) &&
{crew.error || authError}
} {editingAgent && setEditingAgent(undefined)} onSave={(input) => crew.updateAgent(editingAgent.id, input)} />} {deletingAgent && setDeletingAgent(undefined)} onDelete={() => crew.deleteAgent(deletingAgent.id)} />} - {settingsOpen && { setSettingsOpen(false); void logout(); }} onClose={() => setSettingsOpen(false)} />} + {settingsOpen && { setSettingsOpen(false); void logout(); }} onClose={() => setSettingsOpen(false)} />} setPaletteOpen(false)} onSelectAgent={crew.setSelectedAgentId} onCreateAgent={() => void createDefaultAgent()} onSettings={() => setSettingsOpen(true)} onComputer={() => setDetailsOpen(true)} /> ; } diff --git a/src/ui/components/Dialogs.tsx b/src/ui/components/Dialogs.tsx index 8ba6972..5aa6bb4 100644 --- a/src/ui/components/Dialogs.tsx +++ b/src/ui/components/Dialogs.tsx @@ -15,7 +15,7 @@ export function DeleteAgentDialog({ agent, onClose, onDelete }: { agent: Agent; function remove() { onClose(); void onDelete().catch(() => undefined); } return

This permanently removes the cloud agent and its runtime data.

This action cannot be undone.
; } -export function SettingsDialog({ mode = "preferences", providers = [], accountName = "Runta account", accountEmail = "", onLogout, onClose }: { mode?: "connection" | "preferences"; providers?: ModelProviderOption[]; accountName?: string; accountEmail?: string; onLogout?(): void; onClose(): void }) { +export function SettingsDialog({ mode = "preferences", providers = [], organizationId = "", accountName = "Runta account", accountEmail = "", onLogout, onClose }: { mode?: "connection" | "preferences"; providers?: ModelProviderOption[]; organizationId?: string; accountName?: string; accountEmail?: string; onLogout?(): void; onClose(): void }) { const [settings, setSettings] = useState({ endpoint: DEFAULT_RUNTA_API_URL, dashboardUrl: DEFAULT_RUNTA_DASHBOARD_URL, theme: "light", notifications: true }); useEffect(() => { void window.runtaCrew?.settings.get().then(setSettings); }, []); async function save(event: FormEvent) { @@ -30,10 +30,15 @@ export function SettingsDialog({ mode = "preferences", providers = [], accountNa ; const initials = accountName.split(/\s+/).filter(Boolean).map((part) => part[0]).join("").slice(0, 2).toUpperCase(); const persist = (next: AppSettings) => { setSettings(next); void window.runtaCrew?.settings.set(next); document.documentElement.dataset.theme = next.theme === "system" ? window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" : next.theme; }; + const addModelProvider = () => { + if (!settings.dashboardUrl || !organizationId) return; + const url = new URL(`/org/${encodeURIComponent(organizationId)}/secrets/providers/new`, settings.dashboardUrl); + void window.runtaCrew?.openExternal(url.toString()); + }; return

Settings

Account

{initials}{accountName}{accountEmail && {accountEmail}}{onLogout && }
-

Agents

Model provider ({ value: provider.id, label: provider.name })) : [{ value: "add-model-provider", label: "Add model provider", action: addModelProvider }]} onChange={(modelProviderId) => persist({ ...settings, modelProviderId })} />

Appearance

Theme persist({ ...settings, notifications: event.target.checked })} />
; diff --git a/src/ui/components/Select.test.tsx b/src/ui/components/Select.test.tsx index 7c18811..39df5c4 100644 --- a/src/ui/components/Select.test.tsx +++ b/src/ui/components/Select.test.tsx @@ -21,4 +21,12 @@ describe("Select", () => { expect(onChange).toHaveBeenCalledWith("light"); expect(trigger).toHaveAttribute("aria-expanded", "true"); await user.keyboard("{Escape}"); expect(trigger).toHaveAttribute("aria-expanded", "false"); }); + + it("runs an action option without changing the value", async () => { + const onChange = vi.fn(); const action = vi.fn(); const user = userEvent.setup(); + render(); await user.click(screen.getByRole("button", { name: "Model provider" })); - await user.click(screen.getByRole("option", { name: "Add model provider" })); + const option = screen.getByRole("option", { name: "Add model provider" }); + expect(option).toHaveClass("crew-select-action"); + await user.click(option); expect(action).toHaveBeenCalledOnce(); expect(onChange).not.toHaveBeenCalled(); }); + + it("right-aligns a wider popover with its trigger", async () => { + const bounds = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ left: 100, right: 232, top: 50, bottom: 82, width: 132, height: 32, x: 100, y: 50, toJSON: () => undefined }); + const user = userEvent.setup(); + render( ({ value: provider.id, label: provider.name })) : [{ value: "add-model-provider", label: "Add model provider", action: addModelProvider }]} onChange={(modelProviderId) => persist({ ...settings, modelProviderId })} /> +

Agents

{providers.length + ?
Model provider persist({ ...settings, theme: theme as ThemePreference })} />
; diff --git a/src/ui/styles.css b/src/ui/styles.css index 9373e08..223eba9 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -51,7 +51,7 @@ code, pre, kbd, samp { font-family:var(--font-mono); } .dialog-backdrop{position:fixed;z-index:20;inset:0;display:grid;place-items:center;padding:30px;background:rgb(30 25 21/.28);backdrop-filter:blur(4px)}.dialog{width:min(520px,100%);max-height:calc(100vh - 60px);overflow:visible;border:1px solid var(--border);border-radius:10px;background:var(--surface-raised);box-shadow:var(--shadow);-webkit-app-region:no-drag}.dialog *{-webkit-app-region:no-drag}.dialog>header{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid var(--border)}.dialog>header .icon-button{position:relative;z-index:1;pointer-events:auto}.dialog h2{margin:0;font-size:14px}.dialog-lead{margin:18px 20px 0;color:var(--muted);font:12px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.dialog-form,.settings-form{display:flex;flex-direction:column;gap:16px;padding:20px}.dialog-form label,.settings-form label{display:flex;flex-direction:column;gap:7px;color:var(--muted);font-size:10px}.dialog-form input,.dialog-form textarea,.settings-form input,.settings-form select{width:100%;min-height:39px;padding:9px 11px;border:1px solid var(--border);border-radius:6px;outline:0;background:var(--surface)}.dialog-form input:focus,.dialog-form textarea:focus,.settings-form input:focus{border-color:var(--primary)}.dialog-form textarea{min-height:90px;resize:vertical}.dialog-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:3px}.settings-form section{display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border)}.settings-form h3{margin:0;font-size:11px}.settings-form small{color:var(--faint);font-size:8px}.settings-form label.checkbox{flex-direction:row;align-items:center}.checkbox input{width:auto;min-height:auto}.about-row{flex-direction:row!important;justify-content:space-between;align-items:center}.about-row p{margin:4px 0 0;color:var(--muted);font-size:9px}.about-row>span{color:var(--muted);font-size:9px}.saved-label{margin-right:auto;color:var(--green);font-size:10px}.compact-settings{gap:14px}.compact-settings .dialog-actions{margin-top:6px}.compact-settings label.checkbox{padding:4px 0} .dialog h2{font-size:16px;font-weight:500}.dialog-lead{font:13px/1.55 var(--font-sans)}.dialog-form label,.settings-form label{gap:8px;font-size:13px}.dialog-form input,.dialog-form textarea,.dialog-form select,.settings-form input,.settings-form select{min-height:42px;padding:10px 12px;font-size:14px}.dialog-form textarea{min-height:96px}.dialog-actions button{font-size:14px}.settings-form h3{font-size:14px;font-weight:500}.settings-form small{font-size:11px}.about-row p,.about-row>span{font-size:12px}.saved-label{font-size:12px} .delete-agent-copy{padding:20px;font:12px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.delete-agent-copy p{margin:0 0 10px;color:var(--muted)}.delete-agent-copy strong{color:var(--red);font-size:11px}.delete-actions{padding:0 20px 20px}.destructive-button{background:var(--red);color:white}.destructive-button:hover{background:color-mix(in srgb,var(--red) 84%,black)} -.settings-backdrop{padding:30px;background:rgb(0 0 0/.52)}.settings-window{width:min(660px,100%);max-height:calc(100vh - 60px);overflow:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface-raised);box-shadow:var(--shadow);-webkit-app-region:no-drag}.settings-window *{-webkit-app-region:no-drag}.settings-content{min-width:0;padding:0 24px 26px}.settings-content>header{display:flex;height:68px;align-items:center;justify-content:space-between}.settings-content>header .icon-button{pointer-events:auto}.settings-content h2{margin:0;font-size:15px}.settings-content>section{margin-bottom:24px}.settings-content>section:last-child{margin-bottom:0}.settings-content>section>h3{margin:0 0 9px 2px;color:var(--muted);font-size:10px;font-weight:400}.settings-card{border-radius:10px;background:var(--bg)}.account-card{display:flex;min-height:82px;align-items:center;padding:14px 16px}.settings-account-avatar{display:grid;flex:0 0 auto;width:42px;height:42px;place-items:center;border:1px solid var(--account-avatar-border);border-radius:50%;background:var(--account-avatar-bg);color:var(--account-avatar-fg);font-size:13px}.settings-account-copy{display:flex;min-width:0;flex-direction:column;gap:4px;margin-left:12px}.settings-account-copy strong{font-size:12px;font-weight:600}.settings-account-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:10px}.settings-signout{height:32px;margin-left:auto;padding:0 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);font-size:10px}.settings-row{display:flex!important;min-height:50px;align-items:center!important;justify-content:space-between;padding:0 14px!important;border-bottom:1px solid var(--border);color:var(--text)!important;font-size:11px!important}.settings-row:first-child{border-radius:10px 10px 0 0}.settings-row:last-child{border-bottom:0;border-radius:0 0 10px 10px}.settings-toggle{position:relative;width:34px!important;height:20px!important;min-height:0!important;appearance:none;border:0!important;border-radius:999px!important;background:var(--border)!important;cursor:pointer}.settings-toggle:after{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--surface);box-shadow:0 1px 3px rgb(0 0 0/.18);content:"";transition:transform .15s}.settings-toggle:checked{background:var(--text)!important}.settings-toggle:checked:after{transform:translateX(14px)} +.settings-backdrop{padding:30px;background:rgb(0 0 0/.52)}.settings-window{width:min(660px,100%);max-height:calc(100vh - 60px);overflow:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface-raised);box-shadow:var(--shadow);-webkit-app-region:no-drag}.settings-window *{-webkit-app-region:no-drag}.settings-content{min-width:0;padding:0 24px 26px}.settings-content>header{display:flex;height:68px;align-items:center;justify-content:space-between}.settings-content>header .icon-button{pointer-events:auto}.settings-content h2{margin:0;font-size:15px}.settings-content>section{margin-bottom:24px}.settings-content>section:last-child{margin-bottom:0}.settings-content>section>h3{margin:0 0 9px 2px;color:var(--muted);font-size:10px;font-weight:400}.settings-card{border-radius:10px;background:var(--bg)}.account-card{display:flex;min-height:82px;align-items:center;padding:14px 16px}.settings-account-avatar{display:grid;flex:0 0 auto;width:42px;height:42px;place-items:center;border:1px solid var(--account-avatar-border);border-radius:50%;background:var(--account-avatar-bg);color:var(--account-avatar-fg);font-size:13px}.settings-account-copy{display:flex;min-width:0;flex-direction:column;gap:4px;margin-left:12px}.settings-account-copy strong{font-size:12px;font-weight:600}.settings-account-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:10px}.settings-signout{height:32px;margin-left:auto;padding:0 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);font-size:10px}.settings-row{display:flex!important;min-height:50px;align-items:center!important;justify-content:space-between;padding:0 14px!important;border-bottom:1px solid var(--border);color:var(--text)!important;font-size:11px!important}.settings-row:first-child{border-radius:10px 10px 0 0}.settings-row:last-child{border-bottom:0;border-radius:0 0 10px 10px}.settings-provider-action{display:flex;height:32px;align-items:center;gap:6px;padding:0 8px 0 10px;border:0;border-radius:7px;background:var(--surface);color:var(--text);font-size:12px;font-weight:400}.settings-provider-action svg{color:var(--muted)}.settings-provider-action:hover{background:var(--interactive-hover)!important}.settings-toggle{position:relative;width:34px!important;height:20px!important;min-height:0!important;appearance:none;border:0!important;border-radius:999px!important;background:var(--border)!important;cursor:pointer}.settings-toggle:after{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--surface);box-shadow:0 1px 3px rgb(0 0 0/.18);content:"";transition:transform .15s}.settings-toggle:checked{background:var(--text)!important}.settings-toggle:checked:after{transform:translateX(14px)} .crew-select{position:relative;min-width:132px;color:var(--text);font-size:10px}.dialog-form .crew-select{width:100%;font-size:11px}.crew-select-trigger{position:relative;display:flex;width:100%;height:32px;align-items:center;padding:0 34px 0 10px;border:0;border-radius:7px;background:var(--surface);text-align:left}.dialog-form .crew-select-trigger{height:39px;border:1px solid var(--border)}.crew-select-trigger>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.crew-select-chevron{position:absolute;top:0;right:0;display:grid;width:32px;height:100%;place-items:center;color:var(--muted);pointer-events:none}.crew-select.open .crew-select-chevron svg{transform:rotate(180deg)}.crew-select-menu{position:absolute;z-index:60;top:calc(100% + 5px);right:0;min-width:max(100%,180px);max-height:220px;overflow-y:auto;padding:4px;border:1px solid var(--border);border-radius:9px;background:var(--surface-raised);box-shadow:var(--shadow)}.crew-select-menu-portal{right:auto;min-width:0}.crew-select-menu button{display:grid;width:100%;height:32px;grid-template-columns:20px minmax(0,1fr);align-items:center;padding:0 7px;border:0;border-radius:6px;background:transparent;color:var(--text);font-size:11px;text-align:left}.crew-select-menu button.crew-select-action{display:block;padding:0 9px}.crew-select-menu button>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.crew-select-menu button:hover,.crew-select-menu button[aria-selected="true"]{background:var(--interactive-hover)!important}.crew-select-check{display:grid;width:16px;place-items:center}.crew-select-menu button:disabled{opacity:.45} .palette-backdrop{position:fixed;z-index:40;inset:0;display:flex;align-items:flex-start;justify-content:center;padding-top:min(16vh,140px);background:rgb(30 25 21/.24);backdrop-filter:blur(3px)}.command-palette{width:min(620px,calc(100vw - 48px));overflow:hidden;border:1px solid var(--border);border-radius:10px;background:var(--surface-raised);box-shadow:0 24px 70px rgb(46 34 24/.2)}.command-palette>label{display:flex;align-items:center;gap:10px;height:54px;padding:0 14px;border-bottom:1px solid var(--border);color:var(--muted)}.command-palette>label input{flex:1;border:0;outline:0;background:transparent;font-size:12px}.palette-results{max-height:360px;overflow:auto;padding:7px}.palette-results button{display:flex;align-items:center;gap:11px;width:100%;padding:9px;border:0;border-radius:6px;background:transparent;text-align:left}.palette-results button:hover{background:var(--interactive-hover)}.palette-results button.active{background:var(--interactive-selected)}.palette-results button>span{display:grid;width:31px;height:31px;place-items:center;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--muted)}.palette-results button>div{display:flex;flex:1;flex-direction:column;gap:4px}.palette-results strong{font-size:10px}.palette-results small{color:var(--muted);font-size:8px;text-transform:capitalize}.palette-results>p{padding:26px;text-align:center;color:var(--muted);font-size:10px}.command-palette footer{display:flex;justify-content:space-between;padding:9px 12px;border-top:1px solid var(--border);color:var(--faint);font-size:8px}.command-palette footer>span:last-child{display:flex;align-items:center;gap:3px} .error-toast{position:fixed;z-index:30;right:20px;bottom:20px;display:flex;align-items:center;gap:10px;max-width:430px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--red) 30%,var(--border));border-radius:7px;background:var(--surface);box-shadow:var(--shadow);font-size:10px}.error-toast svg{color:var(--red)}.error-toast button{margin-left:8px;border:0;background:transparent;color:var(--primary);font-size:9px}.loading-screen,.empty-state{display:flex;height:100%;align-items:center;justify-content:center;gap:18px;color:var(--muted);font-size:11px}.loading-logo{width:34px;height:34px;object-fit:contain} From 85ce58672b0c213c579b4efe22472289fe9966e2 Mon Sep 17 00:00:00 2001 From: Shiqi Mei Date: Tue, 1 Sep 2026 17:16:48 +0800 Subject: [PATCH 05/78] feat: poll for newly added model providers --- src/state/useCrewController.test.tsx | 12 ++++++++++++ src/state/useCrewController.ts | 11 ++++++++--- src/ui/App.tsx | 21 +++++++++++++++++++-- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/state/useCrewController.test.tsx b/src/state/useCrewController.test.tsx index 1addb45..63c8de9 100644 --- a/src/state/useCrewController.test.tsx +++ b/src/state/useCrewController.test.tsx @@ -7,6 +7,18 @@ import { useCrewController } from "./useCrewController"; const agent = (id: string, name: string): Agent => ({ id, name, role: "Cloud coding agent", goal: name, status: "idle", avatar: name[0]!, lastActiveAt: new Date(0).toISOString(), unreadCount: 0, computerId: id }); describe("useCrewController", () => { + it("refreshes the model-provider catalog on demand", async () => { + let providers = [] as Awaited>["providers"]; + const listModelProviders = vi.fn(async () => ({ organizationId: "org-test", providers })); + const client = { listModelProviders, listAgents: async () => [] } as unknown as CloudAgentsClient; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + providers = [{ id: "provider-1", name: "OpenAI", protocol: "openai_responses" }]; + await act(async () => { await result.current.refreshModelProviders(); }); + expect(result.current.modelProviders).toEqual(providers); + expect(listModelProviders).toHaveBeenCalledTimes(2); + }); + 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; diff --git a/src/state/useCrewController.ts b/src/state/useCrewController.ts index 85cb81f..49c0d4c 100644 --- a/src/state/useCrewController.ts +++ b/src/state/useCrewController.ts @@ -55,12 +55,17 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { })); setSelectedAgentId((current) => current && next.some((agent) => agent.id === current) ? current : next[0]?.id || ""); return next; }, [client]); + const refreshModelProviders = useCallback(async () => { + const catalog = await client.listModelProviders(); + setModelProviders(catalog.providers); setOrganizationId(catalog.organizationId); + return catalog.providers; + }, [client]); useEffect(() => { if (!enabled) { setAgents([]); setModelProviders([]); setOrganizationId(""); setSelectedAgentId(""); setMessages([]); setActivities([]); setApprovals([]); setComputer(undefined); setConnection("disconnected"); setLoading(false); setError(undefined); return; } let alive = true; setLoading(true); - void Promise.all([refreshAgents(), client.listModelProviders()]).then(([, catalog]) => { if (alive) { setModelProviders(catalog.providers); setOrganizationId(catalog.organizationId); setConnection("connected"); setLoading(false); } }).catch((reason: unknown) => { if (alive) { setConnection("error"); setError(reason instanceof Error ? reason.message : "Could not load agents"); setLoading(false); } }); + void Promise.all([refreshAgents(), refreshModelProviders()]).then(() => { if (alive) { setConnection("connected"); setLoading(false); } }).catch((reason: unknown) => { if (alive) { setConnection("error"); setError(reason instanceof Error ? reason.message : "Could not load agents"); setLoading(false); } }); return () => { alive = false; }; - }, [client, enabled, refreshAgents]); + }, [enabled, refreshAgents, refreshModelProviders]); useEffect(() => { if (!enabled) return; const refreshWhenActive = () => { @@ -158,7 +163,7 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { }, [client, conversationId, enabled, liveAgentId, selectedAgent?.name, selectedAgentId]); return { - agents, modelProviders, organizationId, selectedAgent, selectedAgentId, setSelectedAgentId, messages, activities, approvals, computer, connection, loading, conversationLoading, error, + agents, modelProviders, organizationId, refreshModelProviders, selectedAgent, selectedAgentId, setSelectedAgentId, messages, activities, approvals, computer, connection, loading, conversationLoading, error, dismissError: () => setError(undefined), createAgent: async (input: CreateAgentInput) => { const agent = await client.createAgent(input); snapshots.current.set(agent.id, { messages: [], approvals: [], cachedAt: 0 }); setLoadedAgentIds((current) => new Set(current).add(agent.id)); await refreshAgents(); setSelectedAgentId(agent.id); return agent; }, updateAgent: async (agentId: string, input: UpdateAgentInput) => { await client.updateAgent(agentId, input); await refreshAgents(); }, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index bbfc091..6bbdc0a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -30,8 +30,9 @@ export function App() { const forceLoading = import.meta.env.DEV && new URLSearchParams(window.location.search).get("loading") === "1"; const [authReady, setAuthReady] = useState(false); const [signedIn, setSignedIn] = useState(false); const [authPending, setAuthPending] = useState(false); const [authError, setAuthError] = useState(); const [userName, setUserName] = useState(""); const [userEmail, setUserEmail] = useState(""); const [client, setClient] = useState(() => 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 [creatingAgentName, setCreatingAgentName] = useState(); const [creatingAgentBaselineIds, setCreatingAgentBaselineIds] = useState>(() => new Set()); const [composerFocusRequest, setComposerFocusRequest] = useState(0); const [settingsOpen, setSettingsOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [editingAgent, setEditingAgent] = useState(); const [deletingAgent, setDeletingAgent] = useState(); + const [search, setSearch] = useState(""); const [detailsOpen, setDetailsOpen] = useState(false); const [detailsMounted, setDetailsMounted] = useState(false); const [creatingAgentName, setCreatingAgentName] = useState(); const [creatingAgentBaselineIds, setCreatingAgentBaselineIds] = useState>(() => 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(); const [deletingAgent, setDeletingAgent] = useState(); 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 : ""; @@ -109,10 +110,26 @@ export function App() { const timer = window.setTimeout(() => setDetailsMounted(false), 220); return () => window.clearTimeout(timer); }, [detailsOpen]); + useEffect(() => { + if (!settingsOpen || !providerPolling) return; + let stopped = false; let timer: number | undefined; + const poll = async () => { + try { + const providers = await refreshModelProviders(); + if (stopped) return; + if (providers.length) { setProviderPolling(false); 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]); 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)); @@ -148,7 +165,7 @@ export function App() { {(crew.error || authError) &&
{crew.error || authError}
} {editingAgent && setEditingAgent(undefined)} onSave={(input) => crew.updateAgent(editingAgent.id, input)} />} {deletingAgent && setDeletingAgent(undefined)} onDelete={() => crew.deleteAgent(deletingAgent.id)} />} - {settingsOpen && { setSettingsOpen(false); void logout(); }} onClose={() => setSettingsOpen(false)} />} + {settingsOpen && { setProviderPolling(false); setSettingsOpen(false); void logout(); }} onClose={() => { setProviderPolling(false); setSettingsOpen(false); }} />} setPaletteOpen(false)} onSelectAgent={crew.setSelectedAgentId} onCreateAgent={() => void createDefaultAgent()} onSettings={() => setSettingsOpen(true)} onComputer={() => setDetailsOpen(true)} /> ; } From f075b3d0b7f6b980d971033ca6030e907d9b0352 Mon Sep 17 00:00:00 2001 From: Shiqi Mei Date: Thu, 3 Sep 2026 12:03:40 +0800 Subject: [PATCH 06/78] fix: migrate public API endpoints to v2 --- electron/main/index.ts | 26 +++++++++---------- scripts/e2e.mjs | 14 +++++----- .../http/RuntaCloudAgentsClient.test.ts | 18 ++++++------- src/clients/http/RuntaCloudAgentsClient.ts | 24 ++++++++--------- src/ui/App.tsx | 7 +++-- 5 files changed, 43 insertions(+), 46 deletions(-) diff --git a/electron/main/index.ts b/electron/main/index.ts index f263ce8..3c44aa4 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -130,7 +130,7 @@ ipcMain.handle("auth:logout", async () => { if (!settings.endpoint) throw new Error("Runta API endpoint is not configured"); const token = safeStorage.decryptString(readFileSync(credentialFile())); const apiBase = `${settings.endpoint.replace(/\/+$/, "")}/`; - const response = await net.fetch(new URL("v1/auth/token", apiBase).toString(), { method: "DELETE", headers: { authorization: `Bearer ${token}` } }); + const response = await net.fetch(new URL("v2/auth/token", apiBase).toString(), { method: "DELETE", headers: { authorization: `Bearer ${token}` } }); if (!response.ok && response.status !== 401) throw new Error(`Runta key revocation failed (${response.status})`); if (existsSync(credentialFile())) rmSync(credentialFile()); authorizationStatus = "idle"; @@ -138,31 +138,31 @@ ipcMain.handle("auth:logout", async () => { }); ipcMain.handle("auth:start", async () => { if (!settings.endpoint || !settings.dashboardUrl) throw new Error("API and Dashboard URLs are required"); - const dashboardUrl = settings.dashboardUrl; - const response = await net.fetch(deviceAuthorizationUrl(dashboardUrl), { + const apiBase = `${settings.endpoint.replace(/\/+$/, "")}/`; + const response = await net.fetch(new URL("v2/auth/device/authorization", apiBase).toString(), { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(deviceAuthorizationRequest(`Runta Crew on ${process.platform}`)), + body: JSON.stringify({ client_id: "runta_crew", device_name: `Runta Crew on ${process.platform}`, app_url: settings.dashboardUrl.replace(/\/+$/, "") }), }); if (!response.ok) throw new Error(`Device authorization failed (${response.status})`); - const authorization = await response.json() as { deviceCode: string; userCode: string; verificationUriComplete: string; expiresAt: string; interval: number }; + const envelope = await response.json() as { data: { device_code: string; user_code: string; verification_uri_complete: string; expires_at: string; interval: number } }; authorizationStatus = "pending"; const poll = async () => { - let interval = Math.max(5, authorization.interval || 5); - const expiresAt = Date.parse(authorization.expiresAt); + let interval = Math.max(5, envelope.data.interval || 5); + const expiresAt = Date.parse(envelope.data.expires_at); while (authorizationStatus === "pending") { await new Promise((resolve) => setTimeout(resolve, interval * 1000)); if (Number.isFinite(expiresAt) && Date.now() >= expiresAt) { authorizationStatus = "expired"; return; } let tokenResponse: Response; try { - tokenResponse = await net.fetch(deviceTokenUrl(dashboardUrl), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(deviceTokenRequest(authorization.deviceCode)) }); + tokenResponse = await net.fetch(new URL("v2/auth/device/token", apiBase).toString(), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ device_code: envelope.data.device_code }) }); } catch { continue; } if (tokenResponse.ok) { - const token = await tokenResponse.json() as { accessToken: string }; + const token = await tokenResponse.json() as { access_token: string }; if (!safeStorage.isEncryptionAvailable()) { authorizationStatus = "error"; return; } - writeFileSync(credentialFile(), safeStorage.encryptString(token.accessToken), { mode: 0o600 }); + writeFileSync(credentialFile(), safeStorage.encryptString(token.access_token), { mode: 0o600 }); authorizationStatus = "authorized"; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); mainWindow.focus(); } if (process.platform === "darwin") app.focus({ steal: true }); @@ -176,8 +176,8 @@ ipcMain.handle("auth:start", async () => { } }; void poll(); - await shell.openExternal(authorization.verificationUriComplete); - return { verificationUrl: authorization.verificationUriComplete, userCode: authorization.userCode, expiresAt: authorization.expiresAt }; + await shell.openExternal(envelope.data.verification_uri_complete); + return { verificationUrl: envelope.data.verification_uri_complete, userCode: envelope.data.user_code, expiresAt: envelope.data.expires_at }; }); ipcMain.handle("cloud:request", async (_event, request: CloudRequest) => { if (!settings.endpoint) throw new Error("Runta API endpoint is not configured"); @@ -187,7 +187,7 @@ ipcMain.handle("cloud:request", async (_event, request: CloudRequest) => { const token = safeStorage.decryptString(encrypted); const endpoint = new URL(`${settings.endpoint.replace(/\/+$/, "")}/`); const url = new URL(request.path.replace(/^\/+/, ""), endpoint); - const apiPrefix = `${endpoint.pathname.replace(/\/+$/, "")}/v1/`; + const apiPrefix = `${endpoint.pathname.replace(/\/+$/, "")}/v2/`; if (url.origin !== endpoint.origin || !url.pathname.startsWith(apiPrefix)) throw new Error("Cloud request path is not allowed"); const response = await net.fetch(url.toString(), { method: request.method, diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 12a67c7..3bb5a84 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -15,23 +15,21 @@ async function request(path, init = {}) { return body; } -const profile = await request("/v1/me"); -assert.equal(typeof profile.data?.user_id, "string", "user-authorized device token must expose /v1/me"); - -const providers = await request("/v1/model-providers"); +const providers = await request("/v2/model-providers"); +assert.equal(typeof providers.organization_id, "string", "user-authorized device token must expose an organization"); const provider = providers.model_providers?.[0]; assert.equal(typeof provider?.id, "string", "an E2E model provider is required"); const suffix = Date.now().toString(36); let agent; try { - agent = await request("/v1/agents", { + agent = await request("/v2/agents", { method: "POST", body: JSON.stringify({ name: `runta-crew-e2e-${suffix}`, model_provider: { type: "managed", id: provider.id } }), }); assert.equal(typeof agent.id, "string"); - const run = await request(`/v1/agents/${encodeURIComponent(agent.id)}/runs`, { + const run = await request(`/v2/agents/${encodeURIComponent(agent.id)}/runs`, { method: "POST", body: JSON.stringify({ prompt: "Reply with RUNTA_CREW_E2E_OK." }), }); @@ -40,7 +38,7 @@ try { const deadline = Date.now() + 180_000; let completed; while (Date.now() < deadline) { - completed = await request(`/v1/agents/${encodeURIComponent(agent.id)}/runs/${encodeURIComponent(run.id)}`); + completed = await request(`/v2/agents/${encodeURIComponent(agent.id)}/runs/${encodeURIComponent(run.id)}`); if (["finished", "failed", "cancelled"].includes(completed.status)) break; await new Promise((resolve) => setTimeout(resolve, 2_000)); } @@ -48,5 +46,5 @@ try { assert.match(completed.result || "", /RUNTA_CREW_E2E_OK/); console.log(`Runta Crew E2E passed for agent ${agent.id}`); } finally { - if (agent?.id) await request(`/v1/agents/${encodeURIComponent(agent.id)}?delete_runtime=true`, { method: "DELETE" }); + if (agent?.id) await request(`/v2/agents/${encodeURIComponent(agent.id)}?delete_runtime=true`, { method: "DELETE" }); } diff --git a/src/clients/http/RuntaCloudAgentsClient.test.ts b/src/clients/http/RuntaCloudAgentsClient.test.ts index 549c42f..b1a2463 100644 --- a/src/clients/http/RuntaCloudAgentsClient.test.ts +++ b/src/clients/http/RuntaCloudAgentsClient.test.ts @@ -14,14 +14,14 @@ describe("RuntaCloudAgentsClient", () => { it("maps the current Cloud Agents envelope and uses the managed provider for creation", async () => { const request = vi.fn(async ({ method, path }: CloudRequest) => { - if (path.startsWith("/v1/agents?")) return { status: 200, body: { agents: [{ id: "agent-1", runtime_id: "agent-1", name: "Builder", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 2, latest_reply: { run_id: "run-1", text: "Latest agent reply", created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" } }] } }; - if (path === "/v1/model-providers") return { status: 200, body: { organization_id: "org-1", model_providers: [{ id: "provider-1", display_name: "Kimi", protocol: "openai_responses", default_model: "k3" }] } }; - if (method === "GET" && path === "/v1/agents/agent-1/runs?limit=100") return { status: 200, body: [ + if (path.startsWith("/v2/agents?")) return { status: 200, body: { agents: [{ id: "agent-1", runtime_id: "agent-1", name: "Builder", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 2, latest_reply: { run_id: "run-1", text: "Latest agent reply", created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" } }] } }; + if (path === "/v2/model-providers") return { status: 200, body: { organization_id: "org-1", model_providers: [{ id: "provider-1", display_name: "Kimi", protocol: "openai_responses", default_model: "k3" }] } }; + if (method === "GET" && path === "/v2/agents/agent-1/runs?limit=100") return { status: 200, body: [ { id: "run-2", agent_id: "agent-1", status: "failed", prompt: "Break it", result: null, error: "Tool failed", created_at: "2026-08-26T02:00:00Z", updated_at: "2026-08-26T02:00:01Z" }, { id: "run-1", agent_id: "agent-1", status: "finished", prompt: "Build it", result: "Done", error: null, created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" }, ] }; - if (method === "POST" && path === "/v1/agents") return { status: 201, body: { id: "agent-2", runtime_id: "agent-2", name: "Reviewer", status: "pending", created_at_unix_seconds: 3, updated_at_unix_seconds: 3 } }; - if (method === "PATCH" && path === "/v1/agents/agent-1") return { status: 200, body: { id: "agent-1", runtime_id: "agent-1", name: "Atlas", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 4 } }; + if (method === "POST" && path === "/v2/agents") return { status: 201, body: { id: "agent-2", runtime_id: "agent-2", name: "Reviewer", status: "pending", created_at_unix_seconds: 3, updated_at_unix_seconds: 3 } }; + if (method === "PATCH" && path === "/v2/agents/agent-1") return { status: 200, body: { id: "agent-1", runtime_id: "agent-1", name: "Atlas", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 4 } }; return { status: 404 }; }); const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { queueMicrotask(() => listener({ event: "stream.closed" })); return () => undefined; }; @@ -37,8 +37,8 @@ describe("RuntaCloudAgentsClient", () => { ]); expect(await client.createAgent({ name: "Reviewer", modelProviderId: "provider-1" })).toEqual(expect.objectContaining({ id: "agent-2", status: "working" })); expect(await client.updateAgent("agent-1", { name: "Atlas" })).toEqual(expect.objectContaining({ id: "agent-1", name: "Atlas" })); - expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/v1/agents", body: expect.objectContaining({ model_provider: { type: "managed", id: "provider-1" } }) })); - expect(request).toHaveBeenCalledWith({ method: "PATCH", path: "/v1/agents/agent-1", body: { name: "Atlas" } }); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/v2/agents", body: expect.objectContaining({ model_provider: { type: "managed", id: "provider-1" } }) })); + expect(request).toHaveBeenCalledWith({ method: "PATCH", path: "/v2/agents/agent-1", body: { name: "Atlas" } }); }); it("translates the authenticated run SSE stream without exposing credentials", async () => { @@ -48,7 +48,7 @@ describe("RuntaCloudAgentsClient", () => { window.runtaCrew = { cloud: { request, subscribe }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; const events: ConversationEvent[] = []; const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => events.push(event)); - await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v1/agents/agent-1/runs/run-1/events?after=-1", expect.any(Function))); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v2/agents/agent-1/runs/run-1/events?after=-1", expect.any(Function))); streamListener?.({ event: "acp.event", id: "4", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { text: "Checking." } } } } }); streamListener?.({ event: "acp.event", id: "5", data: { params: { update: { sessionUpdate: "tool_call", toolCallId: "tool-1", title: "Read", kind: "read", status: "in_progress" } } } }); streamListener?.({ event: "acp.event", id: "6", data: { params: { update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", title: "Read", kind: "read", status: "completed" } } } }); @@ -77,7 +77,7 @@ describe("RuntaCloudAgentsClient", () => { const subscription = client.subscribeToConversationEvents("conversation-agent-1", () => undefined); await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); await client.sendMessage({ conversationId: "conversation-agent-1", text: "Start" }); - await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v1/agents/agent-1/runs/run-new/events?after=-1", expect.any(Function))); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v2/agents/agent-1/runs/run-new/events?after=-1", expect.any(Function))); subscription.unsubscribe(); }); diff --git a/src/clients/http/RuntaCloudAgentsClient.ts b/src/clients/http/RuntaCloudAgentsClient.ts index f723221..68f88f1 100644 --- a/src/clients/http/RuntaCloudAgentsClient.ts +++ b/src/clients/http/RuntaCloudAgentsClient.ts @@ -97,33 +97,33 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { async listModelProviders(_signal?: AbortSignal): Promise { void _signal; - const response = await this.request<{ organization_id: string; model_providers: ModelProvider[] }>({ method: "GET", path: "/v1/model-providers" }); + const response = await this.request<{ organization_id: string; model_providers: ModelProvider[] }>({ method: "GET", path: "/v2/model-providers" }); return { organizationId: response.organization_id, providers: response.model_providers.map((provider) => ({ id: provider.id, name: provider.display_name, protocol: provider.protocol, defaultModel: provider.default_model ?? undefined })) }; } async listAgents(_signal?: AbortSignal) { void _signal; - const response = await this.request<{ agents: RuntaAgent[] }>({ method: "GET", path: "/v1/agents?limit=250&include_latest_reply=true" }); + const response = await this.request<{ agents: RuntaAgent[] }>({ method: "GET", path: "/v2/agents?limit=250&include_latest_reply=true" }); return response.agents.map((agent) => this.mapAgent(agent)); } - async getAgent(agentId: string, _signal?: AbortSignal) { void _signal; return this.mapAgent(await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}` })); } + async getAgent(agentId: string, _signal?: AbortSignal) { void _signal; return this.mapAgent(await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}` })); } async createAgent(input: CreateAgentInput, _signal?: AbortSignal) { void _signal; if (!input.modelProviderId) throw new CrewError("contract_pending", "Select a managed model provider before creating a Crew agent"); - const created = await this.request({ method: "POST", path: "/v1/agents", body: { name: input.name, model_provider: { type: "managed", id: input.modelProviderId } } }); + const created = await this.request({ method: "POST", path: "/v2/agents", body: { name: input.name, model_provider: { type: "managed", id: input.modelProviderId } } }); return this.mapAgent(created); } async updateAgent(agentId: string, input: UpdateAgentInput, _signal?: AbortSignal): Promise { void _signal; if (!input.name || Object.keys(input).some((key) => key !== "name")) throw new CrewError("contract_pending", "Only the Agent name can be updated"); - return this.mapAgent(await this.request({ method: "PATCH", path: `/v1/agents/${encodeURIComponent(agentId)}`, body: { name: input.name } })); + return this.mapAgent(await this.request({ method: "PATCH", path: `/v2/agents/${encodeURIComponent(agentId)}`, body: { name: input.name } })); } - async deleteAgent(agentId: string, _signal?: AbortSignal) { void _signal; await this.request({ method: "DELETE", path: `/v1/agents/${encodeURIComponent(agentId)}?delete_runtime=true` }); } + async deleteAgent(agentId: string, _signal?: AbortSignal) { void _signal; await this.request({ method: "DELETE", path: `/v2/agents/${encodeURIComponent(agentId)}?delete_runtime=true` }); } async duplicateAgent(_agentId: string, _signal?: AbortSignal): Promise { void _agentId; void _signal; throw new CrewError("contract_pending", "Agent duplication requires the Cloud Agents duplication contract"); } async setAgentUnread(agentId: string, _unread: boolean, signal?: AbortSignal) { return this.getAgent(agentId, signal); } async listConversations(agentId: string, _signal?: AbortSignal) { void _signal; - const runs = await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + const runs = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); const latest = runs[0]; return [{ id: conversationId(agentId), agentId, title: "Agent conversation", updatedAt: latest?.updated_at ?? new Date().toISOString() }]; } @@ -151,7 +151,7 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { }; const timeout = window.setTimeout(finish, 10_000); signal?.addEventListener("abort", finish, { once: true }); - unsubscribe = bridge.subscribe(`/v1/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event) => { + unsubscribe = bridge.subscribe(`/v2/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event) => { if (event.event === "stream.closed" || event.event === "error") { finish(); return; } if (event.event === "acp.event" && event.data && typeof event.data === "object") { const payload = event.data as { params?: { update?: { sessionUpdate?: string } } }; @@ -179,7 +179,7 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { } async getConversation(id: string, _signal?: AbortSignal) { const agentId = agentIdFromConversation(id); - const runs = await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + const runs = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); const messageGroups = await mapWithConcurrency(runs.slice().reverse(), 8, (run) => this.replayRunMessages(agentId, run, id, _signal)); const messages = messageGroups.flat(); return { conversation: { id, agentId, title: "Agent conversation", updatedAt: runs[0]?.updated_at ?? new Date().toISOString() }, messages }; @@ -187,7 +187,7 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { async sendMessage(input: SendMessageInput): Promise { if (input.attachments?.length) throw new CrewError("contract_pending", "Cloud attachment upload is not available yet"); const agentId = agentIdFromConversation(input.conversationId); - const run = await this.request({ method: "POST", path: `/v1/agents/${encodeURIComponent(agentId)}/runs`, body: { prompt: input.text } }); + const run = await this.request({ method: "POST", path: `/v2/agents/${encodeURIComponent(agentId)}/runs`, body: { prompt: input.text } }); for (const refresh of this.conversationRefreshListeners.get(input.conversationId) ?? []) refresh(); return { id: `${run.id}:user`, conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date().toISOString() }; } @@ -207,7 +207,7 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { listener({ type: "message.completed", messageId: assistant.current, notify }); assistant.current = undefined; }; - const unsubscribe = bridge.subscribe(`/v1/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event: CloudStreamEvent) => { + const unsubscribe = bridge.subscribe(`/v2/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event: CloudStreamEvent) => { if (event.event === "run.status" && event.data && typeof event.data === "object") { const data = event.data as Partial & { session_id?: string | null }; if (typeof data.status !== "string") return; @@ -249,7 +249,7 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { if (polling) { pollAgain = true; return; } polling = true; try { - const runs = await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + const runs = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); for (const run of runs.slice().reverse()) { const signature = `${run.status}\u0000${run.result ?? ""}\u0000${run.error ?? ""}`; const previous = observed.get(run.id); observed.set(run.id, signature); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6bbdc0a..d8540a0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -43,14 +43,13 @@ export function App() { async function connectAuthenticatedAccount() { let timeout: number | undefined; const response = await Promise.race([ - window.runtaCrew?.cloud?.request({ method: "GET", path: "/v1/me" }).catch(() => undefined), + window.runtaCrew?.cloud?.request({ method: "GET", path: "/v2/model-providers" }).catch(() => undefined), new Promise((resolve) => { timeout = window.setTimeout(() => resolve(undefined), 5_000); }), ]).finally(() => { if (timeout !== undefined) window.clearTimeout(timeout); }); if (!response) { setSignedIn(false); setUserName(""); setAuthReady(true); setAuthError("Runta session validation did not return a response."); return false; } if (response.status !== 200) { setSignedIn(false); setUserName(""); setAuthReady(true); setAuthError(`Runta session validation failed (${response.status}).`); return false; } - const profile = response.body as { data?: { display_name?: string | null; email?: string } } | undefined; - setUserName(accountDisplayName(profile?.data)); - setUserEmail(profile?.data?.email ?? ""); + setUserName(accountDisplayName()); + setUserEmail(""); setClient(new RuntaCloudAgentsClient()); setSignedIn(true); setAuthReady(true); setAuthError(undefined); return true; From 083e26cfc0419f15edcf5148df523f56f2f169e0 Mon Sep 17 00:00:00 2001 From: Shiqi Mei Date: Thu, 3 Sep 2026 12:31:00 +0800 Subject: [PATCH 07/78] fix: restore v2 device profile lookup --- scripts/e2e.mjs | 4 +++- src/ui/App.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 3bb5a84..7f8811c 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -15,8 +15,10 @@ async function request(path, init = {}) { return body; } +const profile = await request("/v2/me"); +assert.equal(typeof profile.data?.user_id, "string", "user-authorized device token must expose /v2/me"); + const providers = await request("/v2/model-providers"); -assert.equal(typeof providers.organization_id, "string", "user-authorized device token must expose an organization"); const provider = providers.model_providers?.[0]; assert.equal(typeof provider?.id, "string", "an E2E model provider is required"); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d8540a0..ffbf07d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -43,13 +43,14 @@ export function App() { async function connectAuthenticatedAccount() { let timeout: number | undefined; const response = await Promise.race([ - window.runtaCrew?.cloud?.request({ method: "GET", path: "/v2/model-providers" }).catch(() => undefined), + window.runtaCrew?.cloud?.request({ method: "GET", path: "/v2/me" }).catch(() => undefined), new Promise((resolve) => { timeout = window.setTimeout(() => resolve(undefined), 5_000); }), ]).finally(() => { if (timeout !== undefined) window.clearTimeout(timeout); }); if (!response) { setSignedIn(false); setUserName(""); setAuthReady(true); setAuthError("Runta session validation did not return a response."); return false; } if (response.status !== 200) { setSignedIn(false); setUserName(""); setAuthReady(true); setAuthError(`Runta session validation failed (${response.status}).`); return false; } - setUserName(accountDisplayName()); - setUserEmail(""); + const profile = response.body as { data?: { display_name?: string | null; email?: string } } | undefined; + setUserName(accountDisplayName(profile?.data)); + setUserEmail(profile?.data?.email ?? ""); setClient(new RuntaCloudAgentsClient()); setSignedIn(true); setAuthReady(true); setAuthError(undefined); return true; From 6dd53912519148ebaa4b94c0741698dcbfc5ec58 Mon Sep 17 00:00:00 2001 From: Shiqi Mei Date: Thu, 3 Sep 2026 16:33:55 +0800 Subject: [PATCH 08/78] fix: preserve IME composition in composer --- src/ui/components/Conversation.test.tsx | 23 +++++++++++++++++++++++ src/ui/components/Conversation.tsx | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/ui/components/Conversation.test.tsx b/src/ui/components/Conversation.test.tsx index ae05cc7..0f84cc0 100644 --- a/src/ui/components/Conversation.test.tsx +++ b/src/ui/components/Conversation.test.tsx @@ -108,6 +108,29 @@ describe("Conversation states", () => { expect(composer).toHaveFocus(); }); + it("does not submit when Enter confirms an IME composition", () => { + const onSend = vi.fn(async () => undefined); + render( undefined} />); + const composer = screen.getByRole("textbox", { name: "Message Atlas" }); + fireEvent.change(composer, { target: { value: "你好" } }); + + fireEvent.keyDown(composer, { key: "Enter", isComposing: true }); + fireEvent.keyDown(composer, { key: "Enter", keyCode: 229 }); + + expect(onSend).not.toHaveBeenCalled(); + expect(composer).toHaveValue("你好"); + }); + + it("submits with Enter after IME composition ends", () => { + const onSend = vi.fn(async () => undefined); + render( undefined} />); + const composer = screen.getByRole("textbox", { name: "Message Atlas" }); + fireEvent.change(composer, { target: { value: "你好" } }); + fireEvent.keyDown(composer, { key: "Enter", isComposing: false, keyCode: 13 }); + + expect(onSend).toHaveBeenCalledWith("你好", []); + }); + it("shows the animated agent avatar before the first token arrives", () => { const message: Message = { id: "run-1:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "" }], createdAt: new Date().toISOString(), streaming: true }; const { container } = render(); diff --git a/src/ui/components/Conversation.tsx b/src/ui/components/Conversation.tsx index 0cb57ce..b5a2fd9 100644 --- a/src/ui/components/Conversation.tsx +++ b/src/ui/components/Conversation.tsx @@ -85,6 +85,6 @@ export function Conversation({ agent, messages, activities, loading = false, foc return

{agent.name}

{loading ?
: <>
{ if (event.deltaY < 0) { pinnedToBottomRef.current = false; programmaticScrollRef.current = false; setShowScrollToBottom(true); } }} onScroll={(event) => { const element = event.currentTarget; const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight <= 48; const scrollingUp = element.scrollTop < lastScrollTopRef.current - 1; lastScrollTopRef.current = element.scrollTop; setIsScrolled(element.scrollTop > 0); if (scrollingUp) { programmaticScrollRef.current = false; pinnedToBottomRef.current = false; setShowScrollToBottom(true); } else if (!programmaticScrollRef.current) { pinnedToBottomRef.current = atBottom; setShowScrollToBottom(!atBottom); } revealScrollbarBriefly(); }} onPointerMove={(event) => { const bounds = event.currentTarget.getBoundingClientRect(); if (bounds.right - event.clientX <= 14) setScrollbarVisible(true); else if (!scrollHideTimer.current) setScrollbarVisible(false); }} onPointerLeave={() => setScrollbarVisible(false)}>
{messages.length === 0 &&

{agent.name}

}{messages.map((message) => )}
{showScrollToBottom && }}
-