From 28317b142a01198f7f74f004bcc27d1a478e613e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 20 Sep 2026 10:12:31 -0700 Subject: [PATCH 1/5] feat(web): remove chat entry points so home lands on last workbench --- apps/web/src/chat/message-attachments.tsx | 6 +- apps/web/src/command-palette-actions.test.ts | 5 + apps/web/src/command-palette-actions.ts | 14 +-- apps/web/src/command-palette-provider.tsx | 56 +----------- apps/web/src/pages/agents-page.tsx | 30 +++--- apps/web/src/pages/home-page.tsx | 39 ++++---- apps/web/src/pages/insights-page.tsx | 16 +--- apps/web/src/pages/library-page.tsx | 2 +- apps/web/src/pages/workbench-page.tsx | 10 ++ apps/web/src/routes.tsx | 15 --- apps/web/src/shell/bench-activity.ts | 10 +- apps/web/src/shell/canvas-column.tsx | 33 +------ apps/web/src/shell/sidebar-sections.ts | 39 ++------ apps/web/src/shell/sidebar.tsx | 4 +- apps/web/src/shell/workbench-list.tsx | 96 ++------------------ 15 files changed, 81 insertions(+), 294 deletions(-) diff --git a/apps/web/src/chat/message-attachments.tsx b/apps/web/src/chat/message-attachments.tsx index 0dfcf903a..9fe04a5b2 100644 --- a/apps/web/src/chat/message-attachments.tsx +++ b/apps/web/src/chat/message-attachments.tsx @@ -8,8 +8,6 @@ import { cronSentence } from "@corbits/workflows/client"; import { WarningCircle } from "@/lib/icons"; import { useDeployAgentMutation } from "../agents-api"; -import { chatPath } from "../chat-path"; -import { Link } from "../navigation"; import { isFiveFieldCron, isPackageRejection, @@ -107,9 +105,7 @@ function DeployPackageCard({ {deploy.isPending ? "Deploying…" : `Deploy ${pkg.name}`} ) : ( - - Open {pkg.name} - + {pkg.name} is deployed. )} {deploy.error === null ? null : (

{errorText(deploy.error)}

diff --git a/apps/web/src/command-palette-actions.test.ts b/apps/web/src/command-palette-actions.test.ts index 0d7d05dcf..e965a8a25 100644 --- a/apps/web/src/command-palette-actions.test.ts +++ b/apps/web/src/command-palette-actions.test.ts @@ -56,6 +56,11 @@ describe("ACTION_COMMANDS", () => { expect(ids.includes("new-thread")).toBe(false); }); + test("does not include Talk to Myra — standalone chats are gone", () => { + const ids: readonly string[] = ACTION_COMMANDS.map((c) => c.id); + expect(ids.includes("talk-to-myra")).toBe(false); + }); + test("every command has a stable id, title, and subtitle", () => { for (const command of ACTION_COMMANDS) { expect(command.id.length).toBeGreaterThan(0); diff --git a/apps/web/src/command-palette-actions.ts b/apps/web/src/command-palette-actions.ts index 979d05dcb..7d8e584c3 100644 --- a/apps/web/src/command-palette-actions.ts +++ b/apps/web/src/command-palette-actions.ts @@ -5,7 +5,6 @@ import { createPendingDialogRequest } from "@/shell/layout"; import { CHAT_STRINGS } from "@/chat"; import { WORKBENCH_PATH_PREFIX } from "./workbench-path"; import { NEW_WORKBENCH_PATH } from "./routes"; -import { NEW_CHAT_PATH } from "./chat-path"; import { requestLibraryUpload } from "./library-upload"; import { openFirstRunTour } from "./shell/first-run-tour-store"; @@ -27,7 +26,6 @@ export type ActionCommandId = | "upload-artifact" | "toggle-theme" | "close-canvas" - | "talk-to-myra" | "go-workbenches" | "take-tour"; @@ -57,15 +55,10 @@ export const ACTION_COMMANDS: readonly ActionCommand[] = [ title: "Close canvas", subtitle: "Full-width stage", }, - { - id: "talk-to-myra", - title: "Talk to Myra", - subtitle: "Open your personal agent", - }, { id: "go-workbenches", title: "Go to workbenches", - subtitle: "Home · conversation list", + subtitle: "Home · workbench list", }, { id: "take-tour", @@ -116,11 +109,6 @@ export async function runActionCommand( ctx.closeCanvas(); return; } - case "talk-to-myra": { - // A chat with Myra is a mail thread now, composed on /chats/new. - ctx.navigate(NEW_CHAT_PATH); - return; - } case "go-workbenches": { ctx.navigate(WORKBENCH_PATH_PREFIX); return; diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 7991d0dab..6a0b0ccfd 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -17,8 +17,6 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { listChatAgents } from "@/chat/threads-api"; -import { chatPath } from "./chat-path"; import { ACTION_COMMANDS, runActionCommand, type ActionCommandId } from "./command-palette-actions"; import { openCommandPalette, @@ -125,21 +123,9 @@ export function CommandPaletteProvider({ })); }, [selectedTenantId, queryClient]); - // A palette hit opens a chat with the agent, so the search source is the - // same chat-partner listing the roster and the Agents page read — its - // `id` is exactly what `chatPath` expects. - const listAgentsForSearch = useCallback(async () => { - if (selectedTenantId === null) return []; - const agents = await listChatAgents(selectedTenantId); - return agents.map((agent) => ({ id: agent.id, name: agent.name })); - }, [selectedTenantId]); - const entitySearchSources = useMemo( - () => [ - { category: "workbenches", fetch: listWorkbenchesForSearch }, - { category: "agents", fetch: listAgentsForSearch }, - ], - [listWorkbenchesForSearch, listAgentsForSearch], + () => [{ category: "workbenches", fetch: listWorkbenchesForSearch }], + [listWorkbenchesForSearch], ); const strippedQuery = useMemo(() => parsePaletteQuery(query).query, [query]); @@ -165,17 +151,6 @@ export function CommandPaletteProvider({ (row) => ({ id: `entity:workbenches:${row.id}`, title: row.name }), ); - const bareAgentsQuery = useQuery({ - queryKey: ["tenant", selectedTenantId ?? "", "agents", "bare-scope"], - enabled: bareScopeKind === "people" && open && selectedTenantId !== null, - queryFn: listAgentsForSearch, - }); - const bareAgents: readonly PaletteResultItem[] = (bareAgentsQuery.data ?? []).map((row) => ({ - id: `entity:agents:${row.id}`, - title: row.name, - subtitle: "Agent", - })); - const routinesQuery = useTenantQuery( tenantKeys.routines(selectedTenantId ?? ""), open && selectedTenantId !== null, @@ -269,17 +244,6 @@ export function CommandPaletteProvider({ })); }, [results, bareScopeKind, bareWorkbenches]); - const agentItems = useMemo(() => { - if (bareScopeKind === "people") return bareAgents; - return results - .filter((result) => result.category === "agents") - .map((agent) => ({ - id: `entity:agents:${agent.id}`, - title: agent.title, - subtitle: "Agent", - })); - }, [results, bareScopeKind, bareAgents]); - const routineItems = useMemo( () => routinesQuery.kind === "ready" @@ -335,14 +299,8 @@ export function CommandPaletteProvider({ { id: "routines", heading: "Workflows", items: routineItems }, { id: "skills", heading: "Skills", items: skillItems }, { id: "library", heading: "Artifacts", items: libraryItems }, - { - id: "people", - heading: "People & agents", - kind: "people", - items: agentItems, - }, ], - [actionItems, workbenchItems, pageItems, routineItems, skillItems, libraryItems, agentItems], + [actionItems, workbenchItems, pageItems, routineItems, skillItems, libraryItems], ); const recentItems = useMemo( @@ -405,11 +363,6 @@ export function CommandPaletteProvider({ const title = workbenchItems.find((item) => item.id === id)?.title ?? workbenchId; navigate(`/w/${workbenchId}`); pushRecent({ kind: "workbenches", id, title, subtitle: "Workbench" }); - } else if (id.startsWith("entity:agents:")) { - const agentId = id.slice("entity:agents:".length); - const title = agentItems.find((item) => item.id === id)?.title ?? agentId; - navigate(chatPath(agentId)); - pushRecent({ kind: "agents", id, title, subtitle: "Agent" }); } else if (id.startsWith("entity:routines:")) { const routineId = id.slice("entity:routines:".length); const title = routineItems.find((item) => item.id === id)?.title ?? routineId; @@ -437,7 +390,6 @@ export function CommandPaletteProvider({ closeCanvas, pushRecent, workbenchItems, - agentItems, routineItems, skillItems, libraryItems, @@ -461,7 +413,7 @@ export function CommandPaletteProvider({ hasMore={hasMore} onLoadMore={loadMore} placeholder="Search or jump to…" - footer="# workbenches · @ people · > actions · / pages" + footer="# workbenches · > actions · / pages" /> ); diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index d4be89a27..1af5ee87f 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -1,5 +1,5 @@ -// A roster and nothing else — a chat with the agent is the one action -// anyone actually used. Create-agent lives in the sidebar's "+" menu. +// A roster and nothing else: status at a glance, restart when an agent +// is not running. Create-agent lives in the sidebar's "+" menu. import { Badge, @@ -20,11 +20,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { reportError } from "@corbits/error-sink"; import { QueryView } from "@/lib/api-query"; -import { chatKeys, chatPath } from "../chat-path"; +import { chatKeys } from "../chat-path"; import { isAgentNotRunning, listChatAgents, type ChatAgent } from "@/chat/threads-api"; import { describeRestartFailure, redeployWorkbenchAgent } from "../workbench-create"; import { useBench } from "../bench-context"; -import { Link } from "../navigation"; import { useTenantQuery } from "../routines-api"; import { StageTopBar } from "../shell/stage-top-bar"; @@ -108,21 +107,16 @@ export function AgentsRosterList({ - - {status === "not-running" ? ( - - ) : null} - - + ) : null} ); diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 92f1402d8..540a0962c 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,30 +1,35 @@ -// Default land: `/` is a hop onto the person's most recent chat, or the -// new-chat composer when they have none. Home as a dashboard does not -// earn its keep — `/` only exists as this hop. +// Default land: `/` is a hop onto the person's last-visited workbench, +// or the new-workbench picker when no visit is on record. Home as a +// dashboard does not earn its keep — `/` only exists as this hop. The +// workbench listing carries no recency of its own, so array order is never +// treated as "most recent": an unrecorded or stale last id falls through +// to the picker instead of guessing. import { Button, EmptyState, PageShell } from "@corbits/react-ui"; import { WarningCircle } from "@/lib/icons"; import { useQuery } from "@tanstack/react-query"; import { WorkbenchLoadingState } from "@/chat"; -import { listChats } from "@/chat/threads-api"; +import { listWorkbenchTenants, workbenchesQueryKey } from "@/chat/workbench-tenants"; import { useBench } from "../bench-context"; -import { chatKeys, chatPath, NEW_CHAT_PATH } from "../chat-path"; +import { readLastWorkbenchId } from "../last-workbench"; import { useNavigate } from "../navigation"; import { Redirect } from "../redirect"; +import { NEW_WORKBENCH_PATH } from "../routes"; +import { workbenchPath } from "../workbench-path"; export function HomeRoute() { const navigate = useNavigate(); const { selectedTenantId, memberships } = useBench(); - const chats = useQuery({ - queryKey: chatKeys.list(selectedTenantId ?? ""), + const workbenches = useQuery({ + queryKey: workbenchesQueryKey(selectedTenantId ?? "", "workbench"), enabled: selectedTenantId !== null, - queryFn: () => listChats(selectedTenantId ?? ""), + queryFn: () => listWorkbenchTenants(selectedTenantId ?? ""), }); - if (memberships.kind === "error" || chats.isError) { - const cause: unknown = chats.error; + if (memberships.kind === "error" || workbenches.isError) { + const cause: unknown = workbenches.error; const message = memberships.kind === "error" ? memberships.message @@ -35,11 +40,11 @@ export function HomeRoute() { } - title="Couldn't load your chats" + title="Couldn't load your workbenches" description={message} action={ - } /> @@ -47,9 +52,11 @@ export function HomeRoute() { ); } - if (chats.data !== undefined) { - const newest = chats.data[0]; - const to = newest === undefined ? NEW_CHAT_PATH : chatPath(newest.id); + if (workbenches.data !== undefined) { + const lastId = selectedTenantId === null ? null : readLastWorkbenchId(selectedTenantId); + const lastStillThere = + lastId !== null && workbenches.data.some((workbench) => workbench.id === lastId); + const to = lastStillThere && lastId !== null ? workbenchPath(lastId) : NEW_WORKBENCH_PATH; return ; } diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index a88ab0527..003939365 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -800,12 +800,12 @@ function InsightsWorkbenchPageRoute({ readonly benchTenantId: string | null; readonly onOpenRun: (id: string) => void; }) { - const { workbenches, chats, isLoading } = useWorkbenchAndChatLists(benchTenantId); - const resolution = resolveWorkbenchInsightsScope([...workbenches, ...chats], workbenchId); + const { workbenches, isLoading } = useWorkbenchList(benchTenantId); + const resolution = resolveWorkbenchInsightsScope(workbenches, workbenchId); return ; } -function useWorkbenchAndChatLists(tenantId: string | null) { +function useWorkbenchList(tenantId: string | null) { const workbenchesOfKind = useTenantQuery( tenantId === null ? ["tenant", "none", "workbenches", "workbench"] @@ -813,16 +813,8 @@ function useWorkbenchAndChatLists(tenantId: string | null) { tenantId !== null, () => listWorkbenches(tenantId as string, "workbench"), ); - const chatsOfKind = useTenantQuery( - tenantId === null - ? ["tenant", "none", "workbenches", "chat"] - : workbenchesQueryKey(tenantId, "chat"), - tenantId !== null, - () => listWorkbenches(tenantId as string, "chat"), - ); return { workbenches: workbenchesOfKind.kind === "ready" ? workbenchesOfKind.data : [], - chats: chatsOfKind.kind === "ready" ? chatsOfKind.data : [], - isLoading: workbenchesOfKind.kind === "loading" || chatsOfKind.kind === "loading", + isLoading: workbenchesOfKind.kind === "loading", }; } diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx index 3d20ad912..3609bbe01 100644 --- a/apps/web/src/pages/library-page.tsx +++ b/apps/web/src/pages/library-page.tsx @@ -598,7 +598,7 @@ export function LibraryRoute({ path }: { readonly path: string }) { const lastWorkbenchId = selectedTenantId === null ? null : readLastWorkbenchId(selectedTenantId); const workbenchScope = activity.kind === "ready" - ? resolveLibraryWorkbenchScope([...activity.workbenches, ...activity.chats], lastWorkbenchId) + ? resolveLibraryWorkbenchScope(activity.workbenches, lastWorkbenchId) : null; const [scopeOverride, setScopeOverride] = useState<"workbench" | "all" | null>(null); const scope = scopeOverride ?? (workbenchScope !== null ? "workbench" : "all"); diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index 33c75d709..5369f1a17 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -38,6 +38,7 @@ import { createFetchStockHub } from "../needs-converge"; import { usePendingApprovals } from "../pending-approvals"; import { workbenchKeys } from "../chat-path"; import { tenantKeys } from "../query-client"; +import { recordLastWorkbenchId } from "../last-workbench"; import { StageTopBar } from "../shell/stage-top-bar"; import { redeployWorkbenchAgent } from "../workbench-create"; import { WorkbenchSchedulesPanel } from "./workbench-schedules-panel"; @@ -419,6 +420,15 @@ export function WorkbenchRoute({ path }: { readonly path: string }) { const { selectedTenantId } = useBench(); const workbenchTenantId = workbenchIdFromPath(path); + // The recency signal `/` reads: visiting a workbench records it, so home + // lands back here. Guarded inside (not an early return) so the hook + // order stays stable across renders. + useEffect(() => { + if (selectedTenantId !== null && workbenchTenantId !== null) { + recordLastWorkbenchId(selectedTenantId, workbenchTenantId); + } + }, [selectedTenantId, workbenchTenantId]); + if (selectedTenantId === null || workbenchTenantId === null) { return ( diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index d43484148..7d118bdab 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -22,7 +22,6 @@ import { routineSegmentFromPath, } from "./path-ids"; import { WORKBENCH_PATH_PREFIX, isWorkbenchPath } from "./workbench-path"; -import { CHATS_PATH_PREFIX, isChatPath } from "./chat-path"; // Each signed-in page is a dynamic import so Vite emits one chunk per // screen. Static imports here pulled chat-ui, artifact-ui, settings-ui, @@ -37,9 +36,6 @@ const NewWorkbenchPickerRoute = lazy(async () => ({ const WorkbenchRoute = lazy(async () => ({ default: (await import("./pages/workbench-page")).WorkbenchRoute, })); -const ChatThreadRoute = lazy(async () => ({ - default: (await import("./pages/chat-thread-page")).ChatThreadRoute, -})); const WorkflowsRoute = lazy(async () => ({ default: (await import("./pages/routines-page")).RoutinesRoute, })); @@ -122,9 +118,6 @@ export type AppRoute = { // A roster prefix still matches its own nested paths, so the sidebar // footer row stays lit on a detail screen. export function matchesRoute(routePath: string, path: string): boolean { - if (routePath === CHATS_PATH_PREFIX) { - return isChatPath(path); - } if (routePath === WORKBENCH_PATH_PREFIX) { return isWorkbenchPath(path) || path === "/"; } @@ -188,14 +181,6 @@ export const APP_ROUTES: readonly AppRoute[] = [ icon: , render: () => , }, - { - path: CHATS_PATH_PREFIX, - label: "Chats", - icon: , - render: (path: string, navigate: (to: string) => void) => ( - - ), - }, { path: WORKBENCH_PATH_PREFIX, label: "Workbenches", diff --git a/apps/web/src/shell/bench-activity.ts b/apps/web/src/shell/bench-activity.ts index 8956fe02b..f5f33697d 100644 --- a/apps/web/src/shell/bench-activity.ts +++ b/apps/web/src/shell/bench-activity.ts @@ -16,7 +16,6 @@ export type BenchActivityQuery = | { readonly kind: "ready"; readonly workbenches: readonly Workbench[]; - readonly chats: readonly Workbench[]; readonly agents: readonly AgentDefinition[]; readonly routines: readonly RoutineActivityItem[]; }; @@ -37,11 +36,6 @@ export function useBenchActivity(tenantId: string | null): BenchActivityQuery { enabled, queryFn: () => listWorkbenches(key, "workbench"), }); - const chatsQuery = useQuery({ - queryKey: tenantKeys.workbenches(key, "chat"), - enabled, - queryFn: () => listWorkbenches(key, "chat"), - }); const routinesQuery = useQuery({ queryKey: tenantKeys.routineActivity(key), enabled, @@ -55,12 +49,11 @@ export function useBenchActivity(tenantId: string | null): BenchActivityQuery { if (tenantId === null) return { kind: "empty" }; - for (const query of [workbenchesQuery, chatsQuery, routinesQuery, agentsQuery]) { + for (const query of [workbenchesQuery, routinesQuery, agentsQuery]) { if (query.isError) return { kind: "error", message: errorMessage(query.error) }; } if ( workbenchesQuery.data === undefined || - chatsQuery.data === undefined || routinesQuery.data === undefined || agentsQuery.data === undefined ) { @@ -70,7 +63,6 @@ export function useBenchActivity(tenantId: string | null): BenchActivityQuery { return { kind: "ready", workbenches: workbenchesQuery.data, - chats: chatsQuery.data, agents: agentsQuery.data, routines: routinesQuery.data, }; diff --git a/apps/web/src/shell/canvas-column.tsx b/apps/web/src/shell/canvas-column.tsx index 0e694e8c6..f077b060c 100644 --- a/apps/web/src/shell/canvas-column.tsx +++ b/apps/web/src/shell/canvas-column.tsx @@ -19,8 +19,6 @@ import type { ProfileSubject } from "@/chat"; import { ArrowsIn, ArrowsOut, ArrowSquareOut, CaretLeft, UserCircle, X } from "@/lib/icons"; import type { ReactNode } from "react"; -import { useBench } from "../bench-context"; -import { NEW_CHAT_PATH } from "../chat-path"; import type { CanvasArtifactContent, RoutinePanelSubject } from "./canvas-availability"; import { useInsertIntoComposer } from "./composer-insertion"; @@ -83,25 +81,6 @@ export function CanvasColumn({ ); } -/** Messaging someone is composing a chat thread with them: land on the - * composer rather than minting anything here. */ -function messageAction( - tenantId: string | null, - profile: ProfileSubject, - onNavigate: (path: string) => void, - onClose: () => void, -): () => void { - return () => { - if (tenantId === null) { - toast(`Open a workbench to message @${profile.handle}`); - return; - } - // A DM is a chat thread now: compose it on /chats/new. - onNavigate(NEW_CHAT_PATH); - onClose(); - }; -} - /** Insert `@handle` into whichever workbench's composer is on screen — an * honest "nothing to mention into" toast when none is (: no workbench * open, or the settings surface is showing instead of a conversation). */ @@ -195,17 +174,10 @@ export function CanvasPaneHeader({ function profileActions( profile: ProfileSubject, - tenantId: string | null, onClose: () => void, onNavigate: (path: string) => void, insertIntoComposer: (text: string) => boolean, ): readonly ProfileCardAction[] { - const message: ProfileCardAction = { - id: "message", - label: "Message", - tone: "primary", - onClick: messageAction(tenantId, profile, onNavigate, onClose), - }; const mention: ProfileCardAction = { id: "mention", label: "Mention", @@ -223,7 +195,6 @@ function profileActions( // No "Edit agent" hop: `ProfileSubject` carries no workbench id, and // the global `/settings/agents` tab this used to target is gone. return [ - message, mention, { id: "view-runs", @@ -240,7 +211,6 @@ function profileActions( // No "Grants" hop: no deep-link filter exists, and landing on the // unfiltered everyone's-rules list is worse than not offering it. return [ - message, mention, { id: "view-activity", @@ -273,7 +243,6 @@ function ProfileCanvasPane({ readonly onToggleFocus: () => void; readonly onNavigate: (path: string) => void; }) { - const { selectedTenantId } = useBench(); const insertIntoComposer = useInsertIntoComposer(); const sharedWorkbenches = useSharedWorkbenches(); @@ -286,7 +255,7 @@ function ProfileCanvasPane({ initials={profile.initials} statusLabel={profile.kind === "agent" ? "Agent" : "Member"} avatarTone={profile.kind === "agent" ? "agent" : "neutral"} - actions={profileActions(profile, selectedTenantId, onClose, onNavigate, insertIntoComposer)} + actions={profileActions(profile, onClose, onNavigate, insertIntoComposer)} sharedChannels={sharedWorkbenches} /> diff --git a/apps/web/src/shell/sidebar-sections.ts b/apps/web/src/shell/sidebar-sections.ts index 55d41c392..a87407311 100644 --- a/apps/web/src/shell/sidebar-sections.ts +++ b/apps/web/src/shell/sidebar-sections.ts @@ -1,11 +1,8 @@ -// The sidebar's two sections, over stock routes only. Workbenches are the -// bench's own child tenants (stock tenant listing); chats are the person's -// mail threads with one agent each (`@/chat`'s threads-api). +// The sidebar's workbench section, over stock routes only. Workbenches are +// the bench's own child tenants (stock tenant listing). -import { useEffect } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; -import { listChats, subscribeToInbox, type ChatSummary } from "@/chat/threads-api"; import { chatKeys } from "../chat-path"; import { createFetchStockHub, findOwnedTenants, type HubTenant } from "../needs-converge"; @@ -15,7 +12,6 @@ export type SidebarSections = | { readonly kind: "ready"; readonly workbenches: readonly HubTenant[]; - readonly chats: readonly ChatSummary[]; }; async function listChildTenants(tenantId: string): Promise { @@ -26,35 +22,18 @@ async function listChildTenants(tenantId: string): Promise export function useSidebarSections(tenantId: string | null): SidebarSections { const key = tenantId ?? ""; const enabled = tenantId !== null; - const queryClient = useQueryClient(); const workbenches = useQuery({ queryKey: chatKeys.childTenants(key), enabled, queryFn: () => listChildTenants(key), }); - const chats = useQuery({ - queryKey: chatKeys.list(key), - enabled, - queryFn: () => listChats(key), - }); - - // A mailbox event is the only signal that an agent has answered; the - // stream carries no chat identity, so the listing is simply invalidated. - useEffect(() => { - if (tenantId === null) return; - return subscribeToInbox(tenantId, () => { - void queryClient.invalidateQueries({ queryKey: chatKeys.scope(tenantId) }); - }); - }, [tenantId, queryClient]); - if (tenantId === null) return { kind: "ready", workbenches: [], chats: [] }; - for (const query of [workbenches, chats]) { - if (query.isError) { - const cause: unknown = query.error; - return { kind: "error", message: cause instanceof Error ? cause.message : String(cause) }; - } + if (tenantId === null) return { kind: "ready", workbenches: [] }; + if (workbenches.isError) { + const cause: unknown = workbenches.error; + return { kind: "error", message: cause instanceof Error ? cause.message : String(cause) }; } - if (workbenches.data === undefined || chats.data === undefined) return { kind: "loading" }; - return { kind: "ready", workbenches: workbenches.data, chats: chats.data }; + if (workbenches.data === undefined) return { kind: "loading" }; + return { kind: "ready", workbenches: workbenches.data }; } diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx index 4d5fcacfc..3e8664cf0 100644 --- a/apps/web/src/shell/sidebar.tsx +++ b/apps/web/src/shell/sidebar.tsx @@ -23,7 +23,6 @@ import { import { useState } from "react"; import { useBench } from "../bench-context"; -import { NEW_CHAT_PATH } from "../chat-path"; import { CreateAgentPanel } from "../pages/create-agent-panel"; import { AGENTS_PATH_PREFIX } from "../path-ids"; import { matchesRoute, NEW_WORKBENCH_PATH, SETTINGS_PATH } from "../routes"; @@ -49,7 +48,7 @@ export function Sidebar({ {/* Owner's shape: logo with "+" on the first row, the search box (inside the list) below, then Agents and Channels. No header icon cluster — search is the box. The "+" now opens a - dropdown (New Agent / New Chat / New Workbench) instead of + dropdown (New Agent / New Workbench) instead of jumping straight to New chat. */}
@@ -72,7 +71,6 @@ export function Sidebar({ > New Agent - onNavigate(NEW_CHAT_PATH)}>New Chat onNavigate(NEW_WORKBENCH_PATH)}>New Workbench diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index 79009de24..3eb45cb09 100644 --- a/apps/web/src/shell/workbench-list.tsx +++ b/apps/web/src/shell/workbench-list.tsx @@ -1,23 +1,16 @@ -// Two sections, in this order and never mixed: Workbenches above Chats. -// Agent membership is a workbench concept; a chat has exactly one agent. +// One section and nothing else: Workbenches. Agent membership is a +// workbench concept. import { EmptyState, Input, Skeleton } from "@corbits/react-ui"; -import { Check, Hash, MagnifyingGlass } from "@/lib/icons"; +import { Hash, MagnifyingGlass } from "@/lib/icons"; import { useState } from "react"; -import { IdentityAvatar } from "@/chat/avatar"; -import { isChatReplyReady, type ChatSummary } from "@/chat/threads-api"; - import { useBench } from "../bench-context"; -import { chatPath, CHATS_PATH_PREFIX } from "../chat-path"; import { workbenchIdFromPath, workbenchPath } from "../workbench-path"; import type { HubTenant } from "../needs-converge"; import { useSidebarSections } from "./sidebar-sections"; -/** How many chats the section shows before "See all" expands it. */ -const COLLAPSED_CHAT_COUNT = 5; - -export const SIDEBAR_EMPTY_COPY = "No conversations yet"; +export const SIDEBAR_EMPTY_COPY = "No workbenches yet"; function matches(text: string, needle: string): boolean { return text.toLowerCase().includes(needle); @@ -58,48 +51,6 @@ function WorkbenchRow({ ); } -function ChatRow({ - chat, - active, - onSelect, -}: { - readonly chat: ChatSummary; - readonly active: boolean; - readonly onSelect: () => void; -}) { - // A chat is reply-ready once its newest turn is an unseen agent reply; - // opening the chat (readChat/markChatSeen) clears it. - const replyReady = !active && isChatReplyReady(chat); - return ( - - ); -} - export function WorkbenchList({ path, onNavigate, @@ -110,7 +61,6 @@ export function WorkbenchList({ const { selectedTenantId } = useBench(); const sections = useSidebarSections(selectedTenantId); const [query, setQuery] = useState(""); - const [showAllChats, setShowAllChats] = useState(false); if (sections.kind === "loading") { return ( @@ -125,7 +75,7 @@ export function WorkbenchList({ return ( } - title="Couldn't load your conversations" + title="Couldn't load your workbenches" description={sections.message} /> ); @@ -135,28 +85,21 @@ export function WorkbenchList({ const workbenches = sections.workbenches.filter( (tenant) => needle === "" || matches(tenant.name, needle), ); - const chats = sections.chats.filter( - (chat) => needle === "" || matches(chat.title, needle) || matches(chat.agentName, needle), - ); - const visibleChats = showAllChats ? chats : chats.slice(0, COLLAPSED_CHAT_COUNT); const activeWorkbenchId = workbenchIdFromPath(path); - const activeChatId = path.startsWith(`${CHATS_PATH_PREFIX}/`) - ? path.slice(CHATS_PATH_PREFIX.length + 1) - : null; return ( -
+
- {workbenches.length + chats.length === 0 ? ( + {workbenches.length === 0 ? (

{SIDEBAR_EMPTY_COPY}

) : null} @@ -173,29 +116,6 @@ export function WorkbenchList({ ))}
)} - - {chats.length === 0 ? null : ( -
- Chats - {visibleChats.map((chat) => ( - onNavigate(chatPath(chat.id))} - /> - ))} - {chats.length > visibleChats.length || showAllChats ? ( - - ) : null} -
- )}
); } From d188d4b6c29f6c907814345a45f2dd906514d592 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 20 Sep 2026 10:28:37 -0700 Subject: [PATCH 2/5] feat(web): delete standalone chat page and chat threads half --- apps/web/src/agents-api.ts | 4 +- apps/web/src/app.css | 34 +- apps/web/src/chat-path.ts | 37 +-- apps/web/src/chat/index.ts | 2 +- apps/web/src/chat/threads-api.test.ts | 33 -- apps/web/src/chat/threads-api.ts | 312 +----------------- apps/web/src/chat/workbench-tenants.ts | 30 +- apps/web/src/command-palette-provider.tsx | 32 +- apps/web/src/pages/agents-page.tsx | 6 +- apps/web/src/pages/chat-thread-page.tsx | 330 -------------------- apps/web/src/pages/home-page.tsx | 2 +- apps/web/src/pages/insights-page.tsx | 6 +- apps/web/src/pages/new-workbench-picker.tsx | 6 +- apps/web/src/pending-approvals.ts | 7 +- apps/web/src/query-client.ts | 7 +- apps/web/src/shell/bench-activity.ts | 4 +- apps/web/src/shell/sidebar-sections.ts | 4 +- 17 files changed, 69 insertions(+), 787 deletions(-) delete mode 100644 apps/web/src/pages/chat-thread-page.tsx diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index f47b22b7a..538df56d4 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -8,7 +8,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ApiQueryError } from "@/lib/api-query"; import { deployAgentSource, type DeployedAgent, type NewAgentInput } from "./agent-deploy"; -import { chatKeys, workbenchKeys } from "./chat-path"; +import { workbenchKeys } from "./chat-path"; import { tenantKeys } from "./query-client"; export type AgentDefinition = typeof WorkflowDefinitionResponse.infer; @@ -72,7 +72,7 @@ export function useDeployAgentMutation(tenantId: string) { onSuccess: () => { void queryClient.invalidateQueries({ queryKey: tenantKeys.agentDirectory(tenantId) }); void queryClient.invalidateQueries({ queryKey: tenantKeys.visibleAgents(tenantId) }); - void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) }); + void queryClient.invalidateQueries({ queryKey: tenantKeys.agents(tenantId) }); // A deploy from a workbench transcript adds a participant to that workbench. void queryClient.invalidateQueries({ queryKey: workbenchKeys.participants(tenantId) }); }, diff --git a/apps/web/src/app.css b/apps/web/src/app.css index b6326bae1..7a8a245b6 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3731,11 +3731,6 @@ tr.insights-row-clickable:hover { margin: 0; color: var(--destructive); } -.chat-deploy-card-link { - font-size: 0.85rem; - font-weight: 600; - text-decoration: underline; -} .chat-package-rejection-icon { width: 0.9em; height: 0.9em; @@ -3744,7 +3739,7 @@ tr.insights-row-clickable:hover { color: var(--destructive); } -/* Sidebar sections: Workbenches above Chats, each with its own label. */ +/* Sidebar sections: the Workbenches label above the bench's workbench list. */ .shell-panel-section-label { padding: 0.25rem 0.5rem; font-size: 0.7rem; @@ -3753,25 +3748,8 @@ tr.insights-row-clickable:hover { text-transform: uppercase; color: var(--muted-foreground); } -.shell-panel-see-all { - padding: 0.25rem 0.5rem; - text-align: left; - font-size: 0.78rem; - color: var(--muted-foreground); - background: none; - border: 0; - cursor: pointer; -} -.shell-panel-see-all:hover { - color: var(--foreground); -} -/* A chat: transcript above, composer below. */ -.chat-thread-title { - margin: 0 0 1rem; - font-size: 1.1rem; - font-weight: 600; -} +/* A workbench transcript above, composer below. */ .chat-thread-messages { display: flex; flex-direction: column; @@ -3796,14 +3774,6 @@ tr.insights-row-clickable:hover { font-size: 0.82rem; color: var(--destructive); } -.chat-agent-picker { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 1rem; - font-size: 0.82rem; - color: var(--muted-foreground); -} /* Shared composer: transcript-matched text field on the left, a grouped action rail (Send only — no turn cancellation exists yet) on the right. */ diff --git a/apps/web/src/chat-path.ts b/apps/web/src/chat-path.ts index 4f89d8751..7c3a1fcb5 100644 --- a/apps/web/src/chat-path.ts +++ b/apps/web/src/chat-path.ts @@ -1,16 +1,5 @@ -// Chat deep links. `/chats/new` composes a first message to an agent; -// `/chats/:id` is one chat, addressed by its mailbox thread root uid (or -// a local id while the agent has not answered yet). - -/** One key factory for every chat read, so a send can invalidate the - * listing, the transcript, and the agent roster in one call. */ -export const chatKeys = { - scope: (tenantId: string) => ["tenant", tenantId, "chats"] as const, - list: (tenantId: string) => ["tenant", tenantId, "chats", "list"] as const, - agents: (tenantId: string) => ["tenant", tenantId, "chats", "agents"] as const, - one: (tenantId: string, chatId: string) => ["tenant", tenantId, "chats", "one", chatId] as const, - childTenants: (tenantId: string) => ["tenant", tenantId, "child-tenants"] as const, -}; +// Shared react-query keys. A workbench is a child tenant, so its listing +// reads under the tenant scope beside the agent roster. /** One key factory per workbench (a workbench child tenant), so a send * invalidates the workbench's timeline and roster together. */ @@ -19,22 +8,8 @@ export const workbenchKeys = { tenant: (tenantId: string) => ["workbench", tenantId, "tenant"] as const, participants: (tenantId: string) => ["workbench", tenantId, "participants"] as const, timeline: (tenantId: string) => ["workbench", tenantId, "timeline"] as const, + /** The bench's own child tenants (its workbenches), read over the stock + * tenant routes — previously `chatKeys.childTenants`, re-homed here when + * standalone chats were removed. */ + childTenants: (tenantId: string) => ["tenant", tenantId, "child-tenants"] as const, }; - -export const CHATS_PATH_PREFIX = "/chats"; -export const NEW_CHAT_PATH = `${CHATS_PATH_PREFIX}/new`; - -export function chatPath(chatId: string): string { - return `${CHATS_PATH_PREFIX}/${encodeURIComponent(chatId)}`; -} - -export function chatIdFromPath(path: string): string | null { - if (!path.startsWith(`${CHATS_PATH_PREFIX}/`)) return null; - const segment = path.slice(CHATS_PATH_PREFIX.length + 1); - if (segment === "" || segment === "new" || segment.includes("/")) return null; - return decodeURIComponent(segment); -} - -export function isChatPath(path: string): boolean { - return path === CHATS_PATH_PREFIX || path.startsWith(`${CHATS_PATH_PREFIX}/`); -} diff --git a/apps/web/src/chat/index.ts b/apps/web/src/chat/index.ts index f1cad3533..c0d3e6988 100644 --- a/apps/web/src/chat/index.ts +++ b/apps/web/src/chat/index.ts @@ -40,7 +40,7 @@ export { workbenchesQueryKey, workbenchesQueryKeyPrefix, } from "./workbench-tenants"; -export type { Workbench, WorkbenchKind } from "./workbench-tenants"; +export type { Workbench } from "./workbench-tenants"; export { profileSubjectFromParticipant } from "./profile-subject"; export type { ProfileSubject, ProfileParticipant } from "./profile-subject"; diff --git a/apps/web/src/chat/threads-api.test.ts b/apps/web/src/chat/threads-api.test.ts index 5cda60a8e..2e8998734 100644 --- a/apps/web/src/chat/threads-api.test.ts +++ b/apps/web/src/chat/threads-api.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { agentDeploySourceAssetName } from "../agent-deploy"; import { MYRA_SOURCE_CONFIG } from "../myra-source"; import { - chatTitle, displayAgentName, listWorkbenchParticipants, resolveAvatarName, @@ -34,38 +33,6 @@ describe("displayAgentName", () => { }); }); -function meTurn(subject: string, body: string) { - return { - id: "Sent:1", - messageId: "m1", - parentId: undefined, - address: "run_alice@example.com", - author: "me" as const, - subject, - body, - at: "2026-01-01T00:00:00Z", - attachments: [], - }; -} - -describe("chatTitle", () => { - test("titles a chat by the agent's display name, never mail metadata", () => { - expect(chatTitle([meTurn("Deploy the thing", "hi")], "Echo Bot")).toBe("Echo Bot"); - }); - - test("falls back to the opening turn's subject only when the agent can't be resolved", () => { - expect(chatTitle([meTurn("Deploy the thing", "hi")], undefined)).toBe("Deploy the thing"); - }); - - test("falls back further to the opening turn's body when it has no subject", () => { - expect(chatTitle([meTurn("", "hello there")], undefined)).toBe("hello there"); - }); - - test("falls back to a generic label when there is no opening turn and no agent name", () => { - expect(chatTitle([], undefined)).toBe("Untitled chat"); - }); -}); - describe("listWorkbenchParticipants", () => { test("a person's address is their refId lowercased at the workbench's own domain, never email or bare refId", async () => { globalThis.fetch = ((input: RequestInfo | URL) => { diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 6ef64ec9b..0e09f757e 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -1,6 +1,5 @@ -// Chats are mail threads (see docs/chat-mail-threading.md). A chat is keyed -// by its thread root turn's id (the person's opening send), so two chats to -// the same agent stay separate instead of merging into one conversation. +// Mailbox + roster reads behind the workbench: agent sends, the workbench +// timeline, and the deploy roster every workbench surface shares. import { type } from "arktype"; import { WorkflowDeploymentResponse } from "@intx/types"; @@ -56,30 +55,6 @@ export type MailAttachment = { readonly text: string; }; -export type ChatMessage = { - readonly id: string; - readonly author: "me" | "agent"; - readonly authorName: string; - readonly body: string; - readonly at: string; - readonly attachments: readonly MailAttachment[]; -}; - -export type ChatSummary = { - /** Route id: the thread's root turn id (`folder:uid` of its opening - * send) — unique per conversation, unlike the agent it's addressed to. */ - readonly id: string; - readonly title: string; - readonly agentName: string; - readonly preview: string; - readonly lastActivityAt: string; - /** Who sent the newest turn — an "agent" turn is an unread reply until - * the chat is opened. */ - readonly lastAuthor: "me" | "agent"; - /** The newest turn's Message-ID, compared against what was last seen. */ - readonly lastMessageId: string; -}; - // Myra's deploy asset name is not a display name anyone should read; // every other agent's slug renders title-cased ("echo-bot" -> "Echo Bot"). export function displayAgentName(definitionName: string): string { @@ -303,293 +278,14 @@ function extractAddress(raw: string): string { return (/<([^>]+)>/.exec(raw)?.[1] ?? raw).trim(); } -/** Addresses on a frame's `To:` header, unfolded and parsed straight from - * `raw` — the fallback for a person turn, whose `envelope.to` the mailbox - * currently reports empty (library fix in flight). */ -function toHeaderAddresses(raw: string): string[] { - let decoded: string; - try { - decoded = base64ToUtf8(raw); - } catch { - return []; - } - const headers = decoded.replace(/\r\n/g, "\n").split("\n\n")[0] ?? ""; - const unfolded = headers.replace(/\n[ \t]+/g, " "); - const to = /^to:\s*(.+)$/im.exec(unfolded)?.[1]; - return to === undefined ? [] : to.split(",").map((address) => address.trim()); -} - -/** The run address on a message, from whichever side (from/to) carries - * one — a plain address, not just its local part, so it can be matched - * against an agent's full address set. */ -function participantAddress( - envelope: { from: string; to: readonly string[] }, - raw: string, -): string | undefined { - const to = envelope.to.length > 0 ? envelope.to : toHeaderAddresses(raw); - return [envelope.from, ...to] - .map(extractAddress) - .find((address) => address.split("@")[0]?.startsWith("run_")); -} - -type MailTurn = { - readonly id: string; - readonly messageId: string; - /** In-Reply-To, or the newest References entry when that header is - * missing — what ties a reply back to the turn before it. */ - readonly parentId: string | undefined; - readonly address: string; - readonly author: "me" | "agent"; - readonly subject: string; - readonly body: string; - readonly at: string; - readonly attachments: readonly MailAttachment[]; -}; - -/** Every chat turn in one folder: the person's own in `Sent`, the agents' - * in `INBOX`. A message with no run address on either side is not a chat - * turn and is dropped. */ -async function readFolder(tenantId: string, folder: "INBOX" | "Sent"): Promise { - const page = await getJson(`${mailboxPath(tenantId)}?folder=${folder}&limit=100`, InboxPage); - return page.messages.flatMap((message) => { - const address = participantAddress(message.envelope, message.raw); - if (address === undefined) return []; - return [ - { - id: `${folder}:${String(message.uid)}`, - messageId: message.envelope.messageId, - parentId: message.envelope.inReplyTo ?? message.envelope.references.at(-1), - address, - author: folder === "Sent" ? ("me" as const) : ("agent" as const), - subject: message.envelope.subject, - body: frameBody(message.raw), - at: message.envelope.date, - attachments: frameAttachments(message.raw), - }, - ]; - }); -} - -async function readTurns(tenantId: string): Promise { - const [inbox, sent] = await Promise.all([ - readFolder(tenantId, "INBOX"), - readFolder(tenantId, "Sent"), - ]); - return [...inbox, ...sent].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)); -} - -/** Each turn's own id, mapped to the id of its thread's opening turn — - * found by walking In-Reply-To/References back to a turn with no known - * parent. A cycle (shouldn't happen) just stops at the point it's seen. */ -function threadRootIds(turns: readonly MailTurn[]): Map { - const byMessageId = new Map(turns.map((turn) => [turn.messageId, turn])); - const roots = new Map(); - for (const turn of turns) { - let current = turn; - const seen = new Set([turn.messageId]); - for (;;) { - const parent = current.parentId === undefined ? undefined : byMessageId.get(current.parentId); - if (parent === undefined || seen.has(parent.messageId)) break; - seen.add(parent.messageId); - current = parent; - } - roots.set(turn.id, current.id); - } - return roots; -} - // --------------------------------------------------------------------- // Sends // --------------------------------------------------------------------- const SendAccepted = type({ messageId: "string", uid: "number" }); -async function sendToAgent( - tenantId: string, - address: string, - body: string, - inReplyTo: string | undefined, -): Promise<{ readonly messageId: string; readonly uid: number }> { - let response: Response; - try { - response = await fetch(`${mailboxPath(tenantId)}/send`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - to: [address], - subject: body.slice(0, 60), - body, - ...(inReplyTo !== undefined ? { inReplyTo } : {}), - }), - }); - } catch (cause) { - throw new ChatApiError(cause instanceof Error ? cause.message : String(cause)); - } - if (!response.ok) { - throw new ChatApiError( - `The agent could not be reached (${String(response.status)}).`, - response.status, - ); - } - const parsed = SendAccepted(await response.json().catch(() => undefined)); - if (parsed instanceof type.errors) { - throw new ChatApiError(`Unexpected send response: ${parsed.summary}`); - } - return parsed; -} - -/** Starts a brand-new chat with one agent: no `inReplyTo`, so the send opens - * its own thread rather than landing on whatever this agent last answered. - * Returns the chat id to route to — the opening send's own `Sent:`, the - * thread's root turn id. Callers should keep the composer disabled until - * `liveAddress` is set; this still guards against a stale click. */ -export async function startChat( - tenantId: string, - agent: ChatAgent, - content: string, -): Promise { - if (agent.liveAddress === null) { - throw new ChatApiError(`${agent.name} is starting…`); - } - const sent = await sendToAgent(tenantId, agent.liveAddress, content, undefined); - return `Sent:${String(sent.uid)}`; -} - -/** A reply is the same send, threaded onto the chat's newest message, sent - * to the agent's current live run — not whichever run last answered. */ -export async function replyInChat( - tenantId: string, - chat: ChatThread, - content: string, -): Promise { - if (chat.agent.liveAddress === null) { - throw new ChatApiError(`${chat.agent.name} is starting…`); - } - await sendToAgent(tenantId, chat.agent.liveAddress, content, chat.lastMessageId); -} - -// --------------------------------------------------------------------- -// Chat listing and reads -// --------------------------------------------------------------------- - -export type ChatThread = { - readonly id: string; - readonly title: string; - readonly agentName: string; - readonly agent: ChatAgent; - /** The newest turn's Message-ID: what the next reply threads onto. */ - readonly lastMessageId: string | undefined; - readonly messages: readonly ChatMessage[]; -}; - -// Falls back to the person's own opening turn (never an agent reply) only -// when the agent couldn't be resolved at all. -export function chatTitle(turns: readonly MailTurn[], agentName: string | undefined): string { - if (agentName !== undefined) return agentName; - const first = turns.find((turn) => turn.author === "me"); - if (first === undefined) return "Untitled chat"; - return first.subject.length > 0 ? first.subject : first.body.slice(0, 60); -} - -function agentByAddress(agents: readonly ChatAgent[]): Map { - const index = new Map(); - for (const agent of agents) { - for (const address of agent.addresses) index.set(address, agent); - } - return index; -} - -/** Every chat the person has: one per mail thread with an agent, grouped by - * the thread's root turn id, not the agent — an agent's address set spans - * every run it has ever had, so two separate threads to the same agent stay - * two separate chats instead of merging into one. */ -export async function listChats(tenantId: string): Promise { - const [agents, turns] = await Promise.all([listChatAgents(tenantId), readTurns(tenantId)]); - const addressToAgent = agentByAddress(agents); - const rootIds = threadRootIds(turns); - const byThread = new Map(); - for (const turn of turns) { - if (!addressToAgent.has(turn.address)) continue; - const rootId = rootIds.get(turn.id)!; - byThread.set(rootId, [...(byThread.get(rootId) ?? []), turn]); - } - return [...byThread.entries()] - .map(([threadId, rows]) => { - const newest = rows[rows.length - 1]!; - const resolvedName = addressToAgent.get(newest.address)?.name; - const agentName = resolvedName ?? "Unknown agent"; - return { - id: threadId, - title: chatTitle(rows, resolvedName), - agentName, - preview: newest.body.slice(0, 80), - lastActivityAt: newest.at, - lastAuthor: newest.author, - lastMessageId: newest.messageId, - }; - }) - .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt)); -} - -// Reply-ready: tracked per-viewer in localStorage, a convenience not data -// of record, so a missing/blocked store just falls back to "ready". - -const CHAT_SEEN_KEY_PREFIX = "workbench:chat-seen:"; - -/** Marks a chat as read up to its newest turn. Call when a chat is opened. */ -export function markChatSeen(chatId: string, lastMessageId: string | undefined): void { - if (lastMessageId === undefined) return; - try { - localStorage.setItem(`${CHAT_SEEN_KEY_PREFIX}${chatId}`, lastMessageId); - } catch (cause) { - reportError(cause, { operation: "chat_mark_seen", refId: chatId }); - } -} - -/** True when the chat's newest turn is an agent reply that has not yet been - * seen in this browser. */ -export function isChatReplyReady( - summary: Pick, -): boolean { - if (summary.lastAuthor !== "agent") return false; - try { - return localStorage.getItem(`${CHAT_SEEN_KEY_PREFIX}${summary.id}`) !== summary.lastMessageId; - } catch (cause) { - reportError(cause, { operation: "chat_reply_ready_check", refId: summary.id }); - return true; - } -} - -/** One chat's full transcript: every turn in the thread rooted at `chatId`, - * oldest first. */ -export async function readChat(tenantId: string, chatId: string): Promise { - const [agents, turns] = await Promise.all([listChatAgents(tenantId), readTurns(tenantId)]); - const rootIds = threadRootIds(turns); - const rows = turns.filter((turn) => rootIds.get(turn.id) === chatId); - if (rows.length === 0) { - throw new ChatApiError("That chat could not be found.", 404); - } - const addressToAgent = agentByAddress(agents); - const agent = addressToAgent.get(rows[0]!.address); - if (agent === undefined) { - throw new ChatApiError("That agent could not be found.", 404); - } - return { - id: chatId, - title: chatTitle(rows, agent.name), - agentName: agent.name, - agent, - lastMessageId: rows[rows.length - 1]?.messageId, - messages: rows.map((turn) => ({ - id: turn.id, - author: turn.author, - authorName: turn.author === "me" ? "You" : agent.name, - body: turn.body, - at: turn.at, - attachments: turn.attachments, - })), - }; -} +/** Live mailbox updates. The stream carries no thread identity, so a caller + * refetches on any event rather than patching a timeline in place. */ /** Live mailbox updates. The stream carries no chat identity, so a caller * refetches on any event rather than patching a thread in place. */ diff --git a/apps/web/src/chat/workbench-tenants.ts b/apps/web/src/chat/workbench-tenants.ts index 645bb69f1..ddaa6e05b 100644 --- a/apps/web/src/chat/workbench-tenants.ts +++ b/apps/web/src/chat/workbench-tenants.ts @@ -2,14 +2,10 @@ // over the stock tenant routes. import { createFetchStockHub, findOwnedTenants } from "../needs-converge"; -import { listChats } from "./threads-api"; - -export type WorkbenchKind = "workbench" | "chat"; export type Workbench = { readonly id: string; readonly title: string; - readonly kind: WorkbenchKind; readonly slug: string; /** A workbench IS its tenant, so every row links to itself. */ readonly tenancy?: { readonly tenantId: string } | null; @@ -22,8 +18,8 @@ export function workbenchesQueryKeyPrefix(tenantId: string): readonly unknown[] return ["tenant", tenantId, "workbenches"]; } -export function workbenchesQueryKey(tenantId: string, kind: WorkbenchKind): readonly unknown[] { - return [...workbenchesQueryKeyPrefix(tenantId), kind]; +export function workbenchesQueryKey(tenantId: string): readonly unknown[] { + return [...workbenchesQueryKeyPrefix(tenantId)]; } export async function listWorkbenchTenants(tenantId: string): Promise { @@ -33,26 +29,14 @@ export async function listWorkbenchTenants(tenantId: string): Promise ({ id: tenant.id, title: tenant.name, - kind: "workbench" as const, slug: tenant.slug, tenancy: { tenantId: tenant.id }, })); } -/** The two listing surfaces behind one call: workbenches are the bench's - * child tenants, chats are the person's own mail threads. */ -export async function listWorkbenches( - tenantId: string, - kind: WorkbenchKind, -): Promise { - if (kind === "workbench") return listWorkbenchTenants(tenantId); - const chats = await listChats(tenantId); - return chats.map((chat) => ({ - id: chat.id, - title: chat.title, - kind: "chat" as const, - slug: chat.id, - tenancy: { tenantId }, - lastActivityAt: chat.lastActivityAt, - })); +/** The one workbench listing: the bench's child tenants. (Standalone chats + * used to share this module behind a `kind` parameter; they were removed, + * so this is a thin wrapper kept for the shared query-key prefix.) */ +export async function listWorkbenches(tenantId: string): Promise { + return listWorkbenchTenants(tenantId); } diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 6a0b0ccfd..9ced82d12 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -41,6 +41,23 @@ const STATIC_COMMANDS = buildStaticCommands( NAV_ROUTES.map((route) => ({ path: route.path, label: route.label })), ); +// Every id prefix `handleSelect` below has a branch for. A recent whose id +// matches none of these (e.g. `entity:agents:*` from the removed standalone +// chats) would render and then just close the palette on select, so recents +// load drops those entries. +const KNOWN_RECENT_ID_PREFIXES = [ + "route:", + "action:", + "entity:workbenches:", + "entity:routines:", + "entity:skills:", + "entity:library:", +]; + +function isKnownRecentId(id: string): boolean { + return KNOWN_RECENT_ID_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + // Mounted once above `AppShell` so it works from every route, including // one that matches no page. Grouping/scope-parsing lives in // `@/command-palette`; this file only assembles the app's own sources. @@ -70,11 +87,14 @@ export function CommandPaletteProvider({ ); // Recents are per bench: loaded during render when the store changes, so - // the palette never opens on the previous bench's entries. + // the palette never opens on the previous bench's entries. Entries whose + // id no live branch handles (e.g. `entity:agents:*` from the removed + // standalone chats) are dropped on load — selecting one would just close + // the palette. const [recentsFor, setRecentsFor] = useState(recentsStore); if (recentsFor !== recentsStore) { setRecentsFor(recentsStore); - setRecents(recentsStore?.load() ?? []); + setRecents((recentsStore?.load() ?? []).filter((entry) => isKnownRecentId(entry.id))); } const pushRecent = useCallback( @@ -114,8 +134,8 @@ export function CommandPaletteProvider({ const listWorkbenchesForSearch = useCallback(async () => { if (selectedTenantId === null) return []; const result = await queryClient.ensureQueryData({ - queryKey: tenantKeys.workbenches(selectedTenantId, "workbench"), - queryFn: () => listWorkbenches(selectedTenantId, "workbench"), + queryKey: tenantKeys.workbenches(selectedTenantId), + queryFn: () => listWorkbenches(selectedTenantId), }); return result.map((workbench) => ({ id: workbench.id, @@ -143,7 +163,7 @@ export function CommandPaletteProvider({ // A bare `#`/`@` strips to an empty query, which useEntitySearch never // fetches for by design — so fetch that scope's raw list directly. const bareWorkbenchesQuery = useQuery({ - queryKey: [...tenantKeys.workbenches(selectedTenantId ?? "", "workbench"), "bare-scope"], + queryKey: [...tenantKeys.workbenches(selectedTenantId ?? ""), "bare-scope"], enabled: bareScopeKind === "workbenches" && open && selectedTenantId !== null, queryFn: listWorkbenchesForSearch, }); @@ -291,7 +311,7 @@ export function CommandPaletteProvider({ }, { id: "workbenches", - heading: "Agents & Channels", + heading: "Workbenches", kind: "workbenches", items: workbenchItems, }, diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 1af5ee87f..9072bc667 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -20,7 +20,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { reportError } from "@corbits/error-sink"; import { QueryView } from "@/lib/api-query"; -import { chatKeys } from "../chat-path"; +import { tenantKeys } from "../query-client"; import { isAgentNotRunning, listChatAgents, type ChatAgent } from "@/chat/threads-api"; import { describeRestartFailure, redeployWorkbenchAgent } from "../workbench-create"; import { useBench } from "../bench-context"; @@ -48,7 +48,7 @@ export function AgentsRosterList({ const restart = useMutation({ mutationFn: (agent: ChatAgent) => redeployWorkbenchAgent(tenantId, agent), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) }); + void queryClient.invalidateQueries({ queryKey: tenantKeys.agents(tenantId) }); }, onError: (cause) => { reportError(cause, { operation: "agent_restart", tenantId }); @@ -129,7 +129,7 @@ export function AgentsRosterList({ export function AgentsRoute() { const { selectedTenantId } = useBench(); const agentsQuery = useTenantQuery( - chatKeys.agents(selectedTenantId ?? "none"), + tenantKeys.agents(selectedTenantId ?? "none"), selectedTenantId !== null, () => listChatAgents(selectedTenantId as string), // Keep polling while any agent is not live, so a released→deployed diff --git a/apps/web/src/pages/chat-thread-page.tsx b/apps/web/src/pages/chat-thread-page.tsx deleted file mode 100644 index 9d2311dab..000000000 --- a/apps/web/src/pages/chat-thread-page.tsx +++ /dev/null @@ -1,330 +0,0 @@ -// A chat: one person, one agent, one mail thread. `/chats/new` composes a -// first message (agent picked in the select, or by @tagging one in the -// message itself); `/chats/:id` is the transcript plus a reply box. - -import { Button, EmptyState, PageShell, Select, toast } from "@corbits/react-ui"; -import { WarningCircle } from "@/lib/icons"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; -import { reportError } from "@corbits/error-sink"; - -import { ApprovalRow } from "@/chat/approval-row"; -import { IdentityAvatar } from "@/chat/avatar"; -import { Composer } from "@/chat/composer"; -import { Markdown } from "@/chat/markdown"; -import { MessageAttachments } from "@/chat/message-attachments"; -import { resolveMessagePackage } from "@/chat/deployable-package"; -import { - agentFromMention, - isAgentNotRunning, - listChatAgents, - markChatSeen, - readChat, - replyInChat, - startChat, - subscribeToInbox, - type ChatAgent, -} from "@/chat/threads-api"; -import { MYRA_SOURCE_CONFIG } from "../myra-source"; -import { describeRestartFailure, redeployWorkbenchAgent } from "../workbench-create"; -import { useBench } from "../bench-context"; -import { chatIdFromPath, chatKeys, chatPath, NEW_CHAT_PATH } from "../chat-path"; -import { usePendingApprovals } from "../pending-approvals"; -import { tenantKeys } from "../query-client"; - -function errorText(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - -function NewChat({ - tenantId, - navigate, -}: { - readonly tenantId: string; - readonly navigate: (to: string) => void; -}) { - const queryClient = useQueryClient(); - const agentsQuery = useQuery({ - queryKey: chatKeys.agents(tenantId), - queryFn: () => listChatAgents(tenantId), - // Keep polling while the chosen agent has no live run yet, so the - // composer unlocks itself once a redeploy finishes. - refetchInterval: (query) => { - const agentsData = query.state.data; - if (agentsData === undefined) return false; - const defaultAgent = agentsData.find( - (agent) => agent.name === MYRA_SOURCE_CONFIG.displayName, - ); - return (defaultAgent ?? agentsData[0])?.liveAddress === null ? 3000 : false; - }, - }); - const agents = useMemo(() => agentsQuery.data ?? [], [agentsQuery.data]); - const [selected, setSelected] = useState(""); - - const start = useMutation({ - mutationFn: ({ agent, text }: { readonly agent: ChatAgent; readonly text: string }) => - startChat(tenantId, agent, text), - onSuccess: (chatId) => { - void queryClient.invalidateQueries({ queryKey: chatKeys.scope(tenantId) }); - navigate(chatPath(chatId)); - }, - }); - - const restart = useMutation({ - mutationFn: (agent: ChatAgent) => redeployWorkbenchAgent(tenantId, agent), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) }); - }, - onError: (cause) => { - reportError(cause, { operation: "agent_restart", tenantId }); - toast(describeRestartFailure(cause)); - }, - }); - - const defaultAgent = agents.find((agent) => agent.name === MYRA_SOURCE_CONFIG.displayName); - const chosen = agents.find((agent) => agent.id === selected) ?? defaultAgent ?? agents[0]; - const error: unknown = start.error ?? agentsQuery.error; - const isLive = chosen?.liveAddress !== null && chosen?.liveAddress !== undefined; - const notRunning = chosen !== undefined && isAgentNotRunning(chosen); - const restarting = restart.isPending && restart.variables?.id === chosen?.id; - - return ( - -

New chat

- - {agentsQuery.isSuccess && agents.length === 0 ? ( -

- No agent is deployed yet, so there is nobody to chat with. -

- ) : null} - {error === null || error === undefined ? null : ( -

{errorText(error)}

- )} - {restart.error === null || restart.error === undefined ? null : ( -

{errorText(restart.error)}

- )} - {notRunning && !restarting ? ( - - ) : null} - { - const agent = agentFromMention(text, agents) ?? chosen; - if (agent === undefined) return; - start.mutate({ agent, text }); - }} - /> -
- ); -} - -function ChatTranscript({ - tenantId, - chatId, - navigate, -}: { - readonly tenantId: string; - readonly chatId: string; - readonly navigate: (to: string) => void; -}) { - const queryClient = useQueryClient(); - const chatQuery = useQuery({ - queryKey: chatKeys.one(tenantId, chatId), - queryFn: () => readChat(tenantId, chatId), - // The agent may be mid-redeploy with no live run; keep polling until - // one comes up so the composer unlocks on its own. - refetchInterval: (query) => (query.state.data?.agent.liveAddress === null ? 3000 : false), - }); - const chat = chatQuery.data; - const { selectedPrincipalId } = useBench(); - - // Opening the chat clears its reply-ready state — a render-time write - // (not an effect) since it's an idempotent mirror of already-fetched - // data, not a fetch of its own. - if (chat !== undefined) markChatSeen(chatId, chat.lastMessageId); - - // Invalidates rather than patches: the inbox stream carries no chat - // identity. Also refreshes approvals, since a parked ask sends no mail - // but still ticks the same stream. - useEffect( - () => - subscribeToInbox(tenantId, () => { - void queryClient.invalidateQueries({ queryKey: chatKeys.scope(tenantId) }); - void queryClient.invalidateQueries({ queryKey: tenantKeys.pendingApprovals(tenantId) }); - }), - [tenantId, queryClient], - ); - - // Polling only covers the same "agent still starting" window `chatQuery` - // polls for — once live, the inbox subscription's invalidation is the - // only trigger. - const liveAddress = chat?.agent.liveAddress ?? null; - const approvalsQuery = usePendingApprovals(tenantId, { - refetchInterval: liveAddress === null ? 3000 : false, - }); - const approvals = - approvalsQuery.kind === "ready" && liveAddress !== null - ? approvalsQuery.data.filter((item) => item.agentAddress === liveAddress) - : []; - - const reply = useMutation({ - mutationFn: (text: string) => { - if (chat === undefined) throw new Error("no chat to reply in"); - return replyInChat(tenantId, chat, text); - }, - onSuccess: () => queryClient.invalidateQueries({ queryKey: chatKeys.scope(tenantId) }), - }); - - const restart = useMutation({ - mutationFn: () => { - if (chat === undefined) throw new Error("no agent to restart"); - return redeployWorkbenchAgent(tenantId, chat.agent); - }, - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) }); - void queryClient.invalidateQueries({ queryKey: chatKeys.one(tenantId, chatId) }); - }, - onError: (cause) => { - reportError(cause, { operation: "agent_restart", tenantId }); - toast(describeRestartFailure(cause)); - }, - }); - - if (chatQuery.isError && chat === undefined) { - return ( - - } - title="Couldn't open that chat" - description={errorText(chatQuery.error)} - action={ - - } - /> - - ); - } - if (chat === undefined) return ; - - return ( - -

{chat.agentName}

-
- {chat.messages.map((message) => { - const { pkg, renderedBody } = resolveMessagePackage(message.attachments, message.body); - return ( -
- - - -
- - -
-
- ); - })} -
- {approvals.length === 0 ? null : ( -
    - {approvals.map((item) => ( - - ))} -
- )} - {reply.error === null ? null :

{errorText(reply.error)}

} - {restart.error === null ? null : ( -

{errorText(restart.error)}

- )} - {isAgentNotRunning(chat.agent) && !restart.isPending ? ( - - ) : null} - reply.mutate(text)} - /> -
- ); -} - -export function ChatThreadRoute({ - path, - navigate, -}: { - readonly path: string; - readonly navigate: (to: string) => void; -}) { - const { selectedTenantId } = useBench(); - const chatId = chatIdFromPath(path); - - if (selectedTenantId === null) { - return ( - - } - title="No workbench selected" - description="Pick a workbench before starting a chat." - /> - - ); - } - if (chatId === null) return ; - return ; -} diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 540a0962c..59dd21071 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -23,7 +23,7 @@ export function HomeRoute() { const navigate = useNavigate(); const { selectedTenantId, memberships } = useBench(); const workbenches = useQuery({ - queryKey: workbenchesQueryKey(selectedTenantId ?? "", "workbench"), + queryKey: workbenchesQueryKey(selectedTenantId ?? ""), enabled: selectedTenantId !== null, queryFn: () => listWorkbenchTenants(selectedTenantId ?? ""), }); diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 003939365..99479ff40 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -807,11 +807,9 @@ function InsightsWorkbenchPageRoute({ function useWorkbenchList(tenantId: string | null) { const workbenchesOfKind = useTenantQuery( - tenantId === null - ? ["tenant", "none", "workbenches", "workbench"] - : workbenchesQueryKey(tenantId, "workbench"), + tenantId === null ? ["tenant", "none", "workbenches"] : workbenchesQueryKey(tenantId), tenantId !== null, - () => listWorkbenches(tenantId as string, "workbench"), + () => listWorkbenches(tenantId as string), ); return { workbenches: workbenchesOfKind.kind === "ready" ? workbenchesOfKind.data : [], diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 89106f53f..3c1163b08 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -12,7 +12,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { reportError } from "@corbits/error-sink"; import { useBench } from "../bench-context"; -import { chatKeys } from "../chat-path"; +import { workbenchKeys } from "../chat-path"; import { createWorkbench, WorkbenchCreateError } from "../workbench-create"; import { useNavigate } from "../navigation"; import { StageTopBar } from "../shell/stage-top-bar"; @@ -119,7 +119,7 @@ export function NewWorkbenchPickerRoute() { }), onSuccess: (tenantId, variables) => { void queryClient.invalidateQueries({ - queryKey: chatKeys.childTenants(variables.benchTenantId), + queryKey: workbenchKeys.childTenants(variables.benchTenantId), }); navigate(workbenchPath(tenantId)); }, @@ -133,7 +133,7 @@ export function NewWorkbenchPickerRoute() { // from the workbench, so go there. if (cause instanceof WorkbenchCreateError && cause.tenantId !== undefined) { void queryClient.invalidateQueries({ - queryKey: chatKeys.childTenants(variables.benchTenantId), + queryKey: workbenchKeys.childTenants(variables.benchTenantId), }); navigate(workbenchPath(cause.tenantId)); } diff --git a/apps/web/src/pending-approvals.ts b/apps/web/src/pending-approvals.ts index adb39aec6..03fd5e246 100644 --- a/apps/web/src/pending-approvals.ts +++ b/apps/web/src/pending-approvals.ts @@ -8,7 +8,6 @@ import { type } from "arktype"; import type { APIQuery } from "@/lib/api-query"; import { argumentsSummaryFor, headlineFor, toolNameFor } from "@corbits/approvals/headline"; -import { chatKeys } from "./chat-path"; import { listChatAgents } from "./chat/threads-api"; import { TenantApprovalsSchema, useAPIQuery } from "./api"; import { useBench } from "./bench-context"; @@ -106,12 +105,12 @@ export function usePendingApprovals( queryFn: () => fetchAgentName(tenantId, runId), })), }); - // The chat roster's join (deployments -> runs -> assets) names an agent by + // The roster's join (deployments -> runs -> assets) names an agent by // every address it has ever run under, including a just-created one the // run view above can't resolve (the approver may lack a read grant on that - // run). Same query key the chat page uses, so this is usually a cache hit. + // run). Same key the agents page uses, so this is usually a cache hit. const roster = useQuery({ - queryKey: chatKeys.agents(tenantId ?? ""), + queryKey: tenantKeys.agents(tenantId ?? ""), queryFn: () => listChatAgents(tenantId as string), enabled: tenantId !== null, }); diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index daeadac67..00debd023 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -6,7 +6,6 @@ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { ApiQueryError, UnauthenticatedError } from "@/lib/api-query"; import { workbenchesQueryKey } from "@/chat/workbench-tenants"; -import type { WorkbenchKind } from "@/chat/workbench-tenants"; // A 404 is a stable answer, not a transient failure, so retrying it three // times only delays an honest quiet no-op. @@ -72,6 +71,10 @@ export const tenantKeys = { routineRunHistories: (tenantId: string) => ["tenant", tenantId, "routine-run-histories"] as const, definitions: (tenantId: string) => ["tenant", tenantId, "definitions"] as const, agentDirectory: (tenantId: string) => ["tenant", tenantId, "agents", "directory"] as const, + // The deploy roster (deployments -> runs -> assets join): every surface + // that names an agent by address shares this one cached read. Previously + // `chatKeys.agents`, re-homed here when standalone chats were removed. + agents: (tenantId: string) => ["tenant", tenantId, "agents", "roster"] as const, // Kept apart from `agentDirectory` above, which is a different // surface's own key. visibleAgents: (tenantId: string) => ["tenant", tenantId, "agents", "visible"] as const, @@ -91,7 +94,7 @@ export const tenantKeys = { ["tenant", tenantId, "settings-access", principalId] as const, // Delegates to `@/chat`'s own key builder so every workbench-listing // surface keys against one shape, not a copy that could drift apart. - workbenches: (tenantId: string, kind: WorkbenchKind) => workbenchesQueryKey(tenantId, kind), + workbenches: (tenantId: string) => workbenchesQueryKey(tenantId), // The `feed=fires` route is gone; the key is kept so a future native // fires equivalent has somewhere to rewire. routineActivity: (tenantId: string) => ["tenant", tenantId, "routine-activity"] as const, diff --git a/apps/web/src/shell/bench-activity.ts b/apps/web/src/shell/bench-activity.ts index f5f33697d..9b8669d13 100644 --- a/apps/web/src/shell/bench-activity.ts +++ b/apps/web/src/shell/bench-activity.ts @@ -32,9 +32,9 @@ export function useBenchActivity(tenantId: string | null): BenchActivityQuery { const key = tenantId ?? ""; const workbenchesQuery = useQuery({ - queryKey: tenantKeys.workbenches(key, "workbench"), + queryKey: tenantKeys.workbenches(key), enabled, - queryFn: () => listWorkbenches(key, "workbench"), + queryFn: () => listWorkbenches(key), }); const routinesQuery = useQuery({ queryKey: tenantKeys.routineActivity(key), diff --git a/apps/web/src/shell/sidebar-sections.ts b/apps/web/src/shell/sidebar-sections.ts index a87407311..78a4ffc2a 100644 --- a/apps/web/src/shell/sidebar-sections.ts +++ b/apps/web/src/shell/sidebar-sections.ts @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; -import { chatKeys } from "../chat-path"; +import { workbenchKeys } from "../chat-path"; import { createFetchStockHub, findOwnedTenants, type HubTenant } from "../needs-converge"; export type SidebarSections = @@ -24,7 +24,7 @@ export function useSidebarSections(tenantId: string | null): SidebarSections { const enabled = tenantId !== null; const workbenches = useQuery({ - queryKey: chatKeys.childTenants(key), + queryKey: workbenchKeys.childTenants(key), enabled, queryFn: () => listChildTenants(key), }); From 211d62789e573d46631dbabe2d8b36f166dba24c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 20 Sep 2026 10:35:42 -0700 Subject: [PATCH 3/5] docs: describe workbenches as the only conversation --- ARCHITECTURE.md | 6 +++--- DESIGN.md | 15 +++++++-------- PRODUCT.md | 9 +++------ docs/chat-mail-threading.md | 20 +++----------------- 4 files changed, 16 insertions(+), 34 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fb649428a..25fd232a3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,7 +10,7 @@ handful of domain packages built on top. - **`apps/hub`** — the Interchange hub server. It mounts stock `@intx/hub-api` routes (auth, tenants, principals, grants, workflow deploy/run) plus one composition block that adds Corbits libraries — - `@corbits/mailbox` (chat), `@corbits/memory` (recall), `@corbits/artifacts` + `@corbits/mailbox` (mail), `@corbits/memory` (recall), `@corbits/artifacts` (files), `@corbits/cron` (scheduling), `@corbits/webhooks` (inbound webhook ingress) — and the remaining in-repo packages: `workflows` (author routes) and `agent-directory`. Credentials are stored through @@ -29,7 +29,7 @@ A workbench is a plain Interchange tenant — there is no separate "workbench" tenancy layer. Every user gets a tenant on sign-up; a principal is a person or an agent that can act inside one. -## Chat is mail +## Workbench mail There is no chat-specific data model. A conversation is a thread in `@corbits/mailbox`: `GET /me/inbox/threads` lists threads, `POST @@ -70,7 +70,7 @@ The hub mounts `@corbits/mcp`'s discovery route so an OAuth-protected catalog is read server-side; a deploy reads the stored catalog and never touches the network. Each remote tool becomes an agent tool named `.`, its own grant resource, ask-gated unless the server -marks it read-only. The catalog belongs to the workspace; chat binds all +marks it read-only. The catalog belongs to the workspace; a workbench binds all of it, an agent binds only what it needs, Myra's set is fixed. ## Data diff --git a/DESIGN.md b/DESIGN.md index 200fed586..9e6a88623 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -26,12 +26,10 @@ Top to bottom: 1. **Brand row** — logo mark and a "New workbench" button (`+`) that opens `/new`. -2. **Bench list** — one recency list of conversations, pins first. - Agent DMs (`kind: chat`, one per agent) mix with channels - (`kind: workbench`). Not two labeled empty sections, and not - a list titled Workbenches. Search is built into the list itself. +2. **Bench list** — one recency list of workbenches, pins first. + Not two labeled empty sections. Search is built into the list itself. Nothing page-scoped ever renders in this body; it lists - conversations, not product sections. + workbenches, not product sections. 3. **Footer rail** — Mission Control is pinned above the rail as its own row. The first-run rail below it is Routines, Files, Skills, Agents, Tools, in that order. Insights and Evals join that rail only when @@ -45,8 +43,8 @@ Top to bottom: direct one-click control to Settings, not a menu item — Settings never cost two clicks to reach. -A workbench is a conversation tenant — a DM with one agent, or a -channel with many people and agents. The bench list IS the switcher — +A workbench is a conversation tenant — a channel with many people +and agents. The bench list IS the switcher — its rows are the primary way to move between those conversations, with no separate "switcher" control layered on top. The command palette's hidden "Switch workbench" action is a second door onto the same list, @@ -81,7 +79,8 @@ in-workbench card the workbench itself posts, never a side effect of hosting an agent. The card reads live connection state and flips straight to the repo pick, so there is one walkthrough, not a separate already-connected dialog. The sidebar `+` opens this route. First-run after credential -does not: `/` hops to Myra's one DM (`openAgentDm` / find-or-reopen). +does not: `/` hops to the last workbench visited, or the new-workbench +picker when there is none. There is no parallel Myra home route and no Describe door. **`/inbox` is gone as a page.** The path stays as a redirect diff --git a/PRODUCT.md b/PRODUCT.md index d1489e468..ad6eecf24 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -20,7 +20,7 @@ connecting a mail account, searching the web, reading a linked repo, running a scheduled digest. Anyone can author a new one; Myra reuses an existing teammate's capability before duplicating it. -## Three tool surfaces +## Two tool surfaces - **Myra is static.** She carries one small, fixed set: mail, her working tree, artifacts, memory, and web search. Complex work she hands @@ -28,19 +28,16 @@ existing teammate's capability before duplicating it. - **Agents are granular.** A created agent carries exactly the MCP servers its job needs, so its definition stays polished and every remote tool is its own approval decision. -- **Chat is dynamic.** A chat gets the whole workspace catalog: every - tool asks by default, read-only tools are allowed, and the model only - sees a tool once it has searched for it. ## MCP The workspace has one MCP catalog: connect a server in Tools (Exa is there from day one, keyless) and its tools become callable the same way a native `@corbits/*` tool is, with no separate integration per server. -Chat draws from the whole catalog; an agent is given the servers it +A workbench draws from the whole catalog; an agent is given the servers it needs. -## Chat is mail +## Workbench mail Talking to Myra, or to a teammate, is an ordinary mail thread — no separate "chat" concept to learn, no bespoke conversation model. What you send diff --git a/docs/chat-mail-threading.md b/docs/chat-mail-threading.md index 09e6cb77f..73e15d0a4 100644 --- a/docs/chat-mail-threading.md +++ b/docs/chat-mail-threading.md @@ -1,22 +1,8 @@ -# Chat and workbench mail threading - -`apps/web/src/chat/threads-api.ts` is the only seam between the chat UI and -mail: a chat has exactly one agent, and both sides are durable mail — the -person sends from their own mailbox (keeping a Sent copy), and an agent's -reply lands in the same mailbox's INBOX. No chat-specific hub route exists. - -A chat is keyed by its thread's root turn id (`Sent:` of the person's -opening send), found by walking In-Reply-To/References back from every mail -turn — not the agent's definition asset id, so two separate chats with the -same agent stay separate instead of merging into one conversation. Sends -resolve the agent's current live run's address at send time; an agent's -address set spans every run it has ever had (a hub restart retires the old -run and redeploys under a new one), which keeps a thread's history readable -across a redeploy even though the address on later turns changes. +# Workbench mail threading A workbench is a child tenant, and its conversation is that tenant's own -mailbox — reads are the same stock mailbox routes as a chat, scoped to the -child tenant id. +mailbox — reads and sends go through the stock mailbox routes scoped to +the child tenant id. No workbench-specific hub route exists. ## Workbench send roster From 252e1a4636b529b725aa5a7a0207bdc5dca6ef63 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 20 Sep 2026 10:37:16 -0700 Subject: [PATCH 4/5] docs: align glossary and readme with workbench-only wording --- README.md | 2 +- docs/GLOSSARY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8f0b46a2..acadd8aab 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A multiplayer workspace where people and AI agents share the same threads. Workbench is a web client for [Interchange](https://github.com/faremeter/interchange): -you sign in to a plain Interchange tenant, chat is mail, and Myra, the one +you sign in to a plain Interchange tenant, converse in workbenches, and Myra, the one shipped agent, builds whatever workflows, tools, or skills a job needs. ## Run it diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index ab603f111..53c0edfd8 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -8,7 +8,7 @@ paths keep the platform's own names. | Product term | Platform term | What it is | | ------------- | -------------- | ----------- | | **Workspace** | tenant | The top-level Interchange tenant every signed-up user gets — members, credentials, and grants live here at the root. | -| **Workbench (bench)** | tenant (child) | A child tenant `/new` creates under the workspace — a DM with one agent, or a channel with many people and agents — with its own deployed agents. | +| **Workbench (bench)** | tenant (child) | A child tenant `/new` creates under the workspace — a channel with many people and agents — with its own deployed agents. | | **User** | principal | An identity that can act in a workspace or workbench — human or agent. | | **Agent** | principal (agent) | A named coworker principal, not a template. | | **Myra** | — | The `assistant` workflow: every workspace's default agent. Given a job it can't already do, she authors a new tool, skill, or workflow as code, deploys it with only the access it needs, and uses it. | From 1b7c7d547bd86e19bb5c024cc873095a190d225a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 20 Sep 2026 10:41:59 -0700 Subject: [PATCH 5/5] test(web): pin workbench-only home, recents, and roster keys --- apps/web/src/command-palette-provider.tsx | 5 +- .../src/command-palette-recents-drop.test.ts | 64 +++++++ apps/web/src/pages/home-page.test.tsx | 162 ++++++++++++++++++ apps/web/src/query-client.test.ts | 27 +++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/command-palette-recents-drop.test.ts create mode 100644 apps/web/src/pages/home-page.test.tsx diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 9ced82d12..57923cf06 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -54,7 +54,10 @@ const KNOWN_RECENT_ID_PREFIXES = [ "entity:library:", ]; -function isKnownRecentId(id: string): boolean { +// Exported for the recents-drop keeper test: the palette loads through +// this predicate (`command-palette-recents-drop.test.ts`), so a legacy id +// the predicate rejects can never render or be selected. +export function isKnownRecentId(id: string): boolean { return KNOWN_RECENT_ID_PREFIXES.some((prefix) => id.startsWith(prefix)); } diff --git a/apps/web/src/command-palette-recents-drop.test.ts b/apps/web/src/command-palette-recents-drop.test.ts new file mode 100644 index 000000000..0e9ab5a58 --- /dev/null +++ b/apps/web/src/command-palette-recents-drop.test.ts @@ -0,0 +1,64 @@ +// Keeper for the standalone-chats removal: an `entity:agents:*` recent left +// in storage must never render (and so can never be selected — +// `handleSelect` in the provider has no `entity:agents:` branch, only the +// prefixes `isKnownRecentId` accepts). The store seed below goes through the +// real per-bench store and the real group builder; the filter expression is +// the same one `CommandPaletteProvider` runs on load. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { buildCommandPaletteGroups } from "./command-palette"; +import { recentsStoreForBench } from "./command-palette-recents"; +import { isKnownRecentId } from "./command-palette-provider"; + +const TENANT_ID = "tnt_recents_drop"; +const STORAGE_KEY = `workbench.cmdk-recents:${TENANT_ID}`; + +afterEach(() => { + window.localStorage.removeItem(STORAGE_KEY); +}); + +describe("palette recents-drop", () => { + test("drops legacy entity:agents ids while keeping every live prefix", () => { + expect(isKnownRecentId("entity:agents:agt_1")).toBe(false); + for (const id of [ + "route:/library", + "action:new-workbench", + "entity:workbenches:wb_1", + "entity:routines:def_1", + "entity:skills:research", + "entity:library:art_1", + ]) { + expect(isKnownRecentId(id)).toBe(true); + } + }); + + test("a seeded legacy entry never reaches the rendered recents", () => { + const store = recentsStoreForBench(TENANT_ID); + store.push({ kind: "agents", id: "entity:agents:agt_1", title: "Old Chat Agent" }); + store.push({ + kind: "workbenches", + id: "entity:workbenches:wb_1", + title: "Launch Planning", + subtitle: "Workbench", + }); + + // The exact load expression in `CommandPaletteProvider`. + const loaded = store.load().filter((entry) => isKnownRecentId(entry.id)); + expect(loaded.map((entry) => entry.id)).toEqual(["entity:workbenches:wb_1"]); + + // The exact recent mapping the provider feeds the group builder. + const groups = buildCommandPaletteGroups({ + query: "", + recents: loaded.map((entry) => + entry.subtitle === undefined + ? { id: entry.id, title: entry.title } + : { id: entry.id, title: entry.title, subtitle: entry.subtitle }, + ), + sources: [], + }); + const rendered = groups.flatMap((group) => group.items.map((item) => item.id)); + expect(rendered).toEqual(["entity:workbenches:wb_1"]); + expect(rendered.some((id) => id.startsWith("entity:agents:"))).toBe(false); + }); +}); diff --git a/apps/web/src/pages/home-page.test.tsx b/apps/web/src/pages/home-page.test.tsx new file mode 100644 index 000000000..a28b9d2d4 --- /dev/null +++ b/apps/web/src/pages/home-page.test.tsx @@ -0,0 +1,162 @@ +// Keeper for the workbench-only home: `/` is a hop, never a guess. A live +// last-visited id hops to `/w/:id`; a missing or stale id (and an empty +// list) falls through to the picker — array order is never treated as +// recency. + +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { BenchContext, type BenchState } from "../bench-context"; +import { recordLastWorkbenchId } from "../last-workbench"; +import { NavigationProvider } from "../navigation"; +import { TestQueryProvider } from "../test-query-provider"; +import { HomeRoute } from "./home-page"; + +const TENANT_ID = "tnt_bench"; +const realFetch = globalThis.fetch; + +const benchState: BenchState = { + memberships: { + kind: "ready", + data: { + data: [ + { + principalId: "prn_1", + tenantId: TENANT_ID, + tenantName: "Growth Team Bench", + tenantSlug: "growth-team-bench", + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: null, + }, + }, + benchMemberships: [ + { + principalId: "prn_1", + tenantId: TENANT_ID, + tenantName: "Growth Team Bench", + tenantSlug: "growth-team-bench", + kind: "user", + status: "active", + roles: [], + }, + ], + selectedTenantId: TENANT_ID, + selectedPrincipalId: "prn_1", + selectTenant: () => undefined, + onBenchCreated: () => undefined, +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function ownerMembership(tenantId: string, tenantName: string) { + return { + principalId: `prn_${tenantId}`, + tenantId, + tenantName, + tenantSlug: tenantId, + kind: "user", + status: "active", + roles: [{ id: "rol_owner", name: "owner" }], + }; +} + +function tenantRow(id: string, name: string, parentId: string | null) { + return { id, name, slug: id, domain: "bench.test", parentId }; +} + +/** Serves the `findOwnedTenants` chain (`/api/me/principals`, then one + * `/api/tenants/:id` per owned membership) for the given workbench ids. */ +function stubWorkbenches(workbenchIds: readonly string[]): void { + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (path.startsWith("/api/me/principals")) { + return Promise.resolve( + json({ + data: [ + ownerMembership(TENANT_ID, "Growth Team Bench"), + ...workbenchIds.map((id) => ownerMembership(id, `Workbench ${id}`)), + ], + nextCursor: null, + }), + ); + } + if (path === `/api/tenants/${TENANT_ID}`) { + return Promise.resolve(json(tenantRow(TENANT_ID, "Growth Team Bench", null))); + } + const match = /^\/api\/tenants\/([^/]+)$/.exec(path); + if (match?.[1] !== undefined && workbenchIds.includes(match[1])) { + return Promise.resolve(json(tenantRow(match[1], `Workbench ${match[1]}`, TENANT_ID))); + } + return Promise.resolve(json({ error: { code: "not-found" } }, 404)); + }) as typeof fetch; +} + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root !== null) { + act(() => root?.unmount()); + root = null; + } + container?.remove(); + container = null; + globalThis.fetch = realFetch; + window.sessionStorage.clear(); +}); + +async function navigatedTo(workbenchIds: readonly string[]): Promise { + stubWorkbenches(workbenchIds); + const navigated: string[] = []; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + navigated.push(to)}> + + + + + , + ); + }); + for (let tick = 0; tick < 10; tick += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + return navigated; +} + +describe("HomeRoute", () => { + test("a live last id hops to that workbench", async () => { + recordLastWorkbenchId(TENANT_ID, "wb_2"); + expect(await navigatedTo(["wb_1", "wb_2"])).toEqual(["/w/wb_2"]); + }); + + test("a missing last id falls through to the picker, never the first workbench", async () => { + const navigated = await navigatedTo(["wb_1", "wb_2"]); + expect(navigated).toEqual(["/new"]); + }); + + test("a stale last id falls through to the picker", async () => { + recordLastWorkbenchId(TENANT_ID, "wb_gone"); + expect(await navigatedTo(["wb_1", "wb_2"])).toEqual(["/new"]); + }); + + test("an empty workbench list falls through to the picker", async () => { + expect(await navigatedTo([])).toEqual(["/new"]); + }); +}); diff --git a/apps/web/src/query-client.test.ts b/apps/web/src/query-client.test.ts index fa077cbd6..37ee1a1bd 100644 --- a/apps/web/src/query-client.test.ts +++ b/apps/web/src/query-client.test.ts @@ -3,6 +3,7 @@ // (`toAPIQuery`) is covered in `@/lib/api-query`. import { describe, expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; import { meKeys, pathToQueryKey, tenantKeys } from "./query-client"; @@ -37,3 +38,29 @@ describe("tenantKeys.routineActivity", () => { ); }); }); + +describe("tenantKeys.agents", () => { + // Pins the `chatKeys.agents` migration: the approvals roster + // (`pending-approvals.ts`) subscribes with this key while the agents page + // (`agents-api.ts`) invalidates it, so both sides must spell it the same + // way — one shared cached read, usually a hit. + test("is the exact roster key both sides share, with no chat segment left", () => { + expect(tenantKeys.agents("tnt_1")).toEqual(["tenant", "tnt_1", "agents", "roster"]); + expect((tenantKeys.agents("tnt_1") as readonly unknown[]).join("/")).not.toContain("chat"); + }); + + test("nests under tenantKeys.all so a bench switch drops it", () => { + const all = tenantKeys.all("tnt_1"); + expect(tenantKeys.agents("tnt_1").slice(0, all.length)).toEqual([...all]); + }); + + test("a write under the key is visible to a subscriber and cleared by invalidation", async () => { + const client = new QueryClient(); + client.setQueryData(tenantKeys.agents("tnt_1"), [{ name: "researcher-bot" }]); + expect( + client.getQueryData(tenantKeys.agents("tnt_1")), + ).toEqual([{ name: "researcher-bot" }]); + await client.invalidateQueries({ queryKey: tenantKeys.agents("tnt_1") }); + expect(client.getQueryState(tenantKeys.agents("tnt_1"))?.isInvalidated).toBe(true); + }); +});