diff --git a/.env.example b/.env.example
index 81f55dde1..3f6ac84bb 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,9 @@ DATABASE_URL=postgres://localhost:5432/workbench
# Hub origin the web dev server proxies /api to.
BASE_URL=http://localhost:3000
# PORT=3000
+# Origin sign-in trusts. Set it to the hub origin whenever the hub is not on
+# :3000, or sign-up fails with "Invalid origin".
+# BETTER_AUTH_BASE_URL=http://localhost:3000
# Durable hub state (git repos and assets). The default sits inside this
# work tree, which needs the opt-in below. Tests never use this path.
diff --git a/apps/web/src/onboarding/onboarding-layout.tsx b/apps/web/src/onboarding/onboarding-layout.tsx
index 40f036ffc..212593b9f 100644
--- a/apps/web/src/onboarding/onboarding-layout.tsx
+++ b/apps/web/src/onboarding/onboarding-layout.tsx
@@ -5,13 +5,13 @@ const STEP_LABELS = ["Workspace", "Model", "Myra"] as const;
/**
* Single centered column with a step progress bar, matching the onboarding
- * mockup. `step` is the index of the current step (0-2).
+ * mockup. `step` is the index of the current step (0-2; 3 = all done).
*/
export function OnboardingLayout({
step,
children,
}: {
- readonly step: 0 | 1 | 2;
+ readonly step: 0 | 1 | 2 | 3;
readonly children: ReactNode;
}) {
return (
diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx
index cb34a2484..f91afe014 100644
--- a/apps/web/src/onboarding/provider-connect-step.tsx
+++ b/apps/web/src/onboarding/provider-connect-step.tsx
@@ -2,6 +2,7 @@
// derives the single offering it mints; a tenant that already resolves an
// offering skips this step (see `resolveExistingOffering`).
import { Button, Input, RadioGroup, RadioOption, Select } from "@corbits/react-ui";
+import { Cpu, Key, LinkSimple } from "@/lib/icons";
import {
cancelProviderLogin,
credentialNameFor,
@@ -19,6 +20,7 @@ import type { FormEvent } from "react";
import type { ModelProviderPlugin } from "@intx/types";
import { fetchOllamaTags } from "./ollama-tags";
+import "./provider-rows.css";
export type ProviderOption = {
/** Stable option id: two OAuth providers share one plugin, so the plugin
@@ -346,14 +348,28 @@ export function ProviderConnectStep({
value={selected ?? ""}
onValueChange={selectOption}
>
- {PROVIDER_OPTIONS.map((candidate) => (
-
- ))}
+ {PROVIDER_OPTIONS.map((candidate) => {
+ const rowId = `provider-${candidate.id}`;
+ const RowIcon = candidate.local
+ ? Cpu
+ : candidate.oauthProvider !== undefined
+ ? LinkSimple
+ : Key;
+ return (
+
+
+
+
+
+
+ {candidate.label}
+
+ {candidate.description}
+
+
+
+ );
+ })}
{option === undefined ? null : oauthProvider !== undefined ? (
diff --git a/apps/web/src/onboarding/provider-rows.css b/apps/web/src/onboarding/provider-rows.css
new file mode 100644
index 000000000..75c3c691f
--- /dev/null
+++ b/apps/web/src/onboarding/provider-rows.css
@@ -0,0 +1,34 @@
+.onboarding-provider-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.625rem 0;
+}
+
+.onboarding-provider-icon {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 0.5rem;
+ background: var(--muted);
+ color: var(--muted-foreground);
+}
+
+.onboarding-provider-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ cursor: pointer;
+}
+
+.onboarding-provider-name {
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.onboarding-provider-desc {
+ font-size: 0.75rem;
+ color: var(--muted-foreground);
+}
diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx
index 303bc085d..73f4150ab 100644
--- a/apps/web/src/pages/new-workbench-picker.tsx
+++ b/apps/web/src/pages/new-workbench-picker.tsx
@@ -2,8 +2,7 @@
// posts the message so the worker starts.
import { Button, toast } from "@corbits/react-ui";
-import { PaperPlaneRight } from "@/lib/icons";
-import { CHAT_STRINGS, WorkbenchLoadingState } from "@/chat";
+import { WorkbenchLoadingState } from "@/chat";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { reportError } from "@corbits/error-sink";
@@ -30,12 +29,6 @@ export function describeWorkbenchCreateFailure(cause: unknown, refId?: string):
return refId === undefined ? message : `${message} Reference: ${refId}`;
}
-const SUGGESTIONS = [
- "Review every pull request on a repository",
- "Vet a vendor before we sign",
- "Summarize what shipped each Friday",
-];
-
/** A workbench's name is its opening ask, trimmed. */
function workbenchName(prompt: string): string {
const trimmed = prompt.trim();
@@ -92,7 +85,7 @@ export function NewWorkbenchPickerRoute() {
return (
-
+
{create.isPending ? (
// `delayMs={0}`: a genuine wait the instant the person sends.
@@ -100,7 +93,7 @@ export function NewWorkbenchPickerRoute() {
) : (
<>
-
What should this workbench do?
+
What should we work on?
-
- {SUGGESTIONS.map((suggestion) => (
- setPrompt(suggestion)}>
- {suggestion}
-
- ))}
-
+
+ No templates. Your co-worker asks what it needs, connects tools with you, and suggests
+ skills and workflows as you go.
+
>
)}
diff --git a/apps/web/src/pages/new-workbench.css b/apps/web/src/pages/new-workbench.css
index 0faa4387a..9be24b543 100644
--- a/apps/web/src/pages/new-workbench.css
+++ b/apps/web/src/pages/new-workbench.css
@@ -43,25 +43,9 @@
padding: 0.5rem 0.75rem 0.75rem;
}
-.new-suggestions {
- display: flex;
- flex-wrap: wrap;
- justify-content: center;
- gap: 0.5rem;
- margin-top: 1rem;
-}
-
-.new-suggestions button {
- border: 1px solid var(--border);
- border-radius: 999px;
- background: var(--muted);
- color: var(--foreground);
- font: inherit;
+.new-hint {
+ margin: 1rem 0 0;
+ text-align: center;
font-size: 0.8125rem;
- padding: 0.375rem 0.75rem;
- cursor: pointer;
-}
-
-.new-suggestions button:hover {
- border-color: var(--border-strong, var(--border));
+ color: var(--muted-foreground);
}
diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx
index 3513fd241..0983fe39d 100644
--- a/apps/web/src/pages/onboarding-page.tsx
+++ b/apps/web/src/pages/onboarding-page.tsx
@@ -27,6 +27,7 @@ type GateState =
| { readonly phase: "provider-setup"; readonly tenantId: string }
| { readonly phase: "publishing-myra" }
| { readonly phase: "installing"; readonly myraDeploy: WorkflowDeployInput }
+ | { readonly phase: "ready" }
| {
readonly phase: "setup-pending";
readonly message: string;
@@ -170,7 +171,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
(result) => {
if (cancelled) return;
if (result.kind === "ready") {
- navigate("/");
+ setState({ phase: "ready" });
} else if (result.code === "stock-capability-missing") {
setState({ phase: "setup-pending", message: result.gap });
} else {
@@ -258,6 +259,26 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
);
}
+ if (state.phase === "ready") {
+ return (
+
+
+
Myra is ready
+
+ Describe the job and your co-worker sets up the rest.
+
+
+
+ navigate("/")}>
+ Start your first workbench
+
+
+
+
+
+ );
+ }
+
if (state.phase === "setup-pending") {
return (
diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx
index 1a59a7a42..4f49cde55 100644
--- a/apps/web/src/pages/worker-page.tsx
+++ b/apps/web/src/pages/worker-page.tsx
@@ -9,6 +9,7 @@ import type { GrantEffect } from "@intx/types";
import { WorkbenchAvatar } from "@/chat/avatar";
import { isMyraAgent, listChatAgents, type ChatAgent } from "@/chat/threads-api";
+import { listWorkbenches } from "@/chat/workbench-tenants";
import { ChatCircle } from "@/lib/icons";
import { QueryView, describeApiError } from "@/lib/api-query";
import { listTopLevelRuns } from "../agents-api";
@@ -269,27 +270,52 @@ function DetailsCard({
queryFn: () => readAgentSource(tenantId, agent.id, agent.assetName),
});
const first = source.data?.declaredSources[0];
+ const runs = useQuery({
+ queryKey: [...tenantKeys.agents(tenantId), "runs", agent.id],
+ queryFn: () => listTopLevelRuns(tenantId),
+ });
+ const own = (runs.data ?? []).filter((run) => agent.addresses.includes(run.address));
+ const created = own.map((run) => Date.parse(run.createdAt)).sort((a, b) => a - b)[0];
return (
Details
Model
{first === undefined ? "…" : `${first.provider} · ${first.model}`}
+ {created === undefined ? null : (
+ <>
+ Created
+
+ {new Date(created).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
+
+ >
+ )}
+ {runs.data === undefined ? null : (
+ <>
+ Runs
+ {own.length}
+ >
+ )}
);
}
export function WorkerRoute({ agentId }: { readonly agentId: string }) {
- const { selectedTenantId, benchMemberships } = useBench();
+ const { selectedTenantId } = useBench();
const agentsQuery = useTenantQuery(
tenantKeys.agents(selectedTenantId ?? "none"),
selectedTenantId !== null,
() => listChatAgents(selectedTenantId as string),
);
- const benchName =
- benchMemberships.find((member) => member.tenantId === selectedTenantId)?.tenantName ??
- "workbench";
+ // The workspace is not a bench; the worker's workbench is one of its child
+ // tenants, the same rows the sidebar lists.
+ const benches = useQuery({
+ queryKey: tenantKeys.workbenches(selectedTenantId ?? "none"),
+ enabled: selectedTenantId !== null,
+ queryFn: () => listWorkbenches(selectedTenantId as string),
+ });
+ const bench = benches.data?.[0];
return (
@@ -326,18 +352,23 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) {
/>
{agent.name}
+ {isMyraAgent(agent) ? (
+
Your assistant
+ ) : null}
{status.text}
-
-
- Open {benchName}
-
+ {bench === undefined ? null : (
+
+
+ Open {bench.title}
+
+ )}
@@ -348,12 +379,19 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) {
Workbench
-
- {benchName}
-
+ {benches.data === undefined || benches.data.length === 0 ? (
+ No workbenches yet.
+ ) : (
+ benches.data.map((item) => (
+
+ {item.title}
+
+ ))
+ )}
diff --git a/apps/web/src/settings/account-section.tsx b/apps/web/src/settings/account-section.tsx
index 953df803e..71a33be3c 100644
--- a/apps/web/src/settings/account-section.tsx
+++ b/apps/web/src/settings/account-section.tsx
@@ -2,12 +2,13 @@
// The Agent card was removed — nothing there could change until a hub
// preference store exists to write it to.
-import { Avatar, Badge, Button, toast } from "@corbits/react-ui";
+import { Badge, Button, toast } from "@corbits/react-ui";
import { ChatCircleDots, Copy, SignOut } from "@/lib/icons";
import { useQuery } from "@tanstack/react-query";
import { QueryView, toAPIQuery } from "@/lib/api-query";
import { resolveAvatarFill } from "@/chat";
+import { IdentityAvatar } from "@/chat/avatar";
import webPackage from "../../package.json";
import { getAccount, type Account } from "./api";
import { Segmented, SettingsGroup, SettingsRow } from "./rows";
@@ -42,17 +43,6 @@ export function AccountSection({ onSignOut }: { readonly onSignOut?: () => void
);
}
-/** First and (if present) second-word initials, upper-cased. Falls back to
- * "?" for an empty name rather than rendering a blank avatar. */
-function initialsOf(name: string): string {
- const words = name.trim().split(/\s+/).filter(Boolean);
- if (words.length === 0) return "?";
- const first = words[0]?.charAt(0) ?? "";
- const second = words.length > 1 ? (words[1]?.charAt(0) ?? "") : "";
- const initials = `${first}${second}`.toUpperCase();
- return initials.length > 0 ? initials : "?";
-}
-
async function copyEmail(email: string): Promise
{
try {
await navigator.clipboard.writeText(email);
@@ -94,12 +84,7 @@ export function AccountSectionView({
height={40}
/>
) : (
-
+
)}
{name}
diff --git a/apps/web/src/settings/styles.css b/apps/web/src/settings/styles.css
index 28f2018aa..05eebf457 100644
--- a/apps/web/src/settings/styles.css
+++ b/apps/web/src/settings/styles.css
@@ -729,3 +729,21 @@
width: auto;
min-width: 10rem;
}
+
+/* Active settings row: ink on a hover wash; orange is accent-only. */
+.settings-nav [data-slot="sidebar-item-row"] > div:has(button[aria-current="true"]) {
+ background: var(--hover);
+}
+
+.settings-nav [data-slot="sidebar-item-row"] > div:has(button[aria-current="true"]) > button {
+ color: var(--ink);
+ font-weight: 600;
+}
+
+.settings-nav .settings-nav-advanced > summary {
+ margin: 0 0 0.2rem;
+ padding: 0.25rem 0.42rem;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--ink-2);
+}
diff --git a/apps/web/src/theme-tokens.css b/apps/web/src/theme-tokens.css
index e2ac458d8..26d253878 100644
--- a/apps/web/src/theme-tokens.css
+++ b/apps/web/src/theme-tokens.css
@@ -120,8 +120,8 @@
/* Canvas: the warm Corbits cream. */
[data-theme="canvas"] {
--canvas: var(--cream);
- --surface: #fff;
- --surface-sunk: color-mix(in srgb, var(--cream) 38%, #fff);
+ --surface: color-mix(in srgb, var(--cream) 30%, #fff);
+ --surface-sunk: color-mix(in srgb, var(--cream) 55%, #fff);
--ink-2: var(--charcoal-soft);
--ink-3: color-mix(in srgb, var(--charcoal) 68%, #fff);
--hover: rgb(43 38 39 / 5%);
diff --git a/apps/web/src/voice/voice-overlay.css b/apps/web/src/voice/voice-overlay.css
new file mode 100644
index 000000000..9915f9386
--- /dev/null
+++ b/apps/web/src/voice/voice-overlay.css
@@ -0,0 +1,5 @@
+/* Opaque themed backdrop: the thread must not ghost through the voice view. */
+.voice[role="dialog"] {
+ background: var(--canvas);
+ backdrop-filter: none;
+}
diff --git a/apps/web/src/voice/voice-overlay.tsx b/apps/web/src/voice/voice-overlay.tsx
index 80fd29c5a..b97cee8eb 100644
--- a/apps/web/src/voice/voice-overlay.tsx
+++ b/apps/web/src/voice/voice-overlay.tsx
@@ -1,5 +1,6 @@
// Voice mode: covers the thread, sends each final utterance as an ordinary
// message, and speaks the worker's replies. Browser speech only.
+import "./voice-overlay.css";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { CorbitAvatar, avatarColorForPrincipal } from "@/chat/avatar";
diff --git a/docs/local-dev.md b/docs/local-dev.md
index 64155bc70..8f60950de 100644
--- a/docs/local-dev.md
+++ b/docs/local-dev.md
@@ -13,6 +13,12 @@ Tool-heavy turns (several tool calls in one turn) take noticeably longer
on small local models. That is a model-capability limit, not a platform
bug.
+## Hub on another port
+
+Sign-in trusts one origin, `BETTER_AUTH_BASE_URL` (default
+`http://localhost:3000`). If the hub listens elsewhere, set it to the hub
+origin in `.env`, or sign-up fails with "Invalid origin".
+
## Migrations after pulling
`bun run dev` applies both the platform's own migrations and every