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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/onboarding/onboarding-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
32 changes: 24 additions & 8 deletions apps/web/src/onboarding/provider-connect-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -346,14 +348,28 @@ export function ProviderConnectStep({
value={selected ?? ""}
onValueChange={selectOption}
>
{PROVIDER_OPTIONS.map((candidate) => (
<RadioOption
key={candidate.id}
value={candidate.id}
label={candidate.label}
description={candidate.description}
/>
))}
{PROVIDER_OPTIONS.map((candidate) => {
const rowId = `provider-${candidate.id}`;
const RowIcon = candidate.local
? Cpu
: candidate.oauthProvider !== undefined
? LinkSimple
: Key;
return (
<div key={candidate.id} className="onboarding-provider-row">
<RadioOption value={candidate.id} id={rowId} describedBy={`${rowId}-desc`} />
<span className="onboarding-provider-icon" aria-hidden="true">
<RowIcon size={16} />
</span>
<label htmlFor={rowId} className="onboarding-provider-text">
<span className="onboarding-provider-name">{candidate.label}</span>
<span id={`${rowId}-desc`} className="onboarding-provider-desc">
{candidate.description}
</span>
</label>
</div>
);
})}
</RadioGroup>
{option === undefined ? null : oauthProvider !== undefined ? (
<p>
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/onboarding/provider-rows.css
Original file line number Diff line number Diff line change
@@ -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);
}
29 changes: 9 additions & 20 deletions apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -92,15 +85,15 @@ export function NewWorkbenchPickerRoute() {

return (
<div className="flex h-full min-h-0 flex-col">
<StageTopBar crumbs={[{ label: CHAT_STRINGS.newWorkbenchAction }]} />
<StageTopBar crumbs={[{ label: "Workbenches" }, { label: "New" }]} />
<div className="new-wrap">
{create.isPending ? (
// `delayMs={0}`: a genuine wait the instant the person sends.
<WorkbenchLoadingState delayMs={0} title="Setting up your workbench…" />
) : (
<>
<ProviderSkipBanner />
<h1 className="new-title">What should this workbench do?</h1>
<h1 className="new-title">What should we work on?</h1>
<form
className="new-composer"
onSubmit={(event) => {
Expand All @@ -110,8 +103,8 @@ export function NewWorkbenchPickerRoute() {
>
<textarea
className="new-composer-input"
aria-label="What should this workbench do?"
placeholder="Describe the job. Your co-worker sets up the rest."
aria-label="What should we work on?"
placeholder="Describe the job — your co-worker sets up the rest"
value={prompt}
rows={3}
autoFocus
Expand All @@ -130,17 +123,13 @@ export function NewWorkbenchPickerRoute() {
disabled={prompt.trim() === "" || selectedTenantId === null}
>
Start
<PaperPlaneRight size={16} strokeWidth={1.8} />
</Button>
</div>
</form>
<div className="new-suggestions" aria-label="Quick start">
{SUGGESTIONS.map((suggestion) => (
<button key={suggestion} type="button" onClick={() => setPrompt(suggestion)}>
{suggestion}
</button>
))}
</div>
<p className="new-hint">
No templates. Your co-worker asks what it needs, connects tools with you, and suggests
skills and workflows as you go.
</p>
</>
)}
</div>
Expand Down
24 changes: 4 additions & 20 deletions apps/web/src/pages/new-workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
23 changes: 22 additions & 1 deletion apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -258,6 +259,26 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
);
}

if (state.phase === "ready") {
return (
<OnboardingLayout step={3}>
<div className="onboarding-phase" key="ready">
<h1 className="onboarding-title">Myra is ready</h1>
<p className="onboarding-subtitle">
Describe the job and your co-worker sets up the rest.
</p>
<div className="onboarding-content">
<div className="onboarding-actions">
<Button variant="primary" onClick={() => navigate("/")}>
Start your first workbench
</Button>
</div>
</div>
</div>
</OnboardingLayout>
);
}

if (state.phase === "setup-pending") {
return (
<OnboardingLayout step={2}>
Expand Down
72 changes: 55 additions & 17 deletions apps/web/src/pages/worker-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<Card className="p-5">
<CardTitle className="text-[14px]">Details</CardTitle>
<dl className="mt-3 grid grid-cols-[90px_1fr] gap-y-2 text-[13.5px]">
<dt className="text-(--ink-3)">Model</dt>
<dd>{first === undefined ? "…" : `${first.provider} · ${first.model}`}</dd>
{created === undefined ? null : (
<>
<dt className="text-(--ink-3)">Created</dt>
<dd>
{new Date(created).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</dd>
</>
)}
{runs.data === undefined ? null : (
<>
<dt className="text-(--ink-3)">Runs</dt>
<dd className="tabular-nums">{own.length}</dd>
</>
)}
</dl>
</Card>
);
}

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 (
<div className="flex h-full min-h-0 flex-col">
Expand Down Expand Up @@ -326,18 +352,23 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) {
/>
<div className="min-w-0 flex-1">
<h1 className="text-[24px] font-extrabold">{agent.name}</h1>
{isMyraAgent(agent) ? (
<p className="mt-0.5 text-[14px] text-(--ink-2)">Your assistant</p>
) : null}
<p className="mt-1 flex items-center gap-2 text-[14px] text-(--ink-2)">
<StatusPill tone={status.tone} />
{status.text}
</p>
</div>
<Link
to={workbenchPath(selectedTenantId)}
className="inline-flex h-9 items-center gap-2 rounded-(--r-md) bg-(--primary) px-4 text-[14px] font-bold text-(--primary-foreground)"
>
<ChatCircle />
Open {benchName}
</Link>
{bench === undefined ? null : (
<Link
to={workbenchPath(bench.id)}
className="inline-flex h-9 items-center gap-2 rounded-(--r-md) bg-(--primary) px-4 text-[14px] font-bold text-(--primary-foreground)"
>
<ChatCircle />
Open {bench.title}
</Link>
)}
</div>
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,1fr)_300px]">
<div>
Expand All @@ -348,12 +379,19 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) {
<DetailsCard tenantId={selectedTenantId} agent={agent} />
<Card className="p-5">
<CardTitle className="text-[14px]">Workbench</CardTitle>
<Link
to={workbenchPath(selectedTenantId)}
className="mt-2 block text-[13.5px] font-semibold underline-offset-2 hover:underline"
>
{benchName}
</Link>
{benches.data === undefined || benches.data.length === 0 ? (
<p className="mt-2 text-[13.5px] text-(--ink-3)">No workbenches yet.</p>
) : (
benches.data.map((item) => (
<Link
key={item.id}
to={workbenchPath(item.id)}
className="mt-2 block text-[13.5px] font-semibold underline-offset-2 hover:underline"
>
{item.title}
</Link>
))
)}
</Card>
</aside>
</div>
Expand Down
Loading
Loading