mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 15:04:55 +00:00
chore: recover bogota workspace state
This commit is contained in:
parent
5d65013aa5
commit
e08d1b4dca
436 changed files with 172093 additions and 455 deletions
|
|
@ -1,16 +1,33 @@
|
|||
import { useEffect } from "react";
|
||||
import { setFrontendErrorContext } from "@openhandoff/frontend-errors/client";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { setFrontendErrorContext } from "@sandbox-agent/factory-frontend-errors/client";
|
||||
import { type MockBillingPlanId } from "@sandbox-agent/factory-client";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
useNavigate,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import { MockLayout } from "../components/mock-layout";
|
||||
import {
|
||||
MockHostedCheckoutPage,
|
||||
MockOrganizationBillingPage,
|
||||
MockOrganizationImportPage,
|
||||
MockOrganizationSelectorPage,
|
||||
MockOrganizationSettingsPage,
|
||||
MockSignInPage,
|
||||
} from "../components/mock-onboarding";
|
||||
import { defaultWorkspaceId } from "../lib/env";
|
||||
import { handoffWorkbenchClient } from "../lib/workbench";
|
||||
import {
|
||||
activeMockOrganization,
|
||||
activeMockUser,
|
||||
getMockOrganizationById,
|
||||
eligibleOrganizations,
|
||||
useMockAppSnapshot,
|
||||
} from "../lib/mock-app";
|
||||
import { getHandoffWorkbenchClient, resolveRepoRouteHandoffId } from "../lib/workbench";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
|
|
@ -19,13 +36,43 @@ const rootRoute = createRootRoute({
|
|||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: () => (
|
||||
<Navigate
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId: defaultWorkspaceId }}
|
||||
replace
|
||||
/>
|
||||
),
|
||||
component: IndexRoute,
|
||||
});
|
||||
|
||||
const signInRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/signin",
|
||||
component: SignInRoute,
|
||||
});
|
||||
|
||||
const organizationsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations",
|
||||
component: OrganizationsRoute,
|
||||
});
|
||||
|
||||
const organizationImportRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/import",
|
||||
component: OrganizationImportRoute,
|
||||
});
|
||||
|
||||
const organizationSettingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/settings",
|
||||
component: OrganizationSettingsRoute,
|
||||
});
|
||||
|
||||
const organizationBillingRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/billing",
|
||||
component: OrganizationBillingRoute,
|
||||
});
|
||||
|
||||
const organizationCheckoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/checkout/$planId",
|
||||
component: OrganizationCheckoutRoute,
|
||||
});
|
||||
|
||||
const workspaceRoute = createRoute({
|
||||
|
|
@ -57,6 +104,12 @@ const repoRoute = createRoute({
|
|||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
signInRoute,
|
||||
organizationsRoute,
|
||||
organizationImportRoute,
|
||||
organizationSettingsRoute,
|
||||
organizationBillingRoute,
|
||||
organizationCheckoutRoute,
|
||||
workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute]),
|
||||
]);
|
||||
|
||||
|
|
@ -72,32 +125,141 @@ function WorkspaceLayoutRoute() {
|
|||
return <Outlet />;
|
||||
}
|
||||
|
||||
function IndexRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
return <NavigateToMockHome snapshot={snapshot} replace />;
|
||||
}
|
||||
|
||||
function SignInRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
|
||||
if (snapshot.auth.status === "signed_in") {
|
||||
return <NavigateToMockHome snapshot={snapshot} replace />;
|
||||
}
|
||||
|
||||
return <MockSignInPage />;
|
||||
}
|
||||
|
||||
function OrganizationsRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
return <MockOrganizationSelectorPage />;
|
||||
}
|
||||
|
||||
function OrganizationImportRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationImportRoute.useParams().organizationId);
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
return <MockOrganizationImportPage organization={organization} />;
|
||||
}
|
||||
|
||||
function OrganizationSettingsRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationSettingsRoute.useParams().organizationId);
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
return <MockOrganizationSettingsPage organization={organization} />;
|
||||
}
|
||||
|
||||
function OrganizationBillingRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationBillingRoute.useParams().organizationId);
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
return <MockOrganizationBillingPage organization={organization} />;
|
||||
}
|
||||
|
||||
function OrganizationCheckoutRoute() {
|
||||
const { organizationId, planId } = organizationCheckoutRoute.useParams();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationId);
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
if (!isMockBillingPlanId(planId)) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/organizations/$organizationId/billing"
|
||||
params={{ organizationId }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <MockHostedCheckoutPage organization={organization} planId={planId} />;
|
||||
}
|
||||
|
||||
function WorkspaceRoute() {
|
||||
const { workspaceId } = workspaceRoute.useParams();
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
handoffId: undefined,
|
||||
});
|
||||
}, [workspaceId]);
|
||||
return <MockLayout workspaceId={workspaceId} selectedHandoffId={null} selectedSessionId={null} />;
|
||||
|
||||
return (
|
||||
<MockWorkspaceGate workspaceId={workspaceId}>
|
||||
<WorkspaceView workspaceId={workspaceId} selectedHandoffId={null} selectedSessionId={null} />
|
||||
</MockWorkspaceGate>
|
||||
);
|
||||
}
|
||||
|
||||
function HandoffRoute() {
|
||||
const { workspaceId, handoffId } = handoffRoute.useParams();
|
||||
const { sessionId } = handoffRoute.useSearch();
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
handoffId,
|
||||
repoId: undefined,
|
||||
});
|
||||
}, [handoffId, workspaceId]);
|
||||
return <MockLayout workspaceId={workspaceId} selectedHandoffId={handoffId} selectedSessionId={sessionId ?? null} />;
|
||||
|
||||
return (
|
||||
<MockWorkspaceGate workspaceId={workspaceId}>
|
||||
<WorkspaceView workspaceId={workspaceId} selectedHandoffId={handoffId} selectedSessionId={sessionId ?? null} />
|
||||
</MockWorkspaceGate>
|
||||
);
|
||||
}
|
||||
|
||||
function RepoRoute() {
|
||||
const { workspaceId, repoId } = repoRoute.useParams();
|
||||
|
||||
return (
|
||||
<MockWorkspaceGate workspaceId={workspaceId}>
|
||||
<RepoRouteInner workspaceId={workspaceId} repoId={repoId} />
|
||||
</MockWorkspaceGate>
|
||||
);
|
||||
}
|
||||
|
||||
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
|
||||
const client = getHandoffWorkbenchClient(workspaceId);
|
||||
const snapshot = useSyncExternalStore(
|
||||
client.subscribe.bind(client),
|
||||
client.getSnapshot.bind(client),
|
||||
client.getSnapshot.bind(client),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
|
|
@ -105,9 +267,8 @@ function RepoRoute() {
|
|||
repoId,
|
||||
});
|
||||
}, [repoId, workspaceId]);
|
||||
const activeHandoffId = handoffWorkbenchClient.getSnapshot().handoffs.find(
|
||||
(handoff) => handoff.repoId === repoId,
|
||||
)?.id;
|
||||
|
||||
const activeHandoffId = resolveRepoRouteHandoffId(snapshot, repoId);
|
||||
if (!activeHandoffId) {
|
||||
return (
|
||||
<Navigate
|
||||
|
|
@ -130,6 +291,174 @@ function RepoRoute() {
|
|||
);
|
||||
}
|
||||
|
||||
function WorkspaceView({
|
||||
workspaceId,
|
||||
selectedHandoffId,
|
||||
selectedSessionId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
selectedHandoffId: string | null;
|
||||
selectedSessionId: string | null;
|
||||
}) {
|
||||
const client = getHandoffWorkbenchClient(workspaceId);
|
||||
const navigate = useNavigate();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = eligibleOrganizations(snapshot).find((candidate) => candidate.workspaceId === workspaceId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
handoffId: selectedHandoffId ?? undefined,
|
||||
repoId: undefined,
|
||||
});
|
||||
}, [selectedHandoffId, workspaceId]);
|
||||
|
||||
return (
|
||||
<MockLayout
|
||||
client={client}
|
||||
workspaceId={workspaceId}
|
||||
selectedHandoffId={selectedHandoffId}
|
||||
selectedSessionId={selectedSessionId}
|
||||
sidebarTitle={organization?.settings.displayName}
|
||||
sidebarSubtitle={
|
||||
organization
|
||||
? `${organization.billing.planId} plan · ${organization.seatAssignments.length}/${organization.billing.seatsIncluded} seats`
|
||||
: undefined
|
||||
}
|
||||
sidebarActions={
|
||||
organization
|
||||
? [
|
||||
{
|
||||
label: "Switch org",
|
||||
onClick: () => void navigate({ to: "/organizations" }),
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
onClick: () =>
|
||||
void navigate({
|
||||
to: "/organizations/$organizationId/settings",
|
||||
params: { organizationId: organization.id },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Billing",
|
||||
onClick: () =>
|
||||
void navigate({
|
||||
to: "/organizations/$organizationId/billing",
|
||||
params: { organizationId: organization.id },
|
||||
}),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MockWorkspaceGate({
|
||||
workspaceId,
|
||||
children,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
const activeOrganization = activeMockOrganization(snapshot);
|
||||
const workspaceOrganization = eligibleOrganizations(snapshot).find((candidate) => candidate.workspaceId === workspaceId) ?? null;
|
||||
|
||||
if (!workspaceOrganization) {
|
||||
return <NavigateToMockHome snapshot={snapshot} replace />;
|
||||
}
|
||||
|
||||
if (!activeOrganization || activeOrganization.id !== workspaceOrganization.id) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
if (workspaceOrganization.repoImportStatus !== "ready") {
|
||||
return (
|
||||
<Navigate
|
||||
to="/organizations/$organizationId/import"
|
||||
params={{ organizationId: workspaceOrganization.id }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function NavigateToMockHome({
|
||||
snapshot,
|
||||
replace = false,
|
||||
}: {
|
||||
snapshot: ReturnType<typeof useMockAppSnapshot>;
|
||||
replace?: boolean;
|
||||
}) {
|
||||
const activeOrganization = activeMockOrganization(snapshot);
|
||||
const organizations = eligibleOrganizations(snapshot);
|
||||
const targetOrganization =
|
||||
activeOrganization ?? (organizations.length === 1 ? organizations[0] ?? null : null);
|
||||
|
||||
if (snapshot.auth.status === "signed_out" || !activeMockUser(snapshot)) {
|
||||
return <Navigate to="/signin" replace={replace} />;
|
||||
}
|
||||
|
||||
if (!targetOrganization) {
|
||||
return snapshot.users.length === 0 ? (
|
||||
<Navigate
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId: defaultWorkspaceId }}
|
||||
replace={replace}
|
||||
/>
|
||||
) : (
|
||||
<Navigate to="/organizations" replace={replace} />
|
||||
);
|
||||
}
|
||||
|
||||
if (targetOrganization.repoImportStatus !== "ready") {
|
||||
return (
|
||||
<Navigate
|
||||
to="/organizations/$organizationId/import"
|
||||
params={{ organizationId: targetOrganization.id }}
|
||||
replace={replace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId: targetOrganization.workspaceId }}
|
||||
replace={replace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useGuardedMockOrganization(organizationId: string) {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const user = activeMockUser(snapshot);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const organization = getMockOrganizationById(snapshot, organizationId);
|
||||
if (!organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.eligibleOrganizationIds.includes(organization.id) ? organization : null;
|
||||
}
|
||||
|
||||
function isMockBillingPlanId(planId: string): planId is MockBillingPlanId {
|
||||
return planId === "free" || planId === "team" || planId === "enterprise";
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { HandoffWorkbenchClient } from "@sandbox-agent/factory-client";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { DiffContent } from "./mock-layout/diff-content";
|
||||
|
|
@ -8,7 +9,8 @@ import { RightSidebar } from "./mock-layout/right-sidebar";
|
|||
import { Sidebar } from "./mock-layout/sidebar";
|
||||
import { TabStrip } from "./mock-layout/tab-strip";
|
||||
import { TranscriptHeader } from "./mock-layout/transcript-header";
|
||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
||||
import { RightSidebarSkeleton, SidebarSkeleton, TranscriptSkeleton } from "./mock-layout/skeleton";
|
||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, PanelHeaderBar, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
||||
import {
|
||||
buildDisplayMessages,
|
||||
buildHistoryEvents,
|
||||
|
|
@ -22,7 +24,6 @@ import {
|
|||
type Message,
|
||||
type ModelId,
|
||||
} from "./mock-layout/view-model";
|
||||
import { handoffWorkbenchClient } from "../lib/workbench";
|
||||
|
||||
function firstAgentTabId(handoff: Handoff): string | null {
|
||||
return handoff.tabs[0]?.id ?? null;
|
||||
|
|
@ -63,6 +64,7 @@ function sanitizeActiveTabId(
|
|||
}
|
||||
|
||||
const TranscriptPanel = memo(function TranscriptPanel({
|
||||
client,
|
||||
handoff,
|
||||
activeTabId,
|
||||
lastAgentTabId,
|
||||
|
|
@ -72,6 +74,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSetLastAgentTabId,
|
||||
onSetOpenDiffs,
|
||||
}: {
|
||||
client: HandoffWorkbenchClient;
|
||||
handoff: Handoff;
|
||||
activeTabId: string | null;
|
||||
lastAgentTabId: string | null;
|
||||
|
|
@ -172,12 +175,12 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.setSessionUnread({
|
||||
void client.setSessionUnread({
|
||||
handoffId: handoff.id,
|
||||
tabId: activeAgentTab.id,
|
||||
unread: false,
|
||||
});
|
||||
}, [activeAgentTab?.id, activeAgentTab?.unread, handoff.id]);
|
||||
}, [activeAgentTab?.id, activeAgentTab?.unread, client, handoff.id]);
|
||||
|
||||
const startEditingField = useCallback((field: "title" | "branch", value: string) => {
|
||||
setEditingField(field);
|
||||
|
|
@ -197,13 +200,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
}
|
||||
|
||||
if (field === "title") {
|
||||
void handoffWorkbenchClient.renameHandoff({ handoffId: handoff.id, value });
|
||||
void client.renameHandoff({ handoffId: handoff.id, value });
|
||||
} else {
|
||||
void handoffWorkbenchClient.renameBranch({ handoffId: handoff.id, value });
|
||||
void client.renameBranch({ handoffId: handoff.id, value });
|
||||
}
|
||||
setEditingField(null);
|
||||
},
|
||||
[editValue, handoff.id],
|
||||
[client, editValue, handoff.id],
|
||||
);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
|
|
@ -212,14 +215,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.updateDraft({
|
||||
void client.updateDraft({
|
||||
handoffId: handoff.id,
|
||||
tabId: promptTab.id,
|
||||
text: nextText,
|
||||
attachments: nextAttachments,
|
||||
});
|
||||
},
|
||||
[handoff.id, promptTab],
|
||||
[client, handoff.id, promptTab],
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(() => {
|
||||
|
|
@ -230,24 +233,24 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
|
||||
onSetActiveTabId(promptTab.id);
|
||||
onSetLastAgentTabId(promptTab.id);
|
||||
void handoffWorkbenchClient.sendMessage({
|
||||
void client.sendMessage({
|
||||
handoffId: handoff.id,
|
||||
tabId: promptTab.id,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
}, [attachments, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]);
|
||||
}, [attachments, client, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]);
|
||||
|
||||
const stopAgent = useCallback(() => {
|
||||
if (!promptTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.stopAgent({
|
||||
void client.stopAgent({
|
||||
handoffId: handoff.id,
|
||||
tabId: promptTab.id,
|
||||
});
|
||||
}, [handoff.id, promptTab]);
|
||||
}, [client, handoff.id, promptTab]);
|
||||
|
||||
const switchTab = useCallback(
|
||||
(tabId: string) => {
|
||||
|
|
@ -257,7 +260,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSetLastAgentTabId(tabId);
|
||||
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (tab?.unread) {
|
||||
void handoffWorkbenchClient.setSessionUnread({
|
||||
void client.setSessionUnread({
|
||||
handoffId: handoff.id,
|
||||
tabId,
|
||||
unread: false,
|
||||
|
|
@ -266,14 +269,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSyncRouteSession(handoff.id, tabId);
|
||||
}
|
||||
},
|
||||
[handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
|
||||
[client, handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
|
||||
);
|
||||
|
||||
const setTabUnread = useCallback(
|
||||
(tabId: string, unread: boolean) => {
|
||||
void handoffWorkbenchClient.setSessionUnread({ handoffId: handoff.id, tabId, unread });
|
||||
void client.setSessionUnread({ handoffId: handoff.id, tabId, unread });
|
||||
},
|
||||
[handoff.id],
|
||||
[client, handoff.id],
|
||||
);
|
||||
|
||||
const startRenamingTab = useCallback(
|
||||
|
|
@ -305,13 +308,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.renameSession({
|
||||
void client.renameSession({
|
||||
handoffId: handoff.id,
|
||||
tabId: editingSessionTabId,
|
||||
title: trimmedName,
|
||||
});
|
||||
cancelTabRename();
|
||||
}, [cancelTabRename, editingSessionName, editingSessionTabId, handoff.id]);
|
||||
}, [cancelTabRename, client, editingSessionName, editingSessionTabId, handoff.id]);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(tabId: string) => {
|
||||
|
|
@ -326,9 +329,9 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
}
|
||||
|
||||
onSyncRouteSession(handoff.id, nextTabId);
|
||||
void handoffWorkbenchClient.closeTab({ handoffId: handoff.id, tabId });
|
||||
void client.closeTab({ handoffId: handoff.id, tabId });
|
||||
},
|
||||
[activeTabId, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
|
||||
[activeTabId, client, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
|
||||
);
|
||||
|
||||
const closeDiffTab = useCallback(
|
||||
|
|
@ -346,12 +349,12 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
|
||||
const addTab = useCallback(() => {
|
||||
void (async () => {
|
||||
const { tabId } = await handoffWorkbenchClient.addTab({ handoffId: handoff.id });
|
||||
const { tabId } = await client.addTab({ handoffId: handoff.id });
|
||||
onSetLastAgentTabId(tabId);
|
||||
onSetActiveTabId(tabId);
|
||||
onSyncRouteSession(handoff.id, tabId);
|
||||
})();
|
||||
}, [handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]);
|
||||
}, [client, handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]);
|
||||
|
||||
const changeModel = useCallback(
|
||||
(model: ModelId) => {
|
||||
|
|
@ -359,13 +362,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
throw new Error(`Unable to change model for handoff ${handoff.id} without an active prompt tab`);
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.changeModel({
|
||||
void client.changeModel({
|
||||
handoffId: handoff.id,
|
||||
tabId: promptTab.id,
|
||||
model,
|
||||
});
|
||||
},
|
||||
[handoff.id, promptTab],
|
||||
[client, handoff.id, promptTab],
|
||||
);
|
||||
|
||||
const addAttachment = useCallback(
|
||||
|
|
@ -551,17 +554,32 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
});
|
||||
|
||||
interface MockLayoutProps {
|
||||
client: HandoffWorkbenchClient;
|
||||
workspaceId: string;
|
||||
selectedHandoffId?: string | null;
|
||||
selectedSessionId?: string | null;
|
||||
sidebarTitle?: string;
|
||||
sidebarSubtitle?: string;
|
||||
sidebarActions?: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }: MockLayoutProps) {
|
||||
export function MockLayout({
|
||||
client,
|
||||
workspaceId,
|
||||
selectedHandoffId,
|
||||
selectedSessionId,
|
||||
sidebarTitle,
|
||||
sidebarSubtitle,
|
||||
sidebarActions,
|
||||
}: MockLayoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const viewModel = useSyncExternalStore(
|
||||
handoffWorkbenchClient.subscribe.bind(handoffWorkbenchClient),
|
||||
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
|
||||
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
|
||||
client.subscribe.bind(client),
|
||||
client.getSnapshot.bind(client),
|
||||
client.getSnapshot.bind(client),
|
||||
);
|
||||
const handoffs = viewModel.handoffs ?? [];
|
||||
const projects = viewModel.projects ?? [];
|
||||
|
|
@ -661,19 +679,10 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
throw new Error("Cannot create a handoff without an available repo");
|
||||
}
|
||||
|
||||
const task = window.prompt("Describe the handoff task", "Investigate and implement the requested change");
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = window.prompt("Optional handoff title", "")?.trim() || undefined;
|
||||
const branch = window.prompt("Optional branch name", "")?.trim() || undefined;
|
||||
const { handoffId, tabId } = await handoffWorkbenchClient.createHandoff({
|
||||
const { handoffId, tabId } = await client.createHandoff({
|
||||
repoId,
|
||||
task,
|
||||
task: "",
|
||||
model: "gpt-4o",
|
||||
...(title ? { title } : {}),
|
||||
...(branch ? { branch } : {}),
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/handoffs/$handoffId",
|
||||
|
|
@ -684,7 +693,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
search: { sessionId: tabId ?? undefined },
|
||||
});
|
||||
})();
|
||||
}, [activeHandoff?.repoId, navigate, viewModel.repos, workspaceId]);
|
||||
}, [activeHandoff?.repoId, client, navigate, viewModel.repos, workspaceId]);
|
||||
|
||||
const openDiffTab = useCallback(
|
||||
(path: string) => {
|
||||
|
|
@ -726,8 +735,8 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
);
|
||||
|
||||
const markHandoffUnread = useCallback((id: string) => {
|
||||
void handoffWorkbenchClient.markHandoffUnread({ handoffId: id });
|
||||
}, []);
|
||||
void client.markHandoffUnread({ handoffId: id });
|
||||
}, [client]);
|
||||
|
||||
const renameHandoff = useCallback(
|
||||
(id: string) => {
|
||||
|
|
@ -746,9 +755,9 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.renameHandoff({ handoffId: id, value: trimmedTitle });
|
||||
void client.renameHandoff({ handoffId: id, value: trimmedTitle });
|
||||
},
|
||||
[handoffs],
|
||||
[client, handoffs],
|
||||
);
|
||||
|
||||
const renameBranch = useCallback(
|
||||
|
|
@ -768,24 +777,31 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.renameBranch({ handoffId: id, value: trimmedBranch });
|
||||
void client.renameBranch({ handoffId: id, value: trimmedBranch });
|
||||
},
|
||||
[handoffs],
|
||||
[client, handoffs],
|
||||
);
|
||||
|
||||
const archiveHandoff = useCallback(() => {
|
||||
if (!activeHandoff) {
|
||||
throw new Error("Cannot archive without an active handoff");
|
||||
}
|
||||
void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff]);
|
||||
void client.archiveHandoff({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff, client]);
|
||||
|
||||
const publishPr = useCallback(() => {
|
||||
if (!activeHandoff) {
|
||||
throw new Error("Cannot publish PR without an active handoff");
|
||||
}
|
||||
void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff]);
|
||||
void client.publishPr({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff, client]);
|
||||
|
||||
const pushHandoff = useCallback(() => {
|
||||
if (!activeHandoff) {
|
||||
throw new Error("Cannot push without an active handoff");
|
||||
}
|
||||
void client.pushHandoff({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff, client]);
|
||||
|
||||
const revertFile = useCallback(
|
||||
(path: string) => {
|
||||
|
|
@ -804,20 +820,54 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
: current[activeHandoff.id] ?? null,
|
||||
}));
|
||||
|
||||
void handoffWorkbenchClient.revertFile({
|
||||
void client.revertFile({
|
||||
handoffId: activeHandoff.id,
|
||||
path,
|
||||
});
|
||||
},
|
||||
[activeHandoff, lastAgentTabIdByHandoff],
|
||||
[activeHandoff, client, lastAgentTabIdByHandoff],
|
||||
);
|
||||
|
||||
// Show full-page skeleton while the client snapshot is still empty (initial load)
|
||||
const isInitialLoad = handoffs.length === 0 && projects.length === 0 && viewModel.repos.length === 0;
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<Shell>
|
||||
<SPanel>
|
||||
<PanelHeaderBar>
|
||||
<div style={{ flex: 1 }} />
|
||||
</PanelHeaderBar>
|
||||
<ScrollBody>
|
||||
<SidebarSkeleton />
|
||||
</ScrollBody>
|
||||
</SPanel>
|
||||
<SPanel>
|
||||
<PanelHeaderBar>
|
||||
<div style={{ flex: 1 }} />
|
||||
</PanelHeaderBar>
|
||||
<TranscriptSkeleton />
|
||||
</SPanel>
|
||||
<SPanel>
|
||||
<PanelHeaderBar>
|
||||
<div style={{ flex: 1 }} />
|
||||
</PanelHeaderBar>
|
||||
<RightSidebarSkeleton />
|
||||
</SPanel>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeHandoff) {
|
||||
return (
|
||||
<Shell>
|
||||
<Sidebar
|
||||
workspaceId={workspaceId}
|
||||
repoCount={viewModel.repos.length}
|
||||
projects={projects}
|
||||
activeId=""
|
||||
title={sidebarTitle}
|
||||
subtitle={sidebarSubtitle}
|
||||
actions={sidebarActions}
|
||||
onSelect={selectHandoff}
|
||||
onCreate={createHandoff}
|
||||
onMarkUnread={markHandoffUnread}
|
||||
|
|
@ -879,8 +929,13 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
return (
|
||||
<Shell>
|
||||
<Sidebar
|
||||
workspaceId={workspaceId}
|
||||
repoCount={viewModel.repos.length}
|
||||
projects={projects}
|
||||
activeId={activeHandoff.id}
|
||||
title={sidebarTitle}
|
||||
subtitle={sidebarSubtitle}
|
||||
actions={sidebarActions}
|
||||
onSelect={selectHandoff}
|
||||
onCreate={createHandoff}
|
||||
onMarkUnread={markHandoffUnread}
|
||||
|
|
@ -888,6 +943,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
onRenameBranch={renameBranch}
|
||||
/>
|
||||
<TranscriptPanel
|
||||
client={client}
|
||||
handoff={activeHandoff}
|
||||
activeTabId={activeTabId}
|
||||
lastAgentTabId={lastAgentTabId}
|
||||
|
|
@ -908,6 +964,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
activeTabId={activeTabId}
|
||||
onOpenDiff={openDiffTab}
|
||||
onArchive={archiveHandoff}
|
||||
onPush={pushHandoff}
|
||||
onRevertFile={revertFile}
|
||||
onPublishPr={publishPr}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { LabelSmall, LabelXSmall } from "baseui/typography";
|
|||
import { Copy } from "lucide-react";
|
||||
|
||||
import { HistoryMinimap } from "./history-minimap";
|
||||
import { SkeletonBlock, SkeletonLine } from "./skeleton";
|
||||
import { SpinnerDot } from "./ui";
|
||||
import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model";
|
||||
|
||||
|
|
@ -45,21 +46,40 @@ export const MessageList = memo(function MessageList({
|
|||
})}
|
||||
>
|
||||
{tab && messages.length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: 1,
|
||||
minHeight: "200px",
|
||||
gap: "8px",
|
||||
})}
|
||||
>
|
||||
<LabelSmall color={theme.colors.contentTertiary}>
|
||||
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
|
||||
</LabelSmall>
|
||||
</div>
|
||||
tab.created && tab.status === "running" ? (
|
||||
/* New tab that's loading — show message skeleton */
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: "flex", justifyContent: "flex-end" })}>
|
||||
<SkeletonBlock width={200} height={44} borderRadius={16} />
|
||||
</div>
|
||||
<div className={css({ display: "flex", justifyContent: "flex-start" })}>
|
||||
<SkeletonBlock width={280} height={64} borderRadius={16} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: 1,
|
||||
minHeight: "200px",
|
||||
gap: "8px",
|
||||
})}
|
||||
>
|
||||
<LabelSmall color={theme.colors.contentTertiary}>
|
||||
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
|
||||
</LabelSmall>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{messages.map((message) => {
|
||||
const isUser = message.sender === "client";
|
||||
|
|
|
|||
|
|
@ -15,6 +15,49 @@ import {
|
|||
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
|
||||
import { type FileTreeNode, type Handoff, diffTabId } from "./view-model";
|
||||
|
||||
const StatusCard = memo(function StatusCard({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
const [css, theme] = useStyletron();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: "10px 12px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
border: `1px solid ${theme.colors.borderOpaque}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
})}
|
||||
>
|
||||
<LabelSmall color={theme.colors.contentTertiary} $style={{ fontSize: "10px", fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
{label}
|
||||
</LabelSmall>
|
||||
<div
|
||||
className={css({
|
||||
color: theme.colors.contentPrimary,
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
fontFamily: mono ? '"IBM Plex Mono", monospace' : undefined,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
})}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const FileTree = memo(function FileTree({
|
||||
nodes,
|
||||
depth,
|
||||
|
|
@ -106,6 +149,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
activeTabId,
|
||||
onOpenDiff,
|
||||
onArchive,
|
||||
onPush,
|
||||
onRevertFile,
|
||||
onPublishPr,
|
||||
}: {
|
||||
|
|
@ -113,6 +157,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
activeTabId: string | null;
|
||||
onOpenDiff: (path: string) => void;
|
||||
onArchive: () => void;
|
||||
onPush: () => void;
|
||||
onRevertFile: (path: string) => void;
|
||||
onPublishPr: () => void;
|
||||
}) {
|
||||
|
|
@ -121,7 +166,12 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
const contextMenu = useContextMenu();
|
||||
const changedPaths = useMemo(() => new Set(handoff.fileChanges.map((file) => file.path)), [handoff.fileChanges]);
|
||||
const isTerminal = handoff.status === "archived";
|
||||
const canPush = !isTerminal && Boolean(handoff.branch);
|
||||
const pullRequestUrl = handoff.pullRequest != null ? `https://github.com/${handoff.repoName}/pull/${handoff.pullRequest.number}` : null;
|
||||
const pullRequestStatus =
|
||||
handoff.pullRequest == null
|
||||
? "Not published"
|
||||
: `#${handoff.pullRequest.number} ${handoff.pullRequest.status === "draft" ? "Draft" : "Ready"}`;
|
||||
|
||||
const copyFilePath = useCallback(async (path: string) => {
|
||||
try {
|
||||
|
|
@ -183,6 +233,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
{pullRequestUrl ? "Open PR" : "Publish PR"}
|
||||
</button>
|
||||
<button
|
||||
onClick={canPush ? onPush : undefined}
|
||||
className={css({
|
||||
all: "unset",
|
||||
display: "flex",
|
||||
|
|
@ -192,8 +243,9 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 500,
|
||||
color: "#e4e4e7",
|
||||
cursor: "pointer",
|
||||
color: canPush ? "#e4e4e7" : theme.colors.contentTertiary,
|
||||
cursor: canPush ? "pointer" : "not-allowed",
|
||||
opacity: canPush ? 1 : 0.5,
|
||||
transition: "all 200ms ease",
|
||||
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
|
||||
})}
|
||||
|
|
@ -303,6 +355,10 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
</div>
|
||||
|
||||
<ScrollBody>
|
||||
<div className={css({ padding: "12px 14px 0", display: "grid", gap: "8px" })}>
|
||||
<StatusCard label="Branch" value={handoff.branch ?? "Not created"} mono />
|
||||
<StatusCard label="Pull Request" value={pullRequestStatus} />
|
||||
</div>
|
||||
{rightTab === "changes" ? (
|
||||
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
|
||||
{handoff.fileChanges.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useStyletron } from "baseui";
|
|||
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
||||
import { CloudUpload, GitPullRequestDraft, Plus } from "lucide-react";
|
||||
|
||||
import { SidebarSkeleton } from "./skeleton";
|
||||
import { formatRelativeAge, type Handoff, type ProjectSection } from "./view-model";
|
||||
import {
|
||||
ContextMenuOverlay,
|
||||
|
|
@ -14,16 +15,29 @@ import {
|
|||
} from "./ui";
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
workspaceId,
|
||||
repoCount,
|
||||
projects,
|
||||
activeId,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onMarkUnread,
|
||||
onRenameHandoff,
|
||||
onRenameBranch,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
repoCount: number;
|
||||
projects: ProjectSection[];
|
||||
activeId: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onMarkUnread: (id: string) => void;
|
||||
|
|
@ -37,11 +51,17 @@ export const Sidebar = memo(function Sidebar({
|
|||
return (
|
||||
<SPanel>
|
||||
<PanelHeaderBar>
|
||||
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, flex: 1, fontSize: "13px" }}>
|
||||
Handoffs
|
||||
</LabelSmall>
|
||||
<div className={css({ flex: 1, minWidth: 0 })}>
|
||||
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, fontSize: "13px" }}>
|
||||
{title ?? workspaceId}
|
||||
</LabelSmall>
|
||||
<LabelXSmall color={theme.colors.contentTertiary}>
|
||||
{subtitle ?? `${repoCount} ${repoCount === 1 ? "repo" : "repos"}`}
|
||||
</LabelXSmall>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
aria-label="Create handoff"
|
||||
className={css({
|
||||
all: "unset",
|
||||
width: "24px",
|
||||
|
|
@ -60,7 +80,44 @@ export const Sidebar = memo(function Sidebar({
|
|||
<Plus size={14} />
|
||||
</button>
|
||||
</PanelHeaderBar>
|
||||
{actions && actions.length > 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
flexWrap: "wrap",
|
||||
padding: "10px 14px 0",
|
||||
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
|
||||
backgroundColor: theme.colors.backgroundTertiary,
|
||||
})}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className={css({
|
||||
border: `1px solid ${theme.colors.borderOpaque}`,
|
||||
borderRadius: "999px",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.04)",
|
||||
color: theme.colors.contentPrimary,
|
||||
cursor: "pointer",
|
||||
padding: "6px 10px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
marginBottom: "10px",
|
||||
":hover": { backgroundColor: "rgba(255, 255, 255, 0.08)" },
|
||||
})}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<ScrollBody>
|
||||
{projects.length === 0 ? (
|
||||
<SidebarSkeleton />
|
||||
) : (
|
||||
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
|
||||
{projects.map((project) => {
|
||||
const visibleCount = expandedProjects[project.id] ? project.handoffs.length : Math.min(project.handoffs.length, 5);
|
||||
|
|
@ -92,10 +149,22 @@ export const Sidebar = memo(function Sidebar({
|
|||
{project.label}
|
||||
</LabelSmall>
|
||||
<LabelXSmall color={theme.colors.contentTertiary}>
|
||||
{formatRelativeAge(project.updatedAtMs)}
|
||||
{project.updatedAtMs > 0 ? formatRelativeAge(project.updatedAtMs) : "No handoffs"}
|
||||
</LabelXSmall>
|
||||
</div>
|
||||
|
||||
{project.handoffs.length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
padding: "0 12px 10px 34px",
|
||||
color: theme.colors.contentTertiary,
|
||||
fontSize: "12px",
|
||||
})}
|
||||
>
|
||||
No handoffs yet
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{project.handoffs.slice(0, visibleCount).map((handoff) => {
|
||||
const isActive = handoff.id === activeId;
|
||||
const isDim = handoff.status === "archived";
|
||||
|
|
@ -218,6 +287,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollBody>
|
||||
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
|
||||
</SPanel>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import { memo } from "react";
|
||||
|
||||
export const SkeletonLine = memo(function SkeletonLine({
|
||||
width = "100%",
|
||||
height = 12,
|
||||
borderRadius = 4,
|
||||
style,
|
||||
}: {
|
||||
width?: string | number;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
background: "rgba(255, 255, 255, 0.06)",
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.04) 50%, rgba(255,255,255,0) 100%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "hf-shimmer 1.5s ease-in-out infinite",
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const SkeletonCircle = memo(function SkeletonCircle({
|
||||
size = 14,
|
||||
style,
|
||||
}: {
|
||||
size?: number;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return <SkeletonLine width={size} height={size} borderRadius={size} style={style} />;
|
||||
});
|
||||
|
||||
export const SkeletonBlock = memo(function SkeletonBlock({
|
||||
width = "100%",
|
||||
height = 60,
|
||||
borderRadius = 8,
|
||||
style,
|
||||
}: {
|
||||
width?: string | number;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return <SkeletonLine width={width} height={height} borderRadius={borderRadius} style={style} />;
|
||||
});
|
||||
|
||||
/** Sidebar skeleton: header + list of handoff placeholders */
|
||||
export const SidebarSkeleton = memo(function SidebarSkeleton() {
|
||||
return (
|
||||
<div style={{ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{/* Project header skeleton */}
|
||||
<div style={{ padding: "10px 8px 4px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<SkeletonLine width="40%" height={10} />
|
||||
<SkeletonLine width={48} height={10} />
|
||||
</div>
|
||||
{/* Handoff item skeletons */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<SkeletonCircle size={14} />
|
||||
<SkeletonLine width={`${65 - i * 10}%`} height={13} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", paddingLeft: "22px" }}>
|
||||
<SkeletonLine width="30%" height={10} />
|
||||
<SkeletonLine width={32} height={10} style={{ marginLeft: "auto" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** Transcript area skeleton: tab strip + message bubbles */
|
||||
export const TranscriptSkeleton = memo(function TranscriptSkeleton() {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||||
{/* Tab strip skeleton */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
height: "41px",
|
||||
minHeight: "41px",
|
||||
padding: "0 14px",
|
||||
borderBottom: "1px solid rgba(255, 255, 255, 0.12)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<SkeletonCircle size={8} />
|
||||
<SkeletonLine width={64} height={11} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Message skeletons */}
|
||||
<div style={{ padding: "16px 220px 16px 44px", display: "flex", flexDirection: "column", gap: "12px", flex: 1 }}>
|
||||
{/* User message */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div style={{ maxWidth: "60%", display: "flex", flexDirection: "column", gap: "6px", alignItems: "flex-end" }}>
|
||||
<SkeletonBlock width={240} height={48} borderRadius={16} />
|
||||
<SkeletonLine width={60} height={9} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Agent message */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-start" }}>
|
||||
<div style={{ maxWidth: "70%", display: "flex", flexDirection: "column", gap: "6px", alignItems: "flex-start" }}>
|
||||
<SkeletonBlock width={320} height={72} borderRadius={16} />
|
||||
<SkeletonLine width={100} height={9} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Another user message */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div style={{ maxWidth: "60%", display: "flex", flexDirection: "column", gap: "6px", alignItems: "flex-end" }}>
|
||||
<SkeletonBlock width={180} height={40} borderRadius={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** Right sidebar skeleton: status cards + file list */
|
||||
export const RightSidebarSkeleton = memo(function RightSidebarSkeleton() {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||||
{/* Tab bar skeleton */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
height: "41px",
|
||||
minHeight: "41px",
|
||||
padding: "0 16px",
|
||||
borderBottom: "1px solid rgba(255, 255, 255, 0.12)",
|
||||
}}
|
||||
>
|
||||
<SkeletonLine width={56} height={11} />
|
||||
<SkeletonLine width={48} height={11} />
|
||||
</div>
|
||||
{/* Status cards */}
|
||||
<div style={{ padding: "12px 14px 0", display: "grid", gap: "8px" }}>
|
||||
<SkeletonBlock height={52} />
|
||||
<SkeletonBlock height={52} />
|
||||
</div>
|
||||
{/* File changes */}
|
||||
<div style={{ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 10px" }}>
|
||||
<SkeletonCircle size={14} />
|
||||
<SkeletonLine width={`${60 - i * 12}%`} height={12} />
|
||||
<div style={{ display: "flex", gap: "6px", marginLeft: "auto" }}>
|
||||
<SkeletonLine width={24} height={11} />
|
||||
<SkeletonLine width={24} height={11} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkbenchAgentTab } from "@openhandoff/shared";
|
||||
import type { WorkbenchAgentTab } from "@sandbox-agent/factory-shared";
|
||||
import { buildDisplayMessages } from "./view-model";
|
||||
|
||||
function makeTab(transcript: WorkbenchAgentTab["transcript"]): WorkbenchAgentTab {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
WorkbenchParsedDiffLine as ParsedDiffLine,
|
||||
WorkbenchProjectSection as ProjectSection,
|
||||
WorkbenchTranscriptEvent as TranscriptEvent,
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { extractEventText } from "../../features/sessions/model";
|
||||
|
||||
export type { ProjectSection };
|
||||
|
|
|
|||
1034
factory/packages/frontend/src/components/mock-onboarding.tsx
Normal file
1034
factory/packages/frontend/src/components/mock-onboarding.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@openhandoff/shared";
|
||||
import { groupHandoffStatus, type SandboxSessionEventRecord } from "@openhandoff/client";
|
||||
import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/factory-shared";
|
||||
import type { SandboxSessionEventRecord } from "@sandbox-agent/factory-client";
|
||||
import { groupHandoffStatus } from "@sandbox-agent/factory-client/view-model";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "baseui/button";
|
||||
|
|
|
|||
7
factory/packages/frontend/src/factory-client-view-model.d.ts
vendored
Normal file
7
factory/packages/frontend/src/factory-client-view-model.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
declare module "@sandbox-agent/factory-client/view-model" {
|
||||
export {
|
||||
HANDOFF_STATUS_GROUPS,
|
||||
groupHandoffStatus,
|
||||
} from "@sandbox-agent/factory-client";
|
||||
export type { HandoffStatusGroup } from "@sandbox-agent/factory-client";
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffRecord } from "@openhandoff/shared";
|
||||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import { formatDiffStat, groupHandoffsByRepo } from "./model";
|
||||
|
||||
const base: HandoffRecord = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { HandoffRecord } from "@openhandoff/shared";
|
||||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export interface RepoGroup {
|
||||
repoId: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { SandboxSessionRecord } from "@openhandoff/client";
|
||||
import type { SandboxSessionRecord } from "@sandbox-agent/factory-client";
|
||||
import { buildTranscript, extractEventText, resolveSessionSelection } from "./model";
|
||||
|
||||
describe("extractEventText", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { SandboxSessionEventRecord } from "@openhandoff/client";
|
||||
import type { SandboxSessionRecord } from "@openhandoff/client";
|
||||
import type { SandboxSessionEventRecord } from "@sandbox-agent/factory-client";
|
||||
import type { SandboxSessionRecord } from "@sandbox-agent/factory-client";
|
||||
|
||||
function fromPromptArray(value: unknown): string | null {
|
||||
if (!Array.isArray(value)) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createBackendClient } from "@openhandoff/client";
|
||||
import { createBackendClient } from "@sandbox-agent/factory-client/backend";
|
||||
import { backendEndpoint, defaultWorkspaceId } from "./env";
|
||||
|
||||
export const backendClient = createBackendClient({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ function resolveDefaultBackendEndpoint(): string {
|
|||
}
|
||||
|
||||
type FrontendImportMetaEnv = ImportMetaEnv & {
|
||||
OPENHANDOFF_FRONTEND_CLIENT_MODE?: string;
|
||||
FACTORY_FRONTEND_CLIENT_MODE?: string;
|
||||
};
|
||||
|
||||
const frontendEnv = import.meta.env as FrontendImportMetaEnv;
|
||||
|
|
@ -17,7 +17,7 @@ export const backendEndpoint =
|
|||
export const defaultWorkspaceId = import.meta.env.VITE_HF_WORKSPACE?.trim() || "default";
|
||||
|
||||
function resolveFrontendClientMode(): "mock" | "remote" {
|
||||
const raw = frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE?.trim().toLowerCase();
|
||||
const raw = frontendEnv.FACTORY_FRONTEND_CLIENT_MODE?.trim().toLowerCase();
|
||||
if (raw === "mock") {
|
||||
return "mock";
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ function resolveFrontendClientMode(): "mock" | "remote" {
|
|||
return "remote";
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported OPENHANDOFF_FRONTEND_CLIENT_MODE value "${frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`,
|
||||
`Unsupported FACTORY_FRONTEND_CLIENT_MODE value "${frontendEnv.FACTORY_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
40
factory/packages/frontend/src/lib/mock-app.ts
Normal file
40
factory/packages/frontend/src/lib/mock-app.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
createFactoryAppClient,
|
||||
currentFactoryOrganization,
|
||||
currentFactoryUser,
|
||||
eligibleFactoryOrganizations,
|
||||
type FactoryAppClient,
|
||||
} from "@sandbox-agent/factory-client";
|
||||
import type { FactoryAppSnapshot, FactoryOrganization } from "@sandbox-agent/factory-shared";
|
||||
import { backendClient } from "./backend";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
const appClient: FactoryAppClient = createFactoryAppClient({
|
||||
mode: frontendClientMode,
|
||||
backend: frontendClientMode === "remote" ? backendClient : undefined,
|
||||
});
|
||||
|
||||
export function useMockAppSnapshot(): FactoryAppSnapshot {
|
||||
return useSyncExternalStore(
|
||||
appClient.subscribe.bind(appClient),
|
||||
appClient.getSnapshot.bind(appClient),
|
||||
appClient.getSnapshot.bind(appClient),
|
||||
);
|
||||
}
|
||||
|
||||
export function useMockAppClient(): FactoryAppClient {
|
||||
return appClient;
|
||||
}
|
||||
|
||||
export const activeMockUser = currentFactoryUser;
|
||||
export const activeMockOrganization = currentFactoryOrganization;
|
||||
export const eligibleOrganizations = eligibleFactoryOrganizations;
|
||||
|
||||
export function getMockOrganizationById(
|
||||
snapshot: FactoryAppSnapshot,
|
||||
organizationId: string,
|
||||
): FactoryOrganization | null {
|
||||
return snapshot.organizations.find((organization) => organization.id === organizationId) ?? null;
|
||||
}
|
||||
|
||||
8
factory/packages/frontend/src/lib/workbench-routing.ts
Normal file
8
factory/packages/frontend/src/lib/workbench-routing.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export function resolveRepoRouteHandoffId(
|
||||
snapshot: HandoffWorkbenchSnapshot,
|
||||
repoId: string,
|
||||
): string | null {
|
||||
return snapshot.handoffs.find((handoff) => handoff.repoId === repoId)?.id ?? null;
|
||||
}
|
||||
11
factory/packages/frontend/src/lib/workbench-runtime.mock.ts
Normal file
11
factory/packages/frontend/src/lib/workbench-runtime.mock.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import {
|
||||
createHandoffWorkbenchClient,
|
||||
type HandoffWorkbenchClient,
|
||||
} from "@sandbox-agent/factory-client/workbench";
|
||||
|
||||
export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient {
|
||||
return createHandoffWorkbenchClient({
|
||||
mode: "mock",
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import {
|
||||
createHandoffWorkbenchClient,
|
||||
type HandoffWorkbenchClient,
|
||||
} from "@sandbox-agent/factory-client/workbench";
|
||||
import { backendClient } from "./backend";
|
||||
|
||||
export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient {
|
||||
return createHandoffWorkbenchClient({
|
||||
mode: "remote",
|
||||
backend: backendClient,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
38
factory/packages/frontend/src/lib/workbench.test.ts
Normal file
38
factory/packages/frontend/src/lib/workbench.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
|
||||
import { resolveRepoRouteHandoffId } from "./workbench-routing";
|
||||
|
||||
const snapshot: HandoffWorkbenchSnapshot = {
|
||||
workspaceId: "default",
|
||||
repos: [
|
||||
{ id: "repo-a", label: "acme/repo-a" },
|
||||
{ id: "repo-b", label: "acme/repo-b" },
|
||||
],
|
||||
projects: [],
|
||||
handoffs: [
|
||||
{
|
||||
id: "handoff-a",
|
||||
repoId: "repo-a",
|
||||
title: "Alpha",
|
||||
status: "idle",
|
||||
repoName: "acme/repo-a",
|
||||
updatedAtMs: 20,
|
||||
branch: "feature/alpha",
|
||||
pullRequest: null,
|
||||
tabs: [],
|
||||
fileChanges: [],
|
||||
diffs: {},
|
||||
fileTree: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("resolveRepoRouteHandoffId", () => {
|
||||
it("finds the active handoff for a repo route", () => {
|
||||
expect(resolveRepoRouteHandoffId(snapshot, "repo-a")).toBe("handoff-a");
|
||||
});
|
||||
|
||||
it("returns null when a repo has no handoff yet", () => {
|
||||
expect(resolveRepoRouteHandoffId(snapshot, "repo-b")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,18 @@
|
|||
import { createHandoffWorkbenchClient } from "@openhandoff/client";
|
||||
import { backendClient } from "./backend";
|
||||
import { defaultWorkspaceId, frontendClientMode } from "./env";
|
||||
import type { HandoffWorkbenchClient } from "@sandbox-agent/factory-client/workbench";
|
||||
import { createWorkbenchRuntimeClient } from "@workbench-runtime";
|
||||
import { frontendClientMode } from "./env";
|
||||
export { resolveRepoRouteHandoffId } from "./workbench-routing";
|
||||
|
||||
export const handoffWorkbenchClient = createHandoffWorkbenchClient({
|
||||
mode: frontendClientMode,
|
||||
backend: backendClient,
|
||||
workspaceId: defaultWorkspaceId,
|
||||
});
|
||||
const workbenchClientCache = new Map<string, HandoffWorkbenchClient>();
|
||||
|
||||
export function getHandoffWorkbenchClient(workspaceId: string): HandoffWorkbenchClient {
|
||||
const cacheKey = `${frontendClientMode}:${workspaceId}`;
|
||||
const existing = workbenchClientCache.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const client = createWorkbenchRuntimeClient(workspaceId);
|
||||
workbenchClientCache.set(cacheKey, client);
|
||||
return client;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ a {
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes hf-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue