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
79 changes: 79 additions & 0 deletions apps/web/src/bench/pending-opening-message.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// The first message of a new workbench, shown as a pending own bubble until
// the agent is live and the send lands. Past the cap it stays here with a
// retry; it is only cleared once the send succeeds.

import { Button } from "@corbits/react-ui";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";

import { sendToWorkbench, type WorkbenchParticipant } from "@/chat/threads-api";
import { workbenchKeys } from "../chat-path";
import { clearOpeningMessage, readOpeningMessage } from "../opening-message";

const CAP_MS = 60_000;

export function PendingOpeningMessage({
workbenchTenantId,
participants,
}: {
readonly workbenchTenantId: string;
readonly participants: readonly WorkbenchParticipant[];
}) {
const queryClient = useQueryClient();
const [text, setText] = useState(() => readOpeningMessage(workbenchTenantId));
const [timedOut, setTimedOut] = useState(false);
const attempted = useRef(false);

useEffect(() => {
const timer = setTimeout(() => setTimedOut(true), CAP_MS);
return () => clearTimeout(timer);
}, []);

const send = useMutation({
mutationFn: (content: string) => sendToWorkbench({ workbenchTenantId, participants, content }),
onSuccess: () => {
clearOpeningMessage(workbenchTenantId);
setText(null);
void queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) });
},
});

if (text === null) return null;

const live = participants.some((p) => p.kind === "agent" && p.address.includes("@"));
if (live && !attempted.current && !send.isPending) {
attempted.current = true;
send.mutate(text);
}
const failed = send.isError || (timedOut && !live);

return (
<div className="chat-thread-message" data-author="me" data-pending="true">
<div className="chat-thread-body">
<div className="chat-thread-own-bubble">{text}</div>
{failed ? (
<p className="chat-thread-error">
This message hasn&apos;t been sent yet.{" "}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (live) send.mutate(text);
else
void queryClient.invalidateQueries({
queryKey: workbenchKeys.participants(workbenchTenantId),
});
setTimedOut(false);
}}
>
Retry
</Button>
</p>
) : (
<p className="chat-thread-status">Sending once the agent is ready…</p>
)}
</div>
</div>
);
}
31 changes: 31 additions & 0 deletions apps/web/src/opening-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// A new workbench's first message outlives the /new page: it is parked here
// until the workbench's agent is live and the send lands, so it is never lost.

import { reportError } from "@corbits/error-sink";

const key = (tenantId: string): string => `workbench-opening-message:${tenantId}`;

export function parkOpeningMessage(tenantId: string, text: string): void {
try {
sessionStorage.setItem(key(tenantId), text);
} catch (cause) {
reportError(cause, { operation: "opening_message_park", tenantId });
}
}

export function readOpeningMessage(tenantId: string): string | null {
try {
return sessionStorage.getItem(key(tenantId));
} catch (cause) {
reportError(cause, { operation: "opening_message_read", tenantId });
return null;
}
}

export function clearOpeningMessage(tenantId: string): void {
try {
sessionStorage.removeItem(key(tenantId));
} catch (cause) {
reportError(cause, { operation: "opening_message_clear", tenantId });
}
}
8 changes: 3 additions & 5 deletions apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,9 @@ const GENERIC_CREATE_FAILURE = "Something went wrong creating this workbench. Tr
export function describeWorkbenchCreateFailure(cause: unknown, refId?: string): string {
if (!(cause instanceof WorkbenchCreateError)) return GENERIC_CREATE_FAILURE;
const message =
cause.stage === "opening-message"
? "Workbench created, but we couldn't send the opening message. Try again from the workbench."
: cause.stage === "deploy"
? "Workbench created, but its agent couldn't be deployed into it. Try again from the workbench."
: GENERIC_CREATE_FAILURE;
cause.stage === "deploy"
? "Workbench created, but its agent couldn't be deployed into it. Try again from the workbench."
: GENERIC_CREATE_FAILURE;
return refId === undefined ? message : `${message} Reference: ${refId}`;
}

Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/pages/workbench-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { VoiceOverlay } from "../voice/voice-overlay";
import { BenchDrawer } from "../bench/bench-drawer";
import { BenchPill } from "../bench/bench-pill";
import { PendingOpeningMessage } from "../bench/pending-opening-message";
import { ArtifactsTab } from "../bench/artifacts-tab";
import { useWorkerStatus } from "../worker-status";
import { GrantsTab } from "../bench/grants-tab";
Expand Down Expand Up @@ -283,6 +284,10 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string }
onReply={(target) => setOpenThread(target.messageId)}
/>
))}
<PendingOpeningMessage
workbenchTenantId={workbenchTenantId}
participants={participants.data ?? []}
/>
</div>
{send.error === null ? null : (
<p className="chat-thread-error">{errorText(send.error)}</p>
Expand Down
29 changes: 8 additions & 21 deletions apps/web/src/workbench-create.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
// An agent joins a workbench by being deployed into the child tenant
// itself — its deploy route rejects the parent's inherited asset.

import { isMyraAgent, listWorkbenchParticipants, sendToWorkbench } from "@/chat/threads-api";
import { isMyraAgent } from "@/chat/threads-api";
import { agentSlugFromSourceAssetName, deployAgentSource } from "./agent-deploy";
import { readAgentSource } from "./agent-source-read";
import { deployMyraSource } from "./myra-deploy";
import { parkOpeningMessage } from "./opening-message";
import { createFetchStockHub } from "./needs-converge";
import { resolveExistingOffering } from "./onboarding/provider-connect-step";

export class WorkbenchCreateError extends Error {
constructor(
message: string,
readonly stage: "create" | "deploy" | "opening-message",
readonly stage: "create" | "deploy",
/** Set once the workbench tenant exists, so a later-stage failure can
* still land the person in the workbench it created. */
readonly tenantId?: string,
Expand Down Expand Up @@ -71,6 +72,11 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
});
tenantId = created.id;
domain = created.domain;
// The agent surfaces only after the deploy settles, so the workbench page
// sends this once it is live rather than /new waiting on it.
if (input.openingMessage !== undefined && input.openingMessage !== "") {
parkOpeningMessage(tenantId, input.openingMessage);
}
} catch (cause) {
throw failure(cause, "create");
}
Expand Down Expand Up @@ -107,25 +113,6 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
throw failure(cause, "deploy", tenantId);
}

if (input.openingMessage !== undefined && input.openingMessage !== "") {
try {
const participants = await listWorkbenchParticipants(tenantId, domain);
const agents = participants.filter((participant) => participant.kind === "agent");
// A deployment's run address exists only once the deploy settles; a
// workbench whose agent has not surfaced yet keeps the opening message
// for the person to send from the workbench itself.
if (agents.length > 0) {
await sendToWorkbench({
workbenchTenantId: tenantId,
participants,
content: input.openingMessage,
});
}
} catch (cause) {
throw failure(cause, "opening-message", tenantId);
}
}

return tenantId;
}

Expand Down
Loading