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
4 changes: 2 additions & 2 deletions apps/web/src/bench/bench-pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ function statusLine(status: WorkerStatus, workerName: string | undefined): strin
if (status.text === "Working…") {
return workerName === undefined ? "Working…" : `${workerName} is working…`;
}
if (status.text === "Live") return "Idle";
return status.text;
const text = status.text === "Live" ? "Idle" : status.text;
return workerName === undefined ? text : `${workerName} · ${text}`;
}

/** Frosted pill centered over the thread: the bench's worker, its name and
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/bench/description.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
flex-direction: column;
gap: 8px;
}
.bench-description-form textarea {
.bench-description-form textarea,
.bench-description-form input {
width: 100%;
min-height: 72px;
resize: vertical;
Expand All @@ -30,3 +31,10 @@
font-size: 0.75rem;
color: var(--muted-foreground);
}
.bench-description-form input {
min-height: 0;
}
.bench-description-form[data-inline] {
flex-direction: row;
align-items: center;
}
23 changes: 3 additions & 20 deletions apps/web/src/bench/description.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type } from "arktype";

import { ApiQueryError } from "@/lib/api-query";
import { TenantDetailSchema, fetchTenantDetail, type TenantDetail } from "../api";
import { fetchTenantDetail, type TenantDetail } from "../api";
import { patchTenantConfigKey } from "./tenant-config";

export const DESCRIPTION_MAX = 280;

Expand All @@ -24,24 +24,7 @@ export function useBenchDescription(tenantId: string) {
queryFn: () => fetchTenantDetail(tenantId),
});
const save = useMutation({
mutationFn: async (text: string) => {
// Read-modify-write on fresh config so other keys are never dropped.
const current = await fetchTenantDetail(tenantId);
const path = `/api/tenants/${encodeURIComponent(tenantId)}`;
const response = await fetch(path, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ config: { ...current.config, "workbench.description": text } }),
});
if (!response.ok) {
throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path);
}
const parsed = TenantDetailSchema(await response.json());
if (parsed instanceof type.errors) {
throw new ApiQueryError(`Unexpected tenant response shape: ${parsed.summary}`);
}
return parsed;
},
mutationFn: (text: string) => patchTenantConfigKey(tenantId, "workbench.description", text),
onSuccess: (tenant) => queryClient.setQueryData(descriptionKey(tenantId), tenant),
});
return {
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/bench/information-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { benchLink } from "../shell/page-crumbs";
import type { WorkerStatus } from "../worker-status";
import "./description.css";
import { DESCRIPTION_MAX, useBenchDescription } from "./description";
import { DEFAULT_WORKER_NAME, WORKER_NAME_MAX, useWorkerName } from "./worker-name";

function Section({
title,
Expand Down Expand Up @@ -79,6 +80,39 @@ function AboutSection({ workbenchTenantId }: { readonly workbenchTenantId: strin
);
}

function WorkerNameSection({ workbenchTenantId }: { readonly workbenchTenantId: string }) {
const { name, save } = useWorkerName(workbenchTenantId);
const current = name ?? DEFAULT_WORKER_NAME;
const [draft, setDraft] = useState<string | null>(null);
const value = draft ?? current;
const dirty = draft !== null && draft.trim() !== current;
return (
<Section title="Worker">
<form
className="bench-description-form"
data-inline
onSubmit={(event) => {
event.preventDefault();
save.mutate(value.trim(), {
onSuccess: () => setDraft(null),
onError: (cause) => toast(cause instanceof Error ? cause.message : String(cause)),
});
}}
>
<input
aria-label="Worker name"
maxLength={WORKER_NAME_MAX}
value={value}
onChange={(event) => setDraft(event.target.value)}
/>
<Button type="submit" size="sm" disabled={!dirty || save.isPending}>
Rename
</Button>
</form>
</Section>
);
}

/** Overview of the bench: what it is, what's happening now, what's waiting
* on the person, and its live workflows and latest files. */
export function InformationTab({
Expand Down Expand Up @@ -118,6 +152,7 @@ export function InformationTab({
return (
<>
<AboutSection workbenchTenantId={workbenchTenantId} />
<WorkerNameSection workbenchTenantId={workbenchTenantId} />

{worker !== undefined && status.tone === "working" ? (
<Section title="Now">
Expand Down
5 changes: 1 addition & 4 deletions apps/web/src/bench/members-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { IdentityAvatar, WorkbenchAvatar } from "@/chat/avatar";
import type { WorkbenchParticipant } from "@/chat/threads-api";
import { Link } from "../navigation";
import { tenantKeys } from "../query-client";
import { useWorkerBenches } from "../worker-benches";
import { principalLabel } from "../settings/identity";
import { listPrincipals } from "../settings/tenancy-api";

Expand All @@ -27,7 +26,6 @@ export function MembersTab({
queryKey: [...tenantKeys.principals(workbenchTenantId), "members"],
queryFn: async () => (await listPrincipals(workbenchTenantId)).filter((p) => p.kind === "user"),
});
const { workerIdForAsset } = useWorkerBenches();
const agents = participants.filter((p) => p.kind === "agent");

return (
Expand Down Expand Up @@ -75,8 +73,7 @@ export function MembersTab({
) : null}
<ul className="drawer-list">
{agents.map((agent) => {
const workerId =
agent.assetName === undefined ? undefined : workerIdForAsset(agent.assetName);
const workerId = agent.assetName === undefined ? undefined : agent.id;
const body = (
<>
<WorkbenchAvatar kind="worker" name={agent.name} size="md" />
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/bench/pending-opening-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export function PendingOpeningMessage({
}, []);

const send = useMutation({
mutationFn: (content: string) => sendToWorkbench({ workbenchTenantId, participants, content }),
mutationFn: (content: string) =>
sendToWorkbench({
workbenchTenantId,
participants,
content,
}),
onSuccess: () => {
clearOpeningMessage(workbenchTenantId);
setText(null);
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/bench/tenant-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { type } from "arktype";

import { ApiQueryError } from "@/lib/api-query";
import { TenantDetailSchema, fetchTenantDetail, type TenantDetail } from "../api";

/** Sets one key of the stock tenant config. Read-modify-write on fresh config
* so other keys are never dropped. */
export async function patchTenantConfigKey(
tenantId: string,
key: string,
value: string,
): Promise<TenantDetail> {
const current = await fetchTenantDetail(tenantId);
const path = `/api/tenants/${encodeURIComponent(tenantId)}`;
const response = await fetch(path, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ config: { ...current.config, [key]: value } }),
});
if (!response.ok) {
throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path);
}
const parsed = TenantDetailSchema(await response.json());
if (parsed instanceof type.errors) {
throw new ApiQueryError(`Unexpected tenant response shape: ${parsed.summary}`);
}
return parsed;
}
111 changes: 111 additions & 0 deletions apps/web/src/bench/worker-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// The worker's name is bench state in the tenant config, never a redeploy:
// the worker states it on its first reply and the person can rename it.

import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { type } from "arktype";

import type { WorkbenchMessage, WorkbenchParticipant } from "@/chat/threads-api";
import { sameAddress } from "@/chat/threads-api";
import { fetchTenantDetail, type TenantDetail } from "../api";
import { patchTenantConfigKey } from "./tenant-config";

export const DEFAULT_WORKER_NAME = "New worker";
export const WORKER_NAME_MAX = 24;
const CONFIG_KEY = "workbench.worker.name";

const NameConfig = type({ "workbench.worker.name?": "string" });

const NAME_PATTERN = /^[\p{L} -]+$/u;

/** A name the bench accepts: trimmed, short, letters, spaces and hyphens. */
export function validWorkerName(raw: string): string | undefined {
const name = raw.trim();
return name.length > 0 && name.length <= WORKER_NAME_MAX && NAME_PATTERN.test(name)
? name
: undefined;
}

function readWorkerName(config: TenantDetail["config"]): string | undefined {
const parsed = NameConfig(config ?? {});
if (parsed instanceof type.errors) return undefined;
const stored = parsed["workbench.worker.name"];
return stored === undefined ? undefined : validWorkerName(stored);
}

const nameKey = (tenantId: string) => ["workbench", tenantId, "worker-name"] as const;

const nameQuery = (tenantId: string) => ({
queryKey: nameKey(tenantId),
queryFn: async () => readWorkerName((await fetchTenantDetail(tenantId)).config) ?? null,
});

const NAME_LINE = /\n[ \t]*Name:[ \t]*([^\n]+?)[ \t]*$/i;

/** The name a worker message ends with, when it ends with a valid
* `Name: <name>` line. */
export function nameFromMessage(body: string): string | undefined {
const match = NAME_LINE.exec(body.trimEnd());
return match?.[1] === undefined ? undefined : validWorkerName(match[1]);
}

/** The message as rendered: a valid trailing `Name:` line is never shown. */
export function stripNameLine(body: string): string {
const trimmed = body.trimEnd();
return nameFromMessage(trimmed) === undefined ? body : trimmed.replace(NAME_LINE, "");
}

/** The stored name, or undefined until one exists; `save` renames. */
export function useWorkerName(tenantId: string) {
const queryClient = useQueryClient();
const query = useQuery(nameQuery(tenantId));
const save = useMutation({
mutationFn: (name: string) => setWorkerName(tenantId, name),
onSuccess: (name) => queryClient.setQueryData(nameKey(tenantId), name),
});
return {
name: query.data ?? undefined,
loaded: query.isSuccess,
save,
};
}

export async function setWorkerName(tenantId: string, name: string): Promise<string> {
const valid = validWorkerName(name);
if (valid === undefined) {
throw new Error("Use up to 24 letters, spaces or hyphens.");
}
await patchTenantConfigKey(tenantId, CONFIG_KEY, valid);
return valid;
}

/** Stored names of many benches, by bench id; a bench with none reads as
* `DEFAULT_WORKER_NAME`. */
export function useWorkerNames(tenantIds: readonly string[]): ReadonlyMap<string, string> {
const results = useQueries({ queries: tenantIds.map(nameQuery) });
return new Map(tenantIds.map((id, i) => [id, results[i]?.data ?? DEFAULT_WORKER_NAME]));
}

/** Participants with the bench's worker shown under its stored name. */
export function nameWorkers(
participants: readonly WorkbenchParticipant[],
name: string | undefined,
): readonly WorkbenchParticipant[] {
return participants.map((p) =>
p.kind === "agent" ? { ...p, name: name ?? DEFAULT_WORKER_NAME } : p,
);
}

/** The first worker message that states a name, for a bench with none. */
export function firstStatedName(
messages: readonly WorkbenchMessage[],
participants: readonly WorkbenchParticipant[],
): string | undefined {
for (const message of messages) {
if (message.author !== "other") continue;
const sender = participants.find((p) => sameAddress(p.address, message.address));
if (sender !== undefined && sender.kind !== "agent") continue;
const name = nameFromMessage(message.body);
if (name !== undefined) return name;
}
return undefined;
}
6 changes: 5 additions & 1 deletion apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,11 @@ export async function sendToWorkbench(input: {
address: person.address,
kind: "person" as const,
})),
...live.map((agent) => ({ name: agent.name, address: agent.address, kind: "agent" as const })),
...live.map((agent) => ({
name: agent.name,
address: agent.address,
kind: "agent" as const,
})),
]);
let response: Response;
try {
Expand Down
Loading
Loading