mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 00:04:54 +00:00
parent
400f9a214e
commit
99abb9d42e
171 changed files with 7260 additions and 7342 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { type ReactNode, useEffect } from "react";
|
||||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { useSubscription } from "@sandbox-agent/foundry-client";
|
||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
|
||||
import { MockLayout } from "../components/mock-layout";
|
||||
import {
|
||||
|
|
@ -11,8 +11,8 @@ import {
|
|||
MockOrganizationSettingsPage,
|
||||
MockSignInPage,
|
||||
} from "../components/mock-onboarding";
|
||||
import { defaultWorkspaceId, isMockFrontendClient } from "../lib/env";
|
||||
import { interestManager } from "../lib/interest";
|
||||
import { defaultOrganizationId, isMockFrontendClient } from "../lib/env";
|
||||
import { subscriptionManager } from "../lib/subscription";
|
||||
import { activeMockOrganization, getMockOrganizationById, isAppSnapshotBootstrapping, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
|
|
@ -61,20 +61,20 @@ const organizationCheckoutRoute = createRoute({
|
|||
component: OrganizationCheckoutRoute,
|
||||
});
|
||||
|
||||
const workspaceRoute = createRoute({
|
||||
const organizationRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/workspaces/$workspaceId",
|
||||
component: WorkspaceLayoutRoute,
|
||||
path: "/organizations/$organizationId",
|
||||
component: OrganizationLayoutRoute,
|
||||
});
|
||||
|
||||
const workspaceIndexRoute = createRoute({
|
||||
getParentRoute: () => workspaceRoute,
|
||||
const organizationIndexRoute = createRoute({
|
||||
getParentRoute: () => organizationRoute,
|
||||
path: "/",
|
||||
component: WorkspaceRoute,
|
||||
component: OrganizationRoute,
|
||||
});
|
||||
|
||||
const taskRoute = createRoute({
|
||||
getParentRoute: () => workspaceRoute,
|
||||
getParentRoute: () => organizationRoute,
|
||||
path: "tasks/$taskId",
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
sessionId: typeof search.sessionId === "string" && search.sessionId.trim().length > 0 ? search.sessionId : undefined,
|
||||
|
|
@ -83,7 +83,7 @@ const taskRoute = createRoute({
|
|||
});
|
||||
|
||||
const repoRoute = createRoute({
|
||||
getParentRoute: () => workspaceRoute,
|
||||
getParentRoute: () => organizationRoute,
|
||||
path: "repos/$repoId",
|
||||
component: RepoRoute,
|
||||
});
|
||||
|
|
@ -96,7 +96,7 @@ const routeTree = rootRoute.addChildren([
|
|||
organizationSettingsRoute,
|
||||
organizationBillingRoute,
|
||||
organizationCheckoutRoute,
|
||||
workspaceRoute.addChildren([workspaceIndexRoute, taskRoute, repoRoute]),
|
||||
organizationRoute.addChildren([organizationIndexRoute, taskRoute, repoRoute]),
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
|
|
@ -107,7 +107,7 @@ declare module "@tanstack/react-router" {
|
|||
}
|
||||
}
|
||||
|
||||
function WorkspaceLayoutRoute() {
|
||||
function OrganizationLayoutRoute() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
|
|
@ -142,7 +142,7 @@ function IndexRoute() {
|
|||
|
||||
const activeOrganization = activeMockOrganization(snapshot);
|
||||
if (activeOrganization) {
|
||||
return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId: activeOrganization.workspaceId }} replace />;
|
||||
return <Navigate to="/organizations/$organizationId" params={{ organizationId: activeOrganization.organizationId }} replace />;
|
||||
}
|
||||
|
||||
return <Navigate to="/organizations" replace />;
|
||||
|
|
@ -238,54 +238,54 @@ function OrganizationCheckoutRoute() {
|
|||
return <MockHostedCheckoutPage organization={organization} planId={planId as FoundryBillingPlanId} />;
|
||||
}
|
||||
|
||||
function WorkspaceRoute() {
|
||||
const { workspaceId } = workspaceRoute.useParams();
|
||||
function OrganizationRoute() {
|
||||
const { organizationId } = organizationRoute.useParams();
|
||||
return (
|
||||
<AppWorkspaceGate workspaceId={workspaceId}>
|
||||
<WorkspaceView workspaceId={workspaceId} selectedTaskId={null} selectedSessionId={null} />
|
||||
</AppWorkspaceGate>
|
||||
<AppOrganizationGate organizationId={organizationId}>
|
||||
<OrganizationView organizationId={organizationId} selectedTaskId={null} selectedSessionId={null} />
|
||||
</AppOrganizationGate>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceView({
|
||||
workspaceId,
|
||||
function OrganizationView({
|
||||
organizationId,
|
||||
selectedTaskId,
|
||||
selectedSessionId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
organizationId: string;
|
||||
selectedTaskId: string | null;
|
||||
selectedSessionId: string | null;
|
||||
}) {
|
||||
return <MockLayout workspaceId={workspaceId} selectedTaskId={selectedTaskId} selectedSessionId={selectedSessionId} />;
|
||||
return <MockLayout organizationId={organizationId} selectedTaskId={selectedTaskId} selectedSessionId={selectedSessionId} />;
|
||||
}
|
||||
|
||||
function TaskRoute() {
|
||||
const { workspaceId, taskId } = taskRoute.useParams();
|
||||
const { organizationId, taskId } = taskRoute.useParams();
|
||||
const { sessionId } = taskRoute.useSearch();
|
||||
return (
|
||||
<AppWorkspaceGate workspaceId={workspaceId}>
|
||||
<TaskView workspaceId={workspaceId} taskId={taskId} sessionId={sessionId ?? null} />
|
||||
</AppWorkspaceGate>
|
||||
<AppOrganizationGate organizationId={organizationId}>
|
||||
<TaskView organizationId={organizationId} taskId={taskId} sessionId={sessionId ?? null} />
|
||||
</AppOrganizationGate>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskView({ workspaceId, taskId, sessionId }: { workspaceId: string; taskId: string; sessionId: string | null }) {
|
||||
return <MockLayout workspaceId={workspaceId} selectedTaskId={taskId} selectedSessionId={sessionId} />;
|
||||
function TaskView({ organizationId, taskId, sessionId }: { organizationId: string; taskId: string; sessionId: string | null }) {
|
||||
return <MockLayout organizationId={organizationId} selectedTaskId={taskId} selectedSessionId={sessionId} />;
|
||||
}
|
||||
|
||||
function RepoRoute() {
|
||||
const { workspaceId, repoId } = repoRoute.useParams();
|
||||
const { organizationId, repoId } = repoRoute.useParams();
|
||||
return (
|
||||
<AppWorkspaceGate workspaceId={workspaceId}>
|
||||
<RepoRouteInner workspaceId={workspaceId} repoId={repoId} />
|
||||
</AppWorkspaceGate>
|
||||
<AppOrganizationGate organizationId={organizationId}>
|
||||
<RepoRouteInner organizationId={organizationId} repoId={repoId} />
|
||||
</AppOrganizationGate>
|
||||
);
|
||||
}
|
||||
|
||||
function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; children: ReactNode }) {
|
||||
function AppOrganizationGate({ organizationId, children }: { organizationId: string; children: ReactNode }) {
|
||||
const client = useMockAppClient();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId) ?? null;
|
||||
const organization = snapshot.organizations.find((candidate) => candidate.organizationId === organizationId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (organization && snapshot.activeOrganizationId !== organization.id) {
|
||||
|
|
@ -294,7 +294,7 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil
|
|||
}, [client, organization, snapshot.activeOrganizationId]);
|
||||
|
||||
if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) {
|
||||
return <AppLoadingScreen label="Loading workspace..." />;
|
||||
return <AppLoadingScreen label="Loading organization..." />;
|
||||
}
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
|
|
@ -308,13 +308,15 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil
|
|||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const activeTaskId = workspaceState.data?.taskSummaries.find((task) => task.repoId === repoId)?.id;
|
||||
function RepoRouteInner({ organizationId, repoId }: { organizationId: string; repoId: string }) {
|
||||
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
|
||||
const activeTaskId = organizationState.data?.taskSummaries.find((task) => task.repoId === repoId)?.id;
|
||||
if (!activeTaskId) {
|
||||
return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId }} replace />;
|
||||
return <Navigate to="/organizations/$organizationId" params={{ organizationId }} replace />;
|
||||
}
|
||||
return <Navigate to="/workspaces/$workspaceId/tasks/$taskId" params={{ workspaceId, taskId: activeTaskId }} search={{ sessionId: undefined }} replace />;
|
||||
return (
|
||||
<Navigate to="/organizations/$organizationId/tasks/$taskId" params={{ organizationId, taskId: activeTaskId }} search={{ sessionId: undefined }} replace />
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { memo, useEffect, useMemo, useState } from "react";
|
|||
import { useStyletron } from "baseui";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
import { isMockFrontendClient } from "../lib/env";
|
||||
import { interestManager } from "../lib/interest";
|
||||
import { subscriptionManager } from "../lib/subscription";
|
||||
import type {
|
||||
FoundryAppSnapshot,
|
||||
FoundryOrganization,
|
||||
TaskStatus,
|
||||
TaskWorkbenchSnapshot,
|
||||
|
|
@ -11,11 +12,12 @@ import type {
|
|||
WorkbenchSessionSummary,
|
||||
WorkbenchTaskStatus,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { DebugInterestTopic } from "@sandbox-agent/foundry-client";
|
||||
import { useSubscription } from "@sandbox-agent/foundry-client";
|
||||
import type { DebugSubscriptionTopic } from "@sandbox-agent/foundry-client";
|
||||
import { describeTaskState } from "../features/tasks/status";
|
||||
|
||||
interface DevPanelProps {
|
||||
workspaceId: string;
|
||||
organizationId: string;
|
||||
snapshot: TaskWorkbenchSnapshot;
|
||||
organization?: FoundryOrganization | null;
|
||||
focusedTask?: DevPanelFocusedTask | null;
|
||||
|
|
@ -46,12 +48,12 @@ interface TopicInfo {
|
|||
lastRefresh: number | null;
|
||||
}
|
||||
|
||||
function topicLabel(topic: DebugInterestTopic): string {
|
||||
function topicLabel(topic: DebugSubscriptionTopic): string {
|
||||
switch (topic.topicKey) {
|
||||
case "app":
|
||||
return "App";
|
||||
case "workspace":
|
||||
return "Workspace";
|
||||
case "organization":
|
||||
return "Organization";
|
||||
case "task":
|
||||
return "Task";
|
||||
case "session":
|
||||
|
|
@ -62,7 +64,7 @@ function topicLabel(topic: DebugInterestTopic): string {
|
|||
}
|
||||
|
||||
/** Extract the params portion of a cache key (everything after the first `:`) */
|
||||
function topicParams(topic: DebugInterestTopic): string {
|
||||
function topicParams(topic: DebugSubscriptionTopic): string {
|
||||
const idx = topic.cacheKey.indexOf(":");
|
||||
return idx >= 0 ? topic.cacheKey.slice(idx + 1) : "";
|
||||
}
|
||||
|
|
@ -133,7 +135,7 @@ function thinkingLabel(sinceMs: number | null, now: number): string | null {
|
|||
return `thinking ${elapsed}s`;
|
||||
}
|
||||
|
||||
export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organization, focusedTask }: DevPanelProps) {
|
||||
export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organization, focusedTask }: DevPanelProps) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
|
@ -145,7 +147,7 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
}, []);
|
||||
|
||||
const topics = useMemo((): TopicInfo[] => {
|
||||
return interestManager.listDebugTopics().map((topic) => ({
|
||||
return subscriptionManager.listDebugTopics().map((topic) => ({
|
||||
label: topicLabel(topic),
|
||||
key: topic.cacheKey,
|
||||
params: topicParams(topic),
|
||||
|
|
@ -156,12 +158,18 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
}));
|
||||
}, [now]);
|
||||
|
||||
const appState = useSubscription(subscriptionManager, "app", {});
|
||||
const appSnapshot: FoundryAppSnapshot | null = appState.data ?? null;
|
||||
|
||||
const repos = snapshot.repos ?? [];
|
||||
const prCount = (snapshot.tasks ?? []).filter((task) => task.pullRequest != null).length;
|
||||
const tasks = snapshot.tasks ?? [];
|
||||
const prCount = tasks.filter((task) => task.pullRequest != null).length;
|
||||
const focusedTaskStatus = focusedTask?.runtimeStatus ?? focusedTask?.status ?? null;
|
||||
const focusedTaskState = describeTaskState(focusedTaskStatus, focusedTask?.statusMessage ?? null);
|
||||
const lastWebhookAt = organization?.github.lastWebhookAt ?? null;
|
||||
const hasRecentWebhook = lastWebhookAt != null && now - lastWebhookAt < 5 * 60_000;
|
||||
const totalOrgs = appSnapshot?.organizations.length ?? 0;
|
||||
const authStatus = appSnapshot?.auth.status ?? "unknown";
|
||||
|
||||
const mono = css({
|
||||
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
|
||||
|
|
@ -218,8 +226,8 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
|
||||
{/* Body */}
|
||||
<div className={css({ overflowY: "auto", padding: "6px" })}>
|
||||
{/* Interest Topics */}
|
||||
<Section label="Interest Topics" t={t} css={css}>
|
||||
{/* Subscription Topics */}
|
||||
<Section label="Subscription Topics" t={t} css={css}>
|
||||
{topics.map((topic) => (
|
||||
<div
|
||||
key={topic.key}
|
||||
|
|
@ -256,11 +264,36 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
{topics.length === 0 && <span className={css({ fontSize: "10px", color: t.textMuted })}>No active subscriptions</span>}
|
||||
</Section>
|
||||
|
||||
{/* App State */}
|
||||
<Section label="App" t={t} css={css}>
|
||||
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: authStatus === "signed_in" ? t.statusSuccess : authStatus === "signed_out" ? t.statusError : t.textMuted,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>Auth</span>
|
||||
<span className={`${mono} ${css({ color: authStatus === "signed_in" ? t.statusSuccess : t.statusError })}`}>{authStatus.replace(/_/g, " ")}</span>
|
||||
</div>
|
||||
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
||||
<Stat label="orgs" value={totalOrgs} t={t} css={css} />
|
||||
<Stat label="users" value={appSnapshot?.users.length ?? 0} t={t} css={css} />
|
||||
</div>
|
||||
<div className={`${mono} ${css({ color: t.textTertiary })}`}>app topic: {appState.status}</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Snapshot Summary */}
|
||||
<Section label="Snapshot" t={t} css={css}>
|
||||
<Section label="Organization Snapshot" t={t} css={css}>
|
||||
<div className={css({ display: "flex", gap: "10px", fontSize: "10px" })}>
|
||||
<Stat label="repos" value={repos.length} t={t} css={css} />
|
||||
<Stat label="tasks" value={(snapshot.tasks ?? []).length} t={t} css={css} />
|
||||
<Stat label="tasks" value={tasks.length} t={t} css={css} />
|
||||
<Stat label="PRs" value={prCount} t={t} css={css} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
|
@ -395,7 +428,7 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
{sandbox.sandboxId.slice(0, 16)}
|
||||
{isActive ? " *" : ""}
|
||||
</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{sandbox.providerId}</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{sandbox.sandboxProviderId}</span>
|
||||
</div>
|
||||
{sandbox.cwd && <div className={`${mono} ${css({ color: t.textTertiary, paddingLeft: "11px" })}`}>cwd: {sandbox.cwd}</div>}
|
||||
</div>
|
||||
|
|
@ -408,8 +441,8 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
)}
|
||||
|
||||
{/* GitHub */}
|
||||
{organization && (
|
||||
<Section label="GitHub" t={t} css={css}>
|
||||
<Section label="GitHub" t={t} css={css}>
|
||||
{organization ? (
|
||||
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||
<span
|
||||
|
|
@ -421,7 +454,7 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>App</span>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>App Install</span>
|
||||
<span className={`${mono} ${css({ color: installStatusColor(organization.github.installationStatus, t) })}`}>
|
||||
{organization.github.installationStatus.replace(/_/g, " ")}
|
||||
</span>
|
||||
|
|
@ -438,6 +471,9 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
|
||||
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
|
||||
{organization.github.lastSyncAt != null && (
|
||||
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(organization.github.lastSyncAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||
<span
|
||||
|
|
@ -455,12 +491,12 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
{organization.github.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>never received</span>
|
||||
<span className={`${mono} ${css({ color: t.statusWarning })}`}>never received</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
||||
<Stat label="repos" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||
<Stat label="PRs" value={prCount} t={t} css={css} />
|
||||
<Stat label="imported" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||
<Stat label="catalog" value={organization.repoCatalog.length} t={t} css={css} />
|
||||
</div>
|
||||
{organization.github.connectedAccount && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
|
||||
|
|
@ -469,12 +505,14 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {organization.github.lastSyncLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
) : (
|
||||
<span className={css({ fontSize: "10px", color: t.textMuted })}>No organization data loaded</span>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Workspace */}
|
||||
<Section label="Workspace" t={t} css={css}>
|
||||
<div className={`${mono} ${css({ color: t.textTertiary })}`}>{workspaceId}</div>
|
||||
{/* Organization */}
|
||||
<Section label="Organization" t={t} css={css}>
|
||||
<div className={`${mono} ${css({ color: t.textTertiary })}`}>{organizationId}</div>
|
||||
{organization && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "2px" })}`}>
|
||||
org: {organization.settings.displayName} ({organization.kind})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,7 @@ import { Copy } from "lucide-react";
|
|||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { HistoryMinimap } from "./history-minimap";
|
||||
import { SpinnerDot } from "./ui";
|
||||
import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model";
|
||||
import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentSession, type HistoryEvent, type Message } from "./view-model";
|
||||
|
||||
const TranscriptMessageBody = memo(function TranscriptMessageBody({
|
||||
message,
|
||||
|
|
@ -140,7 +140,7 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({
|
|||
});
|
||||
|
||||
export const MessageList = memo(function MessageList({
|
||||
tab,
|
||||
session,
|
||||
scrollRef,
|
||||
messageRefs,
|
||||
historyEvents,
|
||||
|
|
@ -150,8 +150,9 @@ export const MessageList = memo(function MessageList({
|
|||
copiedMessageId,
|
||||
onCopyMessage,
|
||||
thinkingTimerLabel,
|
||||
pendingMessage,
|
||||
}: {
|
||||
tab: AgentTab | null | undefined;
|
||||
session: AgentSession | null | undefined;
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
historyEvents: HistoryEvent[];
|
||||
|
|
@ -161,24 +162,35 @@ export const MessageList = memo(function MessageList({
|
|||
copiedMessageId: string | null;
|
||||
onCopyMessage: (message: Message) => void;
|
||||
thinkingTimerLabel: string | null;
|
||||
pendingMessage: { text: string; sentAt: number } | null;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const messages = useMemo(() => buildDisplayMessages(tab), [tab]);
|
||||
const PENDING_MESSAGE_ID = "__pending__";
|
||||
const messages = useMemo(() => buildDisplayMessages(session), [session]);
|
||||
const messagesById = useMemo(() => new Map(messages.map((message) => [message.id, message])), [messages]);
|
||||
const messageIndexById = useMemo(() => new Map(messages.map((message, index) => [message.id, index])), [messages]);
|
||||
const transcriptEntries = useMemo<TranscriptEntry[]>(
|
||||
() =>
|
||||
messages.map((message) => ({
|
||||
id: message.id,
|
||||
eventId: message.id,
|
||||
const transcriptEntries = useMemo<TranscriptEntry[]>(() => {
|
||||
const entries: TranscriptEntry[] = messages.map((message) => ({
|
||||
id: message.id,
|
||||
eventId: message.id,
|
||||
kind: "message",
|
||||
time: new Date(message.createdAtMs).toISOString(),
|
||||
role: message.sender === "client" ? "user" : "assistant",
|
||||
text: message.text,
|
||||
}));
|
||||
if (pendingMessage) {
|
||||
entries.push({
|
||||
id: PENDING_MESSAGE_ID,
|
||||
eventId: PENDING_MESSAGE_ID,
|
||||
kind: "message",
|
||||
time: new Date(message.createdAtMs).toISOString(),
|
||||
role: message.sender === "client" ? "user" : "assistant",
|
||||
text: message.text,
|
||||
})),
|
||||
[messages],
|
||||
);
|
||||
time: new Date(pendingMessage.sentAt).toISOString(),
|
||||
role: "user",
|
||||
text: pendingMessage.text,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}, [messages, pendingMessage]);
|
||||
|
||||
const messageContentClass = css({
|
||||
maxWidth: "100%",
|
||||
|
|
@ -256,7 +268,7 @@ export const MessageList = memo(function MessageList({
|
|||
`}</style>
|
||||
{historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null}
|
||||
<div ref={scrollRef} className={scrollContainerClass}>
|
||||
{tab && transcriptEntries.length === 0 ? (
|
||||
{session && transcriptEntries.length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
|
|
@ -269,7 +281,7 @@ export const MessageList = memo(function MessageList({
|
|||
})}
|
||||
>
|
||||
<LabelSmall color={t.textTertiary}>
|
||||
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
|
||||
{!session?.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
|
||||
</LabelSmall>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -280,6 +292,28 @@ export const MessageList = memo(function MessageList({
|
|||
scrollToEntryId={targetMessageId}
|
||||
virtualize
|
||||
renderMessageText={(entry) => {
|
||||
if (entry.id === PENDING_MESSAGE_ID && pendingMessage) {
|
||||
const pendingMsg: Message = {
|
||||
id: PENDING_MESSAGE_ID,
|
||||
sender: "client",
|
||||
text: pendingMessage.text,
|
||||
createdAtMs: pendingMessage.sentAt,
|
||||
event: {
|
||||
id: PENDING_MESSAGE_ID,
|
||||
eventIndex: -1,
|
||||
sessionId: "",
|
||||
connectionId: "",
|
||||
sender: "client",
|
||||
createdAt: pendingMessage.sentAt,
|
||||
payload: {},
|
||||
},
|
||||
};
|
||||
return (
|
||||
<div style={{ opacity: 0.5 }}>
|
||||
<TranscriptMessageBody message={pendingMsg} messageRefs={messageRefs} copiedMessageId={copiedMessageId} onCopyMessage={onCopyMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const message = messagesById.get(entry.id);
|
||||
if (!message) {
|
||||
return null;
|
||||
|
|
@ -296,7 +330,7 @@ export const MessageList = memo(function MessageList({
|
|||
/>
|
||||
);
|
||||
}}
|
||||
isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)}
|
||||
isThinking={Boolean((session && session.status === "running" && transcriptEntries.length > 0) || pendingMessage)}
|
||||
renderThinkingState={() => (
|
||||
<div className={transcriptClassNames.thinkingRow}>
|
||||
<SpinnerDot size={12} />
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ const FileTree = memo(function FileTree({
|
|||
|
||||
export const RightSidebar = memo(function RightSidebar({
|
||||
task,
|
||||
activeTabId,
|
||||
activeSessionId,
|
||||
onOpenDiff,
|
||||
onArchive,
|
||||
onRevertFile,
|
||||
|
|
@ -102,7 +102,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
onToggleSidebar,
|
||||
}: {
|
||||
task: Task;
|
||||
activeTabId: string | null;
|
||||
activeSessionId: string | null;
|
||||
onOpenDiff: (path: string) => void;
|
||||
onArchive: () => void;
|
||||
onRevertFile: (path: string) => void;
|
||||
|
|
@ -400,7 +400,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
</div>
|
||||
) : null}
|
||||
{task.fileChanges.map((file) => {
|
||||
const isActive = activeTabId === diffTabId(file.path);
|
||||
const isActive = activeSessionId === diffTabId(file.path);
|
||||
const TypeIcon = file.type === "A" ? FilePlus : file.type === "D" ? FileX : FileCode;
|
||||
const iconColor = file.type === "A" ? t.statusSuccess : file.type === "D" ? t.statusError : t.textTertiary;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,40 +4,40 @@ import { LabelXSmall } from "baseui/typography";
|
|||
import { FileCode, Plus, X } from "lucide-react";
|
||||
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { ContextMenuOverlay, TabAvatar, useContextMenu } from "./ui";
|
||||
import { ContextMenuOverlay, SessionAvatar, useContextMenu } from "./ui";
|
||||
import { diffTabId, fileName, type Task } from "./view-model";
|
||||
|
||||
export const TabStrip = memo(function TabStrip({
|
||||
export const SessionStrip = memo(function SessionStrip({
|
||||
task,
|
||||
activeTabId,
|
||||
activeSessionId,
|
||||
openDiffs,
|
||||
editingSessionTabId,
|
||||
editingSessionId,
|
||||
editingSessionName,
|
||||
onEditingSessionNameChange,
|
||||
onSwitchTab,
|
||||
onStartRenamingTab,
|
||||
onSwitchSession,
|
||||
onStartRenamingSession,
|
||||
onCommitSessionRename,
|
||||
onCancelSessionRename,
|
||||
onSetTabUnread,
|
||||
onCloseTab,
|
||||
onSetSessionUnread,
|
||||
onCloseSession,
|
||||
onCloseDiffTab,
|
||||
onAddTab,
|
||||
onAddSession,
|
||||
sidebarCollapsed,
|
||||
}: {
|
||||
task: Task;
|
||||
activeTabId: string | null;
|
||||
activeSessionId: string | null;
|
||||
openDiffs: string[];
|
||||
editingSessionTabId: string | null;
|
||||
editingSessionId: string | null;
|
||||
editingSessionName: string;
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onSwitchTab: (tabId: string) => void;
|
||||
onStartRenamingTab: (tabId: string) => void;
|
||||
onSwitchSession: (sessionId: string) => void;
|
||||
onStartRenamingSession: (sessionId: string) => void;
|
||||
onCommitSessionRename: () => void;
|
||||
onCancelSessionRename: () => void;
|
||||
onSetTabUnread: (tabId: string, unread: boolean) => void;
|
||||
onCloseTab: (tabId: string) => void;
|
||||
onSetSessionUnread: (sessionId: string, unread: boolean) => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onCloseDiffTab: (path: string) => void;
|
||||
onAddTab: () => void;
|
||||
onAddSession: () => void;
|
||||
sidebarCollapsed?: boolean;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
|
|
@ -48,8 +48,8 @@ export const TabStrip = memo(function TabStrip({
|
|||
return (
|
||||
<>
|
||||
<style>{`
|
||||
[data-tab]:hover [data-tab-close] { opacity: 0.5 !important; }
|
||||
[data-tab]:hover [data-tab-close]:hover { opacity: 1 !important; }
|
||||
[data-session]:hover [data-session-close] { opacity: 0.5 !important; }
|
||||
[data-session]:hover [data-session-close]:hover { opacity: 1 !important; }
|
||||
`}</style>
|
||||
<div
|
||||
className={css({
|
||||
|
|
@ -67,30 +67,30 @@ export const TabStrip = memo(function TabStrip({
|
|||
"::-webkit-scrollbar": { display: "none" },
|
||||
})}
|
||||
>
|
||||
{task.tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
{task.sessions.map((tab) => {
|
||||
const isActive = tab.id === activeSessionId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
onClick={() => onSwitchTab(tab.id)}
|
||||
onDoubleClick={() => onStartRenamingTab(tab.id)}
|
||||
onClick={() => onSwitchSession(tab.id)}
|
||||
onDoubleClick={() => onStartRenamingSession(tab.id)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1 && task.tabs.length > 1) {
|
||||
if (event.button === 1 && task.sessions.length > 1) {
|
||||
event.preventDefault();
|
||||
onCloseTab(tab.id);
|
||||
onCloseSession(tab.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) =>
|
||||
contextMenu.open(event, [
|
||||
{ label: "Rename session", onClick: () => onStartRenamingTab(tab.id) },
|
||||
{ label: "Rename session", onClick: () => onStartRenamingSession(tab.id) },
|
||||
{
|
||||
label: tab.unread ? "Mark as read" : "Mark as unread",
|
||||
onClick: () => onSetTabUnread(tab.id, !tab.unread),
|
||||
onClick: () => onSetSessionUnread(tab.id, !tab.unread),
|
||||
},
|
||||
...(task.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []),
|
||||
...(task.sessions.length > 1 ? [{ label: "Close session", onClick: () => onCloseSession(tab.id) }] : []),
|
||||
])
|
||||
}
|
||||
data-tab
|
||||
data-session
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -117,9 +117,9 @@ export const TabStrip = memo(function TabStrip({
|
|||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<TabAvatar tab={tab} />
|
||||
<SessionAvatar session={tab} />
|
||||
</div>
|
||||
{editingSessionTabId === tab.id ? (
|
||||
{editingSessionId === tab.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingSessionName}
|
||||
|
|
@ -155,15 +155,15 @@ export const TabStrip = memo(function TabStrip({
|
|||
{tab.sessionName}
|
||||
</LabelXSmall>
|
||||
)}
|
||||
{task.tabs.length > 1 ? (
|
||||
{task.sessions.length > 1 ? (
|
||||
<X
|
||||
size={11}
|
||||
color={t.textTertiary}
|
||||
data-tab-close
|
||||
data-session-close
|
||||
className={css({ cursor: "pointer", opacity: 0 })}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCloseTab(tab.id);
|
||||
onCloseSession(tab.id);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -171,19 +171,19 @@ export const TabStrip = memo(function TabStrip({
|
|||
);
|
||||
})}
|
||||
{openDiffs.map((path) => {
|
||||
const tabId = diffTabId(path);
|
||||
const isActive = tabId === activeTabId;
|
||||
const sessionId = diffTabId(path);
|
||||
const isActive = sessionId === activeSessionId;
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
onClick={() => onSwitchTab(tabId)}
|
||||
key={sessionId}
|
||||
onClick={() => onSwitchSession(sessionId)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
onCloseDiffTab(path);
|
||||
}
|
||||
}}
|
||||
data-tab
|
||||
data-session
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -206,7 +206,7 @@ export const TabStrip = memo(function TabStrip({
|
|||
<X
|
||||
size={11}
|
||||
color={t.textTertiary}
|
||||
data-tab-close
|
||||
data-session-close
|
||||
className={css({ cursor: "pointer", opacity: 0 })}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
|
@ -217,7 +217,7 @@ export const TabStrip = memo(function TabStrip({
|
|||
);
|
||||
})}
|
||||
<div
|
||||
onClick={onAddTab}
|
||||
onClick={onAddSession}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -21,13 +21,13 @@ import {
|
|||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { formatRelativeAge, type Task, type ProjectSection } from "./view-model";
|
||||
import { formatRelativeAge, type Task, type RepositorySection } from "./view-model";
|
||||
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
|
||||
import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app";
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import type { FoundryTokens } from "../../styles/tokens";
|
||||
|
||||
const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
||||
const REPOSITORY_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
||||
|
||||
/** Strip the org prefix (e.g. "rivet-dev/") when all repos share the same org. */
|
||||
function stripCommonOrgPrefix(label: string, repos: Array<{ label: string }>): string {
|
||||
|
|
@ -40,18 +40,18 @@ function stripCommonOrgPrefix(label: string, repos: Array<{ label: string }>): s
|
|||
return label;
|
||||
}
|
||||
|
||||
function projectInitial(label: string): string {
|
||||
function repositoryInitial(label: string): string {
|
||||
const parts = label.split("/");
|
||||
const name = parts[parts.length - 1] ?? label;
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
function projectIconColor(label: string): string {
|
||||
function repositoryIconColor(label: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < label.length; i++) {
|
||||
hash = (hash * 31 + label.charCodeAt(i)) | 0;
|
||||
}
|
||||
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
|
||||
return REPOSITORY_COLORS[Math.abs(hash) % REPOSITORY_COLORS.length]!;
|
||||
}
|
||||
|
||||
function isPullRequestSidebarItem(task: Task): boolean {
|
||||
|
|
@ -59,7 +59,7 @@ function isPullRequestSidebarItem(task: Task): boolean {
|
|||
}
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
projects,
|
||||
repositories,
|
||||
newTaskRepos,
|
||||
selectedNewTaskRepoId,
|
||||
activeId,
|
||||
|
|
@ -69,13 +69,16 @@ export const Sidebar = memo(function Sidebar({
|
|||
onMarkUnread,
|
||||
onRenameTask,
|
||||
onRenameBranch,
|
||||
onReorderRepositories,
|
||||
taskOrderByRepository,
|
||||
onReorderTasks,
|
||||
onReloadOrganization,
|
||||
onReloadPullRequests,
|
||||
onReloadRepository,
|
||||
onReloadPullRequest,
|
||||
onToggleSidebar,
|
||||
}: {
|
||||
projects: ProjectSection[];
|
||||
repositories: RepositorySection[];
|
||||
newTaskRepos: Array<{ id: string; label: string }>;
|
||||
selectedNewTaskRepoId: string;
|
||||
activeId: string;
|
||||
|
|
@ -85,6 +88,9 @@ export const Sidebar = memo(function Sidebar({
|
|||
onMarkUnread: (id: string) => void;
|
||||
onRenameTask: (id: string) => void;
|
||||
onRenameBranch: (id: string) => void;
|
||||
onReorderRepositories: (fromIndex: number, toIndex: number) => void;
|
||||
taskOrderByRepository: Record<string, string[]>;
|
||||
onReorderTasks: (repositoryId: string, fromIndex: number, toIndex: number) => void;
|
||||
onReloadOrganization: () => void;
|
||||
onReloadPullRequests: () => void;
|
||||
onReloadRepository: (repoId: string) => void;
|
||||
|
|
@ -94,12 +100,72 @@ export const Sidebar = memo(function Sidebar({
|
|||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const contextMenu = useContextMenu();
|
||||
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({});
|
||||
const [hoveredProjectId, setHoveredProjectId] = useState<string | null>(null);
|
||||
const [collapsedRepositories, setCollapsedRepositories] = useState<Record<string, boolean>>({});
|
||||
const [hoveredRepositoryId, setHoveredRepositoryId] = useState<string | null>(null);
|
||||
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const headerMenuRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mouse-based drag and drop state
|
||||
type DragState =
|
||||
| { type: "repository"; fromIdx: number; overIdx: number | null }
|
||||
| { type: "task"; repositoryId: string; fromIdx: number; overIdx: number | null }
|
||||
| null;
|
||||
const [drag, setDrag] = useState<DragState>(null);
|
||||
const dragRef = useRef<DragState>(null);
|
||||
const startYRef = useRef(0);
|
||||
const didDragRef = useRef(false);
|
||||
|
||||
// Attach global mousemove/mouseup when dragging
|
||||
useEffect(() => {
|
||||
if (!drag) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
// Detect which element is under the cursor using data attributes
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!el) return;
|
||||
const repositoryEl = (el as HTMLElement).closest?.("[data-repository-idx]") as HTMLElement | null;
|
||||
const taskEl = (el as HTMLElement).closest?.("[data-task-idx]") as HTMLElement | null;
|
||||
|
||||
if (drag.type === "repository" && repositoryEl) {
|
||||
const overIdx = Number(repositoryEl.dataset.repositoryIdx);
|
||||
if (overIdx !== drag.overIdx) {
|
||||
setDrag({ ...drag, overIdx });
|
||||
dragRef.current = { ...drag, overIdx };
|
||||
}
|
||||
} else if (drag.type === "task" && taskEl) {
|
||||
const overRepositoryId = taskEl.dataset.taskRepositoryId ?? "";
|
||||
const overIdx = Number(taskEl.dataset.taskIdx);
|
||||
if (overRepositoryId === drag.repositoryId && overIdx !== drag.overIdx) {
|
||||
setDrag({ ...drag, overIdx });
|
||||
dragRef.current = { ...drag, overIdx };
|
||||
}
|
||||
}
|
||||
// Mark that we actually moved (to distinguish from clicks)
|
||||
if (Math.abs(e.clientY - startYRef.current) > 4) {
|
||||
didDragRef.current = true;
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
const d = dragRef.current;
|
||||
if (d && didDragRef.current && d.overIdx !== null && d.fromIdx !== d.overIdx) {
|
||||
if (d.type === "repository") {
|
||||
onReorderRepositories(d.fromIdx, d.overIdx);
|
||||
} else {
|
||||
onReorderTasks(d.repositoryId, d.fromIdx, d.overIdx);
|
||||
}
|
||||
}
|
||||
dragRef.current = null;
|
||||
didDragRef.current = false;
|
||||
setDrag(null);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [drag, onReorderRepositories, onReorderTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!headerMenuOpen) {
|
||||
return;
|
||||
|
|
@ -116,18 +182,36 @@ export const Sidebar = memo(function Sidebar({
|
|||
|
||||
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
||||
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
|
||||
type FlatItem = { key: string; type: "project-header"; project: ProjectSection } | { key: string; type: "task"; project: ProjectSection; task: Task };
|
||||
const flatItems = useMemo<FlatItem[]>(
|
||||
() =>
|
||||
projects.flatMap((project) => {
|
||||
const items: FlatItem[] = [{ key: `project:${project.id}`, type: "project-header", project }];
|
||||
if (!collapsedProjects[project.id]) {
|
||||
items.push(...project.tasks.map((task) => ({ key: `task:${task.id}`, type: "task" as const, project, task })));
|
||||
}
|
||||
return items;
|
||||
}),
|
||||
[collapsedProjects, projects],
|
||||
);
|
||||
type FlatItem =
|
||||
| { key: string; type: "repository-header"; repository: RepositorySection; repositoryIndex: number }
|
||||
| { key: string; type: "task"; repository: RepositorySection; repositoryIndex: number; task: Task; taskIndex: number }
|
||||
| { key: string; type: "task-drop-zone"; repository: RepositorySection; repositoryIndex: number; taskCount: number }
|
||||
| { key: string; type: "repository-drop-zone"; repositoryCount: number };
|
||||
const flatItems = useMemo<FlatItem[]>(() => {
|
||||
const items: FlatItem[] = [];
|
||||
repositories.forEach((repository, repositoryIndex) => {
|
||||
items.push({ key: `repository:${repository.id}`, type: "repository-header", repository, repositoryIndex });
|
||||
if (!collapsedRepositories[repository.id]) {
|
||||
const orderedTaskIds = taskOrderByRepository[repository.id];
|
||||
const orderedTasks = orderedTaskIds
|
||||
? (() => {
|
||||
const byId = new Map(repository.tasks.map((t) => [t.id, t]));
|
||||
const sorted = orderedTaskIds.map((id) => byId.get(id)).filter(Boolean) as typeof repository.tasks;
|
||||
for (const t of repository.tasks) {
|
||||
if (!orderedTaskIds.includes(t.id)) sorted.push(t);
|
||||
}
|
||||
return sorted;
|
||||
})()
|
||||
: repository.tasks;
|
||||
orderedTasks.forEach((task, taskIndex) => {
|
||||
items.push({ key: `task:${task.id}`, type: "task" as const, repository, repositoryIndex, task, taskIndex });
|
||||
});
|
||||
items.push({ key: `task-drop:${repository.id}`, type: "task-drop-zone", repository, repositoryIndex, taskCount: orderedTasks.length });
|
||||
}
|
||||
});
|
||||
items.push({ key: "repository-drop-zone", type: "repository-drop-zone", repositoryCount: repositories.length });
|
||||
return items;
|
||||
}, [collapsedRepositories, repositories, taskOrderByRepository]);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatItems.length,
|
||||
getItemKey: (index) => flatItems[index]?.key ?? index,
|
||||
|
|
@ -140,10 +224,10 @@ export const Sidebar = memo(function Sidebar({
|
|||
return (
|
||||
<SPanel>
|
||||
<style>{`
|
||||
[data-project-header]:hover [data-chevron] {
|
||||
[data-repository-header]:hover [data-chevron] {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
[data-project-header]:hover [data-project-icon] {
|
||||
[data-repository-header]:hover [data-repository-icon] {
|
||||
display: none !important;
|
||||
}
|
||||
`}</style>
|
||||
|
|
@ -433,13 +517,16 @@ export const Sidebar = memo(function Sidebar({
|
|||
return null;
|
||||
}
|
||||
|
||||
if (item.type === "project-header") {
|
||||
const project = item.project;
|
||||
const isCollapsed = collapsedProjects[project.id] === true;
|
||||
if (item.type === "repository-header") {
|
||||
const { repository, repositoryIndex } = item;
|
||||
const isCollapsed = collapsedRepositories[repository.id] === true;
|
||||
const isRepositoryDropTarget = drag?.type === "repository" && drag.overIdx === repositoryIndex && drag.fromIdx !== repositoryIndex;
|
||||
const isBeingDragged = drag?.type === "repository" && drag.fromIdx === repositoryIndex && didDragRef.current;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
data-repository-idx={repositoryIndex}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
virtualizer.measureElement(node);
|
||||
|
|
@ -451,32 +538,48 @@ export const Sidebar = memo(function Sidebar({
|
|||
top: 0,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
width: "100%",
|
||||
opacity: isBeingDragged ? 0.4 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
}}
|
||||
>
|
||||
{isRepositoryDropTarget ? (
|
||||
<div className={css({ height: "2px", backgroundColor: t.textPrimary, transition: "background-color 100ms ease" })} />
|
||||
) : null}
|
||||
<div className={css({ paddingBottom: "4px" })}>
|
||||
<div
|
||||
onMouseEnter={() => setHoveredProjectId(project.id)}
|
||||
onMouseLeave={() => setHoveredProjectId((cur) => (cur === project.id ? null : cur))}
|
||||
onMouseEnter={() => setHoveredRepositoryId(repository.id)}
|
||||
onMouseLeave={() => setHoveredRepositoryId((cur) => (cur === repository.id ? null : cur))}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
startYRef.current = event.clientY;
|
||||
didDragRef.current = false;
|
||||
setHoveredRepositoryId(null);
|
||||
const state: DragState = { type: "repository", fromIdx: repositoryIndex, overIdx: null };
|
||||
dragRef.current = state;
|
||||
setDrag(state);
|
||||
}}
|
||||
onClick={() => {
|
||||
setCollapsedProjects((current) => ({
|
||||
...current,
|
||||
[project.id]: !current[project.id],
|
||||
}));
|
||||
if (!didDragRef.current) {
|
||||
setCollapsedRepositories((current) => ({
|
||||
...current,
|
||||
[repository.id]: !current[repository.id],
|
||||
}));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) =>
|
||||
contextMenu.open(event, [
|
||||
{ label: "Reload repository", onClick: () => onReloadRepository(project.id) },
|
||||
{ label: "New task", onClick: () => onCreate(project.id) },
|
||||
{ label: "Reload repository", onClick: () => onReloadRepository(repository.id) },
|
||||
{ label: "New task", onClick: () => onCreate(repository.id) },
|
||||
])
|
||||
}
|
||||
data-project-header
|
||||
data-repository-header
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 8px 4px",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
})}
|
||||
>
|
||||
|
|
@ -494,11 +597,11 @@ export const Sidebar = memo(function Sidebar({
|
|||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
color: t.textOnAccent,
|
||||
backgroundColor: projectIconColor(project.label),
|
||||
backgroundColor: repositoryIconColor(repository.label),
|
||||
})}
|
||||
data-project-icon
|
||||
data-repository-icon
|
||||
>
|
||||
{projectInitial(project.label)}
|
||||
{repositoryInitial(repository.label)}
|
||||
</span>
|
||||
<span
|
||||
className={css({ position: "absolute", inset: 0, display: "none", alignItems: "center", justifyContent: "center" })}
|
||||
|
|
@ -519,18 +622,19 @@ export const Sidebar = memo(function Sidebar({
|
|||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{stripCommonOrgPrefix(project.label, projects)}
|
||||
{stripCommonOrgPrefix(repository.label, repositories)}
|
||||
</LabelSmall>
|
||||
</div>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||
{isCollapsed ? <LabelXSmall color={t.textTertiary}>{formatRelativeAge(project.updatedAtMs)}</LabelXSmall> : null}
|
||||
{isCollapsed ? <LabelXSmall color={t.textTertiary}>{formatRelativeAge(repository.updatedAtMs)}</LabelXSmall> : null}
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setHoveredProjectId(null);
|
||||
onSelectNewTaskRepo(project.id);
|
||||
onCreate(project.id);
|
||||
setHoveredRepositoryId(null);
|
||||
onSelectNewTaskRepo(repository.id);
|
||||
onCreate(repository.id);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={css({
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -544,12 +648,12 @@ export const Sidebar = memo(function Sidebar({
|
|||
margin: 0,
|
||||
cursor: "pointer",
|
||||
color: t.textTertiary,
|
||||
opacity: hoveredProjectId === project.id ? 1 : 0,
|
||||
opacity: hoveredRepositoryId === repository.id ? 1 : 0,
|
||||
transition: "opacity 150ms ease, background-color 200ms ease, color 200ms ease",
|
||||
pointerEvents: hoveredProjectId === project.id ? "auto" : "none",
|
||||
pointerEvents: hoveredRepositoryId === repository.id ? "auto" : "none",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
|
||||
})}
|
||||
title={`New task in ${project.label}`}
|
||||
title={`New task in ${repository.label}`}
|
||||
>
|
||||
<Plus size={12} color={t.textTertiary} />
|
||||
</button>
|
||||
|
|
@ -560,127 +664,230 @@ export const Sidebar = memo(function Sidebar({
|
|||
);
|
||||
}
|
||||
|
||||
const { project, task } = item;
|
||||
const isActive = task.id === activeId;
|
||||
const isPullRequestItem = isPullRequestSidebarItem(task);
|
||||
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
||||
const isProvisioning =
|
||||
!isPullRequestItem &&
|
||||
(String(task.status).startsWith("init_") ||
|
||||
task.status === "new" ||
|
||||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create"));
|
||||
const hasUnread = task.tabs.some((tab) => tab.unread);
|
||||
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
||||
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
||||
const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0);
|
||||
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
|
||||
if (item.type === "task") {
|
||||
const { repository, task, taskIndex } = item;
|
||||
const isActive = task.id === activeId;
|
||||
const isPullRequestItem = isPullRequestSidebarItem(task);
|
||||
const isRunning = task.sessions.some((s) => s.status === "running");
|
||||
const isProvisioning =
|
||||
!isPullRequestItem &&
|
||||
(String(task.status).startsWith("init_") ||
|
||||
task.status === "new" ||
|
||||
task.sessions.some((s) => s.status === "pending_provision" || s.status === "pending_session_create"));
|
||||
const hasUnread = task.sessions.some((s) => s.unread);
|
||||
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
||||
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
||||
const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0);
|
||||
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
|
||||
const isTaskDropTarget =
|
||||
drag?.type === "task" && drag.repositoryId === repository.id && drag.overIdx === taskIndex && drag.fromIdx !== taskIndex;
|
||||
const isTaskBeingDragged = drag?.type === "task" && drag.repositoryId === repository.id && drag.fromIdx === taskIndex && didDragRef.current;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
virtualizer.measureElement(node);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div className={css({ paddingBottom: "4px" })}>
|
||||
<div
|
||||
onClick={() => onSelect(task.id)}
|
||||
onContextMenu={(event) => {
|
||||
if (isPullRequestItem && task.pullRequest) {
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
data-task-idx={taskIndex}
|
||||
data-task-repository-id={repository.id}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
virtualizer.measureElement(node);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
width: "100%",
|
||||
opacity: isTaskBeingDragged ? 0.4 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (dragRef.current) return;
|
||||
event.stopPropagation();
|
||||
startYRef.current = event.clientY;
|
||||
didDragRef.current = false;
|
||||
const state: DragState = { type: "task", repositoryId: repository.id, fromIdx: taskIndex, overIdx: null };
|
||||
dragRef.current = state;
|
||||
setDrag(state);
|
||||
}}
|
||||
>
|
||||
{isTaskDropTarget ? (
|
||||
<div className={css({ height: "2px", backgroundColor: t.textPrimary, transition: "background-color 100ms ease" })} />
|
||||
) : null}
|
||||
<div className={css({ paddingBottom: "4px" })}>
|
||||
<div
|
||||
onClick={() => onSelect(task.id)}
|
||||
onContextMenu={(event) => {
|
||||
if (isPullRequestItem && task.pullRequest) {
|
||||
contextMenu.open(event, [
|
||||
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
|
||||
{ label: "Create task", onClick: () => onSelect(task.id) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
contextMenu.open(event, [
|
||||
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
|
||||
{ label: "Create task", onClick: () => onSelect(task.id) },
|
||||
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
|
||||
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
|
||||
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
contextMenu.open(event, [
|
||||
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
|
||||
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
|
||||
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
|
||||
]);
|
||||
}}
|
||||
className={css({
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: isActive ? t.interactiveHover : "transparent",
|
||||
cursor: "pointer",
|
||||
transition: "all 150ms ease",
|
||||
":hover": {
|
||||
backgroundColor: t.interactiveHover,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
|
||||
<div
|
||||
className={css({
|
||||
width: "14px",
|
||||
minWidth: "14px",
|
||||
height: "14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{isPullRequestItem ? (
|
||||
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
|
||||
) : (
|
||||
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||
)}
|
||||
</div>
|
||||
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
|
||||
<LabelSmall
|
||||
$style={{
|
||||
fontWeight: hasUnread ? 600 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
color={hasUnread ? t.textPrimary : t.textSecondary}
|
||||
}}
|
||||
className={css({
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: isActive ? t.interactiveHover : "transparent",
|
||||
cursor: "pointer",
|
||||
transition: "all 150ms ease",
|
||||
":hover": {
|
||||
backgroundColor: t.interactiveHover,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
|
||||
<div
|
||||
className={css({
|
||||
width: "14px",
|
||||
minWidth: "14px",
|
||||
height: "14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{task.title}
|
||||
</LabelSmall>
|
||||
{isPullRequestItem && task.statusMessage ? (
|
||||
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{task.statusMessage}
|
||||
</LabelXSmall>
|
||||
) : null}
|
||||
</div>
|
||||
{task.pullRequest != null ? (
|
||||
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
||||
#{task.pullRequest.number}
|
||||
</LabelXSmall>
|
||||
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color={t.accent} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<GitPullRequestDraft size={11} color={t.textTertiary} />
|
||||
)}
|
||||
{hasDiffs ? (
|
||||
<div className={css({ display: "flex", gap: "4px", flexShrink: 0, marginLeft: "auto" })}>
|
||||
<span className={css({ fontSize: "11px", color: t.statusSuccess })}>+{totalAdded}</span>
|
||||
<span className={css({ fontSize: "11px", color: t.statusError })}>-{totalRemoved}</span>
|
||||
{isPullRequestItem ? (
|
||||
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
|
||||
) : (
|
||||
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<LabelXSmall color={t.textTertiary} $style={{ flexShrink: 0, marginLeft: hasDiffs ? undefined : "auto" }}>
|
||||
{formatRelativeAge(task.updatedAtMs)}
|
||||
</LabelXSmall>
|
||||
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
|
||||
<LabelSmall
|
||||
$style={{
|
||||
fontWeight: hasUnread ? 600 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
color={hasUnread ? t.textPrimary : t.textSecondary}
|
||||
>
|
||||
{task.title}
|
||||
</LabelSmall>
|
||||
{isPullRequestItem && task.statusMessage ? (
|
||||
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{task.statusMessage}
|
||||
</LabelXSmall>
|
||||
) : null}
|
||||
</div>
|
||||
{task.pullRequest != null ? (
|
||||
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
||||
#{task.pullRequest.number}
|
||||
</LabelXSmall>
|
||||
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color={t.accent} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<GitPullRequestDraft size={11} color={t.textTertiary} />
|
||||
)}
|
||||
{hasDiffs ? (
|
||||
<div className={css({ display: "flex", gap: "4px", flexShrink: 0, marginLeft: "auto" })}>
|
||||
<span className={css({ fontSize: "11px", color: t.statusSuccess })}>+{totalAdded}</span>
|
||||
<span className={css({ fontSize: "11px", color: t.statusError })}>-{totalRemoved}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<LabelXSmall color={t.textTertiary} $style={{ flexShrink: 0, marginLeft: hasDiffs ? undefined : "auto" }}>
|
||||
{formatRelativeAge(task.updatedAtMs)}
|
||||
</LabelXSmall>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "task-drop-zone") {
|
||||
const { repository, taskCount } = item;
|
||||
const isDropTarget =
|
||||
drag?.type === "task" &&
|
||||
drag.repositoryId === repository.id &&
|
||||
drag.overIdx === taskCount &&
|
||||
drag.fromIdx !== taskCount;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
data-task-idx={taskCount}
|
||||
data-task-repository-id={repository.id}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
virtualizer.measureElement(node);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
width: "100%",
|
||||
}}
|
||||
className={css({
|
||||
minHeight: "4px",
|
||||
position: "relative",
|
||||
"::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "2px",
|
||||
backgroundColor: isDropTarget ? t.textPrimary : "transparent",
|
||||
transition: "background-color 100ms ease",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "repository-drop-zone") {
|
||||
const isDropTarget =
|
||||
drag?.type === "repository" && drag.overIdx === item.repositoryCount && drag.fromIdx !== item.repositoryCount;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
data-repository-idx={item.repositoryCount}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
virtualizer.measureElement(node);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
width: "100%",
|
||||
}}
|
||||
className={css({
|
||||
minHeight: "4px",
|
||||
position: "relative",
|
||||
"::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "2px",
|
||||
backgroundColor: isDropTarget ? t.textPrimary : "transparent",
|
||||
transition: "background-color 100ms ease",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -717,19 +924,19 @@ function SidebarFooter() {
|
|||
const snapshot = useMockAppSnapshot();
|
||||
const organization = activeMockOrganization(snapshot);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [workspaceFlyoutOpen, setWorkspaceFlyoutOpen] = useState(false);
|
||||
const [organizationFlyoutOpen, setOrganizationFlyoutOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const flyoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const workspaceTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const organizationTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const flyoutRef = useRef<HTMLDivElement>(null);
|
||||
const [flyoutPos, setFlyoutPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (workspaceFlyoutOpen && workspaceTriggerRef.current) {
|
||||
const rect = workspaceTriggerRef.current.getBoundingClientRect();
|
||||
if (organizationFlyoutOpen && organizationTriggerRef.current) {
|
||||
const rect = organizationTriggerRef.current.getBoundingClientRect();
|
||||
setFlyoutPos({ top: rect.top, left: rect.right + 4 });
|
||||
}
|
||||
}, [workspaceFlyoutOpen]);
|
||||
}, [organizationFlyoutOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -739,7 +946,7 @@ function SidebarFooter() {
|
|||
const inFlyout = flyoutRef.current?.contains(target);
|
||||
if (!inContainer && !inFlyout) {
|
||||
setOpen(false);
|
||||
setWorkspaceFlyoutOpen(false);
|
||||
setOrganizationFlyoutOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
|
|
@ -749,10 +956,10 @@ function SidebarFooter() {
|
|||
const switchToOrg = useCallback(
|
||||
(org: (typeof snapshot.organizations)[number]) => {
|
||||
setOpen(false);
|
||||
setWorkspaceFlyoutOpen(false);
|
||||
setOrganizationFlyoutOpen(false);
|
||||
void (async () => {
|
||||
await client.selectOrganization(org.id);
|
||||
await navigate({ to: `/workspaces/${org.workspaceId}` as never });
|
||||
await navigate({ to: `/organizations/${org.organizationId}` as never });
|
||||
})();
|
||||
},
|
||||
[client, navigate],
|
||||
|
|
@ -760,11 +967,11 @@ function SidebarFooter() {
|
|||
|
||||
const openFlyout = useCallback(() => {
|
||||
if (flyoutTimerRef.current) clearTimeout(flyoutTimerRef.current);
|
||||
setWorkspaceFlyoutOpen(true);
|
||||
setOrganizationFlyoutOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeFlyout = useCallback(() => {
|
||||
flyoutTimerRef.current = setTimeout(() => setWorkspaceFlyoutOpen(false), 150);
|
||||
flyoutTimerRef.current = setTimeout(() => setOrganizationFlyoutOpen(false), 150);
|
||||
}, []);
|
||||
|
||||
const menuItems: Array<{ icon: React.ReactNode; label: string; danger?: boolean; onClick: () => void }> = [];
|
||||
|
|
@ -838,14 +1045,14 @@ function SidebarFooter() {
|
|||
})}
|
||||
>
|
||||
<div className={popoverStyle}>
|
||||
{/* Workspace flyout trigger */}
|
||||
{/* Organization flyout trigger */}
|
||||
{organization ? (
|
||||
<div ref={workspaceTriggerRef} onMouseEnter={openFlyout} onMouseLeave={closeFlyout}>
|
||||
<div ref={organizationTriggerRef} onMouseEnter={openFlyout} onMouseLeave={closeFlyout}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWorkspaceFlyoutOpen((prev) => !prev)}
|
||||
onClick={() => setOrganizationFlyoutOpen((prev) => !prev)}
|
||||
className={css({
|
||||
...menuButtonStyle(workspaceFlyoutOpen, t),
|
||||
...menuButtonStyle(organizationFlyoutOpen, t),
|
||||
fontWeight: 500,
|
||||
":hover": {
|
||||
backgroundColor: t.interactiveHover,
|
||||
|
|
@ -858,7 +1065,7 @@ function SidebarFooter() {
|
|||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "4px",
|
||||
background: `linear-gradient(135deg, ${projectIconColor(organization.settings.displayName)}, ${projectIconColor(organization.settings.displayName + "x")})`,
|
||||
background: `linear-gradient(135deg, ${repositoryIconColor(organization.settings.displayName)}, ${repositoryIconColor(organization.settings.displayName + "x")})`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
|
@ -878,8 +1085,8 @@ function SidebarFooter() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Workspace flyout portal */}
|
||||
{workspaceFlyoutOpen && organization && flyoutPos
|
||||
{/* Organization flyout portal */}
|
||||
{organizationFlyoutOpen && organization && flyoutPos
|
||||
? createPortal(
|
||||
<div
|
||||
ref={flyoutRef}
|
||||
|
|
@ -908,7 +1115,7 @@ function SidebarFooter() {
|
|||
if (!isActive) switchToOrg(org);
|
||||
else {
|
||||
setOpen(false);
|
||||
setWorkspaceFlyoutOpen(false);
|
||||
setOrganizationFlyoutOpen(false);
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
|
|
@ -926,7 +1133,7 @@ function SidebarFooter() {
|
|||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "4px",
|
||||
background: `linear-gradient(135deg, ${projectIconColor(org.settings.displayName)}, ${projectIconColor(org.settings.displayName + "x")})`,
|
||||
background: `linear-gradient(135deg, ${repositoryIconColor(org.settings.displayName)}, ${repositoryIconColor(org.settings.displayName + "x")})`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
|
@ -976,7 +1183,7 @@ function SidebarFooter() {
|
|||
type="button"
|
||||
onClick={() => {
|
||||
setOpen((prev) => {
|
||||
if (prev) setWorkspaceFlyoutOpen(false);
|
||||
if (prev) setOrganizationFlyoutOpen(false);
|
||||
return !prev;
|
||||
});
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type SandboxProcessRecord, useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { type SandboxProcessRecord, useSubscription } from "@sandbox-agent/foundry-client";
|
||||
import { ProcessTerminal } from "@sandbox-agent/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useStyletron } from "baseui";
|
||||
|
|
@ -7,10 +7,10 @@ import { ChevronDown, ChevronUp, Plus, SquareTerminal, Trash2 } from "lucide-rea
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { backendClient } from "../../lib/backend";
|
||||
import { interestManager } from "../../lib/interest";
|
||||
import { subscriptionManager } from "../../lib/subscription";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
workspaceId: string;
|
||||
organizationId: string;
|
||||
taskId: string | null;
|
||||
isExpanded?: boolean;
|
||||
onExpand?: () => void;
|
||||
|
|
@ -95,10 +95,10 @@ function HeaderIconButton({
|
|||
);
|
||||
}
|
||||
|
||||
export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onCollapse, onStartResize }: TerminalPaneProps) {
|
||||
export function TerminalPane({ organizationId, taskId, isExpanded, onExpand, onCollapse, onStartResize }: TerminalPaneProps) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [activeSessionId, setActiveTabId] = useState<string | null>(null);
|
||||
const [processTabs, setProcessTabs] = useState<ProcessTab[]>([]);
|
||||
const [creatingProcess, setCreatingProcess] = useState(false);
|
||||
const [hoveredTabId, setHoveredTabId] = useState<string | null>(null);
|
||||
|
|
@ -184,17 +184,17 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
[listWidth],
|
||||
);
|
||||
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
|
||||
const taskSummary = useMemo(
|
||||
() => (taskId ? (workspaceState.data?.taskSummaries.find((task) => task.id === taskId) ?? null) : null),
|
||||
[taskId, workspaceState.data?.taskSummaries],
|
||||
() => (taskId ? (organizationState.data?.taskSummaries.find((task) => task.id === taskId) ?? null) : null),
|
||||
[taskId, organizationState.data?.taskSummaries],
|
||||
);
|
||||
const taskState = useInterest(
|
||||
interestManager,
|
||||
const taskState = useSubscription(
|
||||
subscriptionManager,
|
||||
"task",
|
||||
taskSummary
|
||||
? {
|
||||
workspaceId,
|
||||
organizationId,
|
||||
repoId: taskSummary.repoId,
|
||||
taskId: taskSummary.id,
|
||||
}
|
||||
|
|
@ -211,7 +211,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
}, [taskState.data]);
|
||||
|
||||
const connectionQuery = useQuery({
|
||||
queryKey: ["mock-layout", "sandbox-agent-connection", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""],
|
||||
queryKey: ["mock-layout", "sandbox-agent-connection", organizationId, activeSandbox?.sandboxProviderId ?? "", activeSandbox?.sandboxId ?? ""],
|
||||
enabled: Boolean(activeSandbox?.sandboxId),
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
|
|
@ -220,17 +220,17 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
throw new Error("Cannot load a sandbox connection without an active sandbox.");
|
||||
}
|
||||
|
||||
return await backendClient.getSandboxAgentConnection(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId);
|
||||
return await backendClient.getSandboxAgentConnection(organizationId, activeSandbox.sandboxProviderId, activeSandbox.sandboxId);
|
||||
},
|
||||
});
|
||||
|
||||
const processesState = useInterest(
|
||||
interestManager,
|
||||
const processesState = useSubscription(
|
||||
subscriptionManager,
|
||||
"sandboxProcesses",
|
||||
activeSandbox
|
||||
? {
|
||||
workspaceId,
|
||||
providerId: activeSandbox.providerId,
|
||||
organizationId,
|
||||
sandboxProviderId: activeSandbox.sandboxProviderId,
|
||||
sandboxId: activeSandbox.sandboxId,
|
||||
}
|
||||
: null,
|
||||
|
|
@ -325,11 +325,11 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
});
|
||||
}, []);
|
||||
|
||||
const closeTerminalTab = useCallback((tabId: string) => {
|
||||
const closeTerminalTab = useCallback((sessionId: string) => {
|
||||
setProcessTabs((current) => {
|
||||
const next = current.filter((tab) => tab.id !== tabId);
|
||||
const next = current.filter((tab) => tab.id !== sessionId);
|
||||
setActiveTabId((currentActive) => {
|
||||
if (currentActive === tabId) {
|
||||
if (currentActive === sessionId) {
|
||||
return next.length > 0 ? next[next.length - 1]!.id : null;
|
||||
}
|
||||
return currentActive;
|
||||
|
|
@ -346,8 +346,8 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
setCreatingProcess(true);
|
||||
try {
|
||||
const created = await backendClient.createSandboxProcess({
|
||||
workspaceId,
|
||||
providerId: activeSandbox.providerId,
|
||||
organizationId,
|
||||
sandboxProviderId: activeSandbox.sandboxProviderId,
|
||||
sandboxId: activeSandbox.sandboxId,
|
||||
request: defaultShellRequest(activeSandbox.cwd),
|
||||
});
|
||||
|
|
@ -355,10 +355,10 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
} finally {
|
||||
setCreatingProcess(false);
|
||||
}
|
||||
}, [activeSandbox, openTerminalTab, workspaceId]);
|
||||
}, [activeSandbox, openTerminalTab, organizationId]);
|
||||
|
||||
const processTabsById = useMemo(() => new Map(processTabs.map((tab) => [tab.id, tab])), [processTabs]);
|
||||
const activeProcessTab = activeTabId ? (processTabsById.get(activeTabId) ?? null) : null;
|
||||
const activeProcessTab = activeSessionId ? (processTabsById.get(activeSessionId) ?? null) : null;
|
||||
const activeTerminalProcess = useMemo(
|
||||
() => (activeProcessTab ? (processes.find((process) => process.id === activeProcessTab.processId) ?? null) : null),
|
||||
[activeProcessTab, processes],
|
||||
|
|
@ -571,9 +571,9 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
css={css}
|
||||
t={t}
|
||||
label="Kill terminal"
|
||||
disabled={!activeTabId}
|
||||
disabled={!activeSessionId}
|
||||
onClick={() => {
|
||||
if (activeTabId) closeTerminalTab(activeTabId);
|
||||
if (activeSessionId) closeTerminalTab(activeSessionId);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
|
|
@ -622,7 +622,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
})}
|
||||
>
|
||||
{processTabs.map((tab, tabIndex) => {
|
||||
const isActive = activeTabId === tab.id;
|
||||
const isActive = activeSessionId === tab.id;
|
||||
const isHovered = hoveredTabId === tab.id;
|
||||
const isDropTarget = tabDrag !== null && tabDrag.overIdx === tabIndex && tabDrag.fromIdx !== tabIndex;
|
||||
const isBeingDragged = tabDrag !== null && tabDrag.fromIdx === tabIndex && didTabDrag.current;
|
||||
|
|
|
|||
|
|
@ -6,19 +6,19 @@ import { Clock, PanelLeft, PanelRight } from "lucide-react";
|
|||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { deriveHeaderStatus } from "../../features/tasks/status";
|
||||
import { HeaderStatusPill, PanelHeaderBar } from "./ui";
|
||||
import { type AgentTab, type Task } from "./view-model";
|
||||
import { type AgentSession, type Task } from "./view-model";
|
||||
|
||||
export const TranscriptHeader = memo(function TranscriptHeader({
|
||||
task,
|
||||
hasSandbox,
|
||||
activeTab,
|
||||
activeSession,
|
||||
editingField,
|
||||
editValue,
|
||||
onEditValueChange,
|
||||
onStartEditingField,
|
||||
onCommitEditingField,
|
||||
onCancelEditingField,
|
||||
onSetActiveTabUnread,
|
||||
onSetActiveSessionUnread,
|
||||
sidebarCollapsed,
|
||||
onToggleSidebar,
|
||||
onSidebarPeekStart,
|
||||
|
|
@ -29,14 +29,14 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
}: {
|
||||
task: Task;
|
||||
hasSandbox: boolean;
|
||||
activeTab: AgentTab | null | undefined;
|
||||
activeSession: AgentSession | null | undefined;
|
||||
editingField: "title" | "branch" | null;
|
||||
editValue: string;
|
||||
onEditValueChange: (value: string) => void;
|
||||
onStartEditingField: (field: "title" | "branch", value: string) => void;
|
||||
onCommitEditingField: (field: "title" | "branch") => void;
|
||||
onCancelEditingField: () => void;
|
||||
onSetActiveTabUnread: (unread: boolean) => void;
|
||||
onSetActiveSessionUnread: (unread: boolean) => void;
|
||||
sidebarCollapsed?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
onSidebarPeekStart?: () => void;
|
||||
|
|
@ -51,8 +51,8 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
const needsTrafficLightInset = isDesktop && sidebarCollapsed;
|
||||
const taskStatus = task.runtimeStatus ?? task.status;
|
||||
const headerStatus = useMemo(
|
||||
() => deriveHeaderStatus(taskStatus, task.statusMessage ?? null, activeTab?.status ?? null, activeTab?.errorMessage ?? null, hasSandbox),
|
||||
[taskStatus, task.statusMessage, activeTab?.status, activeTab?.errorMessage, hasSandbox],
|
||||
() => deriveHeaderStatus(taskStatus, task.statusMessage ?? null, activeSession?.status ?? null, activeSession?.errorMessage ?? null, hasSandbox),
|
||||
[taskStatus, task.statusMessage, activeSession?.status, activeSession?.errorMessage, hasSandbox],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { GitPullRequest, GitPullRequestDraft } from "lucide-react";
|
|||
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { getFoundryTokens } from "../../styles/tokens";
|
||||
import type { AgentKind, AgentTab } from "./view-model";
|
||||
import type { AgentKind, AgentSession } from "./view-model";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
|
|
@ -251,10 +251,10 @@ export const HeaderStatusPill = memo(function HeaderStatusPill({ status }: { sta
|
|||
);
|
||||
});
|
||||
|
||||
export const TabAvatar = memo(function TabAvatar({ tab }: { tab: AgentTab }) {
|
||||
if (tab.status === "running" || tab.status === "pending_provision" || tab.status === "pending_session_create") return <SpinnerDot size={8} />;
|
||||
if (tab.unread) return <UnreadDot />;
|
||||
return <AgentIcon agent={tab.agent} size={13} />;
|
||||
export const SessionAvatar = memo(function SessionAvatar({ session }: { session: AgentSession }) {
|
||||
if (session.status === "running" || session.status === "pending_provision" || session.status === "pending_session_create") return <SpinnerDot size={8} />;
|
||||
if (session.unread) return <UnreadDot />;
|
||||
return <AgentIcon agent={session.agent} size={13} />;
|
||||
});
|
||||
|
||||
export const Shell = styled("div", ({ $theme }) => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkbenchAgentTab } from "@sandbox-agent/foundry-shared";
|
||||
import type { WorkbenchSession } from "@sandbox-agent/foundry-shared";
|
||||
import { buildDisplayMessages } from "./view-model";
|
||||
|
||||
function makeTab(transcript: WorkbenchAgentTab["transcript"]): WorkbenchAgentTab {
|
||||
function makeSession(transcript: WorkbenchSession["transcript"]): WorkbenchSession {
|
||||
return {
|
||||
id: "tab-1",
|
||||
id: "session-1",
|
||||
sessionId: "session-1",
|
||||
sessionName: "Session 1",
|
||||
agent: "Codex",
|
||||
|
|
@ -25,7 +25,7 @@ function makeTab(transcript: WorkbenchAgentTab["transcript"]): WorkbenchAgentTab
|
|||
describe("buildDisplayMessages", () => {
|
||||
it("collapses chunked agent output into a single display message", () => {
|
||||
const messages = buildDisplayMessages(
|
||||
makeTab([
|
||||
makeSession([
|
||||
{
|
||||
id: "evt-setup",
|
||||
eventIndex: 0,
|
||||
|
|
@ -139,7 +139,7 @@ describe("buildDisplayMessages", () => {
|
|||
|
||||
it("hides non-message session update envelopes", () => {
|
||||
const messages = buildDisplayMessages(
|
||||
makeTab([
|
||||
makeSession([
|
||||
{
|
||||
id: "evt-client",
|
||||
eventIndex: 1,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type {
|
||||
WorkbenchAgentKind as AgentKind,
|
||||
WorkbenchAgentTab as AgentTab,
|
||||
WorkbenchSession as AgentSession,
|
||||
WorkbenchDiffLineKind as DiffLineKind,
|
||||
WorkbenchFileChange as FileChange,
|
||||
WorkbenchFileTreeNode as FileTreeNode,
|
||||
|
|
@ -10,12 +10,12 @@ import type {
|
|||
WorkbenchModelGroup as ModelGroup,
|
||||
WorkbenchModelId as ModelId,
|
||||
WorkbenchParsedDiffLine as ParsedDiffLine,
|
||||
WorkbenchProjectSection as ProjectSection,
|
||||
WorkbenchRepositorySection as RepositorySection,
|
||||
WorkbenchTranscriptEvent as TranscriptEvent,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { extractEventText } from "../../features/sessions/model";
|
||||
|
||||
export type { ProjectSection };
|
||||
export type { RepositorySection };
|
||||
|
||||
export const MODEL_GROUPS: ModelGroup[] = [
|
||||
{
|
||||
|
|
@ -138,17 +138,17 @@ function historyDetail(event: TranscriptEvent): string {
|
|||
return content || "Untitled event";
|
||||
}
|
||||
|
||||
export function buildHistoryEvents(tabs: AgentTab[]): HistoryEvent[] {
|
||||
return tabs
|
||||
.flatMap((tab) =>
|
||||
tab.transcript
|
||||
export function buildHistoryEvents(sessions: AgentSession[]): HistoryEvent[] {
|
||||
return sessions
|
||||
.flatMap((session) =>
|
||||
session.transcript
|
||||
.filter((event) => event.sender === "client")
|
||||
.map((event) => ({
|
||||
id: `history-${tab.id}-${event.id}`,
|
||||
id: `history-${session.id}-${event.id}`,
|
||||
messageId: event.id,
|
||||
preview: historyPreview(event),
|
||||
sessionName: tab.sessionName,
|
||||
tabId: tab.id,
|
||||
sessionName: session.sessionName,
|
||||
sessionId: session.id,
|
||||
createdAtMs: event.createdAt,
|
||||
detail: historyDetail(event),
|
||||
})),
|
||||
|
|
@ -255,8 +255,8 @@ function shouldDisplayEvent(event: TranscriptEvent): boolean {
|
|||
return Boolean(extractEventText(payload).trim());
|
||||
}
|
||||
|
||||
export function buildDisplayMessages(tab: AgentTab | null | undefined): Message[] {
|
||||
if (!tab) {
|
||||
export function buildDisplayMessages(session: AgentSession | null | undefined): Message[] {
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -270,7 +270,7 @@ export function buildDisplayMessages(tab: AgentTab | null | undefined): Message[
|
|||
pendingAgentMessage = null;
|
||||
};
|
||||
|
||||
for (const event of tab.transcript) {
|
||||
for (const event of session.transcript) {
|
||||
const chunkText = isAgentChunkEvent(event);
|
||||
if (chunkText !== null) {
|
||||
if (!pendingAgentMessage) {
|
||||
|
|
@ -329,7 +329,7 @@ export function parseDiffLines(diff: string): ParsedDiffLine[] {
|
|||
|
||||
export type {
|
||||
AgentKind,
|
||||
AgentTab,
|
||||
AgentSession,
|
||||
DiffLineKind,
|
||||
FileChange,
|
||||
FileTreeNode,
|
||||
|
|
|
|||
|
|
@ -103,8 +103,8 @@ function formatDate(value: string | null): string {
|
|||
return dateFormatter.format(new Date(value));
|
||||
}
|
||||
|
||||
function workspacePath(organization: FoundryOrganization): string {
|
||||
return `/workspaces/${organization.workspaceId}`;
|
||||
function organizationPath(organization: FoundryOrganization): string {
|
||||
return `/organizations/${organization.organizationId}`;
|
||||
}
|
||||
|
||||
function settingsPath(organization: FoundryOrganization): string {
|
||||
|
|
@ -121,7 +121,7 @@ function checkoutPath(organization: FoundryOrganization, planId: FoundryBillingP
|
|||
|
||||
function statusBadge(t: FoundryTokens, organization: FoundryOrganization) {
|
||||
if (organization.kind === "personal") {
|
||||
return <span style={badgeStyle(t, "rgba(24, 140, 255, 0.18)", "#b9d8ff")}>Personal workspace</span>;
|
||||
return <span style={badgeStyle(t, "rgba(24, 140, 255, 0.18)", "#b9d8ff")}>Personal organization</span>;
|
||||
}
|
||||
return <span style={badgeStyle(t, "rgba(255, 79, 0, 0.16)", "#ffd6c7")}>GitHub organization</span>;
|
||||
}
|
||||
|
|
@ -347,11 +347,11 @@ export function MockOrganizationSelectorPage() {
|
|||
/>
|
||||
<rect x="19.25" y="18.25" width="91.5" height="91.5" rx="25.75" stroke="#F0F0F0" strokeWidth="8.5" />
|
||||
</svg>
|
||||
<h1 style={{ fontSize: "20px", fontWeight: 600, margin: "0 0 6px 0", letterSpacing: "-0.01em" }}>Select a workspace</h1>
|
||||
<h1 style={{ fontSize: "20px", fontWeight: 600, margin: "0 0 6px 0", letterSpacing: "-0.01em" }}>Select a organization</h1>
|
||||
<p style={{ fontSize: "13px", color: t.textTertiary, margin: 0 }}>Choose where you want to work.</p>
|
||||
</div>
|
||||
|
||||
{/* Workspace list */}
|
||||
{/* Organization list */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
|
@ -368,7 +368,7 @@ export function MockOrganizationSelectorPage() {
|
|||
onClick={() => {
|
||||
void (async () => {
|
||||
await client.selectOrganization(organization.id);
|
||||
await navigate({ to: workspacePath(organization) });
|
||||
await navigate({ to: organizationPath(organization) });
|
||||
})();
|
||||
}}
|
||||
style={{
|
||||
|
|
@ -580,13 +580,13 @@ function SettingsLayout({
|
|||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Back to workspace */}
|
||||
{/* Back to organization */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await client.selectOrganization(organization.id);
|
||||
await navigate({ to: workspacePath(organization) });
|
||||
await navigate({ to: organizationPath(organization) });
|
||||
})();
|
||||
}}
|
||||
style={{
|
||||
|
|
@ -599,7 +599,7 @@ function SettingsLayout({
|
|||
}}
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
Back to workspace
|
||||
Back to organization
|
||||
</button>
|
||||
|
||||
{/* User header */}
|
||||
|
|
@ -775,7 +775,7 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
|
|||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "16px" }}>
|
||||
{[
|
||||
"Hand off tasks to teammates for review or continuation",
|
||||
"Shared workspace with unified billing across your org",
|
||||
"Shared organization with unified billing across your org",
|
||||
"200 task hours per seat, with bulk hour purchases available",
|
||||
"Collaborative task history and audit trail",
|
||||
].map((feature) => (
|
||||
|
|
@ -1132,7 +1132,7 @@ export function MockAccountSettingsPage() {
|
|||
}}
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
Back to workspace
|
||||
Back to organization
|
||||
</button>
|
||||
|
||||
<div style={{ padding: "2px 10px 12px", display: "flex", flexDirection: "column", gap: "1px" }}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { AgentType, RepoBranchRecord, RepoOverview, RepoStackAction, TaskWorkbenchSnapshot, WorkbenchTaskStatus } from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
import type { AgentType, RepoBranchRecord, RepoOverview, TaskWorkbenchSnapshot, WorkbenchTaskStatus } from "@sandbox-agent/foundry-shared";
|
||||
import { currentFoundryOrganization, useSubscription } from "@sandbox-agent/foundry-client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "baseui/button";
|
||||
|
|
@ -13,17 +13,17 @@ import { Textarea } from "baseui/textarea";
|
|||
import { StyledDivider } from "baseui/divider";
|
||||
import { styled, useStyletron } from "baseui";
|
||||
import { HeadingSmall, HeadingXSmall, LabelSmall, LabelXSmall, MonoLabelSmall, ParagraphSmall } from "baseui/typography";
|
||||
import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizontal, Shuffle } from "lucide-react";
|
||||
import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizontal } from "lucide-react";
|
||||
import { formatDiffStat } from "../features/tasks/model";
|
||||
import { deriveHeaderStatus, describeTaskState } from "../features/tasks/status";
|
||||
import { HeaderStatusPill } from "./mock-layout/ui";
|
||||
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
|
||||
import { backendClient } from "../lib/backend";
|
||||
import { interestManager } from "../lib/interest";
|
||||
import { subscriptionManager } from "../lib/subscription";
|
||||
import { DevPanel, useDevPanel } from "./dev-panel";
|
||||
|
||||
interface WorkspaceDashboardProps {
|
||||
workspaceId: string;
|
||||
interface OrganizationDashboardProps {
|
||||
organizationId: string;
|
||||
selectedTaskId?: string;
|
||||
selectedRepoId?: string;
|
||||
}
|
||||
|
|
@ -142,8 +142,6 @@ function repoSummary(overview: RepoOverview | undefined): {
|
|||
total: number;
|
||||
mapped: number;
|
||||
unmapped: number;
|
||||
conflicts: number;
|
||||
needsRestack: number;
|
||||
openPrs: number;
|
||||
} {
|
||||
if (!overview) {
|
||||
|
|
@ -151,27 +149,17 @@ function repoSummary(overview: RepoOverview | undefined): {
|
|||
total: 0,
|
||||
mapped: 0,
|
||||
unmapped: 0,
|
||||
conflicts: 0,
|
||||
needsRestack: 0,
|
||||
openPrs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let mapped = 0;
|
||||
let conflicts = 0;
|
||||
let needsRestack = 0;
|
||||
let openPrs = 0;
|
||||
|
||||
for (const row of overview.branches) {
|
||||
if (row.taskId) {
|
||||
mapped += 1;
|
||||
}
|
||||
if (row.conflictsWithMain) {
|
||||
conflicts += 1;
|
||||
}
|
||||
if (row.trackedInStack && row.parentBranch && row.hasUnpushed) {
|
||||
needsRestack += 1;
|
||||
}
|
||||
if (row.prNumber && row.prState !== "MERGED" && row.prState !== "CLOSED") {
|
||||
openPrs += 1;
|
||||
}
|
||||
|
|
@ -181,16 +169,11 @@ function repoSummary(overview: RepoOverview | undefined): {
|
|||
total: overview.branches.length,
|
||||
mapped,
|
||||
unmapped: Math.max(0, overview.branches.length - mapped),
|
||||
conflicts,
|
||||
needsRestack,
|
||||
openPrs,
|
||||
};
|
||||
}
|
||||
|
||||
function branchKind(row: RepoBranchRecord): StatusTagKind {
|
||||
if (row.conflictsWithMain) {
|
||||
return "negative";
|
||||
}
|
||||
if (row.prState === "OPEN" || row.prState === "DRAFT") {
|
||||
return "warning";
|
||||
}
|
||||
|
|
@ -333,7 +316,7 @@ function MetaRow({ label, value, mono = false }: { label: string; value: string;
|
|||
);
|
||||
}
|
||||
|
||||
export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId }: WorkspaceDashboardProps) {
|
||||
export function OrganizationDashboard({ organizationId, selectedTaskId, selectedRepoId }: OrganizationDashboardProps) {
|
||||
const [css, theme] = useStyletron();
|
||||
const navigate = useNavigate();
|
||||
const showDevPanel = useDevPanel();
|
||||
|
|
@ -346,16 +329,9 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [createOnBranch, setCreateOnBranch] = useState<string | null>(null);
|
||||
const [addRepoOpen, setAddRepoOpen] = useState(false);
|
||||
const [createTaskOpen, setCreateTaskOpen] = useState(false);
|
||||
const [addRepoRemote, setAddRepoRemote] = useState("");
|
||||
const [addRepoError, setAddRepoError] = useState<string | null>(null);
|
||||
const [stackActionError, setStackActionError] = useState<string | null>(null);
|
||||
const [stackActionMessage, setStackActionMessage] = useState<string | null>(null);
|
||||
const [selectedOverviewBranch, setSelectedOverviewBranch] = useState<string | null>(null);
|
||||
const [overviewFilter, setOverviewFilter] = useState<RepoOverviewFilter>("active");
|
||||
const [reparentBranchName, setReparentBranchName] = useState<string | null>(null);
|
||||
const [reparentParentBranch, setReparentParentBranch] = useState("");
|
||||
const [newAgentType, setNewAgentType] = useState<AgentType>(() => {
|
||||
try {
|
||||
const raw = globalThis.localStorage?.getItem("hf.settings.agentType");
|
||||
|
|
@ -366,16 +342,19 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const repos = workspaceState.data?.repos ?? [];
|
||||
const rows = workspaceState.data?.taskSummaries ?? [];
|
||||
const appState = useSubscription(subscriptionManager, "app", {});
|
||||
const activeOrg = appState.data ? currentFoundryOrganization(appState.data) : null;
|
||||
|
||||
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
|
||||
const repos = organizationState.data?.repos ?? [];
|
||||
const rows = organizationState.data?.taskSummaries ?? [];
|
||||
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
|
||||
const taskState = useInterest(
|
||||
interestManager,
|
||||
const taskState = useSubscription(
|
||||
subscriptionManager,
|
||||
"task",
|
||||
!repoOverviewMode && selectedSummary
|
||||
? {
|
||||
workspaceId,
|
||||
organizationId,
|
||||
repoId: selectedSummary.repoId,
|
||||
taskId: selectedSummary.id,
|
||||
}
|
||||
|
|
@ -384,13 +363,13 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
const activeRepoId = selectedRepoId ?? createRepoId;
|
||||
|
||||
const repoOverviewQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "repo-overview", activeRepoId],
|
||||
queryKey: ["organization", organizationId, "repo-overview", activeRepoId],
|
||||
enabled: Boolean(repoOverviewMode && activeRepoId),
|
||||
queryFn: async () => {
|
||||
if (!activeRepoId) {
|
||||
throw new Error("No repo selected");
|
||||
}
|
||||
return backendClient.getRepoOverview(workspaceId, activeRepoId);
|
||||
return backendClient.getRepoOverview(organizationId, activeRepoId);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -455,16 +434,16 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
useEffect(() => {
|
||||
if (!repoOverviewMode && !selectedTaskId && rows.length > 0) {
|
||||
void navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
to: "/organizations/$organizationId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
organizationId,
|
||||
taskId: rows[0]!.id,
|
||||
},
|
||||
search: { sessionId: undefined },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [navigate, repoOverviewMode, rows, selectedTaskId, workspaceId]);
|
||||
}, [navigate, repoOverviewMode, rows, selectedTaskId, organizationId]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSessionId(null);
|
||||
|
|
@ -494,12 +473,12 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
);
|
||||
const resolvedSessionId = sessionSelection.sessionId;
|
||||
const staleSessionId = sessionSelection.staleSessionId;
|
||||
const sessionState = useInterest(
|
||||
interestManager,
|
||||
const sessionState = useSubscription(
|
||||
subscriptionManager,
|
||||
"session",
|
||||
selectedForSession && resolvedSessionId
|
||||
? {
|
||||
workspaceId,
|
||||
organizationId,
|
||||
repoId: selectedForSession.repoId,
|
||||
taskId: selectedForSession.id,
|
||||
sessionId: resolvedSessionId,
|
||||
|
|
@ -537,9 +516,9 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
}, [repoOverviewMode, selectedForSession, selectedSummary]);
|
||||
const devPanelSnapshot = useMemo(
|
||||
(): TaskWorkbenchSnapshot => ({
|
||||
workspaceId,
|
||||
organizationId,
|
||||
repos: repos.map((repo) => ({ id: repo.id, label: repo.label })),
|
||||
projects: [],
|
||||
repositories: [],
|
||||
tasks: rows.map((task) => ({
|
||||
id: task.id,
|
||||
repoId: task.repoId,
|
||||
|
|
@ -551,7 +530,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
updatedAtMs: task.updatedAtMs,
|
||||
branch: task.branch ?? null,
|
||||
pullRequest: task.pullRequest,
|
||||
tabs: task.sessionsSummary.map((session) => ({
|
||||
sessions: task.sessionsSummary.map((session) => ({
|
||||
...session,
|
||||
draft: {
|
||||
text: "",
|
||||
|
|
@ -567,7 +546,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
activeSandboxId: selectedForSession?.id === task.id ? selectedForSession.activeSandboxId : null,
|
||||
})),
|
||||
}),
|
||||
[repos, rows, selectedForSession, workspaceId],
|
||||
[repos, rows, selectedForSession, organizationId],
|
||||
);
|
||||
|
||||
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
|
||||
|
|
@ -575,8 +554,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
throw new Error("No sandbox is available for this task");
|
||||
}
|
||||
return backendClient.createSandboxSession({
|
||||
workspaceId,
|
||||
providerId: activeSandbox.providerId,
|
||||
organizationId,
|
||||
sandboxProviderId: activeSandbox.sandboxProviderId,
|
||||
sandboxId: activeSandbox.sandboxId,
|
||||
prompt: selectedForSession.task,
|
||||
cwd: activeSandbox.cwd ?? undefined,
|
||||
|
|
@ -607,8 +586,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
}
|
||||
const sessionId = await ensureSessionForPrompt();
|
||||
await backendClient.sendSandboxPrompt({
|
||||
workspaceId,
|
||||
providerId: activeSandbox.providerId,
|
||||
organizationId,
|
||||
sandboxProviderId: activeSandbox.sandboxProviderId,
|
||||
sandboxId: activeSandbox.sandboxId,
|
||||
sessionId,
|
||||
prompt,
|
||||
|
|
@ -634,7 +613,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
const draftBranchName = newBranchName.trim();
|
||||
|
||||
return backendClient.createTask({
|
||||
workspaceId,
|
||||
organizationId,
|
||||
repoId,
|
||||
task,
|
||||
agentType: newAgentType,
|
||||
|
|
@ -651,9 +630,9 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
setCreateOnBranch(null);
|
||||
setCreateTaskOpen(false);
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
to: "/organizations/$organizationId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
organizationId,
|
||||
taskId: task.taskId,
|
||||
},
|
||||
search: { sessionId: undefined },
|
||||
|
|
@ -664,63 +643,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
},
|
||||
});
|
||||
|
||||
const addRepo = useMutation({
|
||||
mutationFn: async (remoteUrl: string) => {
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Remote URL is required");
|
||||
}
|
||||
return backendClient.addRepo(workspaceId, trimmed);
|
||||
},
|
||||
onSuccess: async (created) => {
|
||||
setAddRepoError(null);
|
||||
setAddRepoRemote("");
|
||||
setAddRepoOpen(false);
|
||||
setCreateRepoId(created.repoId);
|
||||
if (repoOverviewMode) {
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/repos/$repoId",
|
||||
params: {
|
||||
workspaceId,
|
||||
repoId: created.repoId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setAddRepoError(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
|
||||
const runStackAction = useMutation({
|
||||
mutationFn: async (input: { action: RepoStackAction; branchName?: string; parentBranch?: string }) => {
|
||||
if (!activeRepoId) {
|
||||
throw new Error("No repository selected");
|
||||
}
|
||||
return backendClient.runRepoStackAction({
|
||||
workspaceId,
|
||||
repoId: activeRepoId,
|
||||
action: input.action,
|
||||
branchName: input.branchName,
|
||||
parentBranch: input.parentBranch,
|
||||
});
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
if (result.executed) {
|
||||
setStackActionError(null);
|
||||
setStackActionMessage(result.message);
|
||||
} else {
|
||||
setStackActionMessage(null);
|
||||
setStackActionError(result.message);
|
||||
}
|
||||
await repoOverviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setStackActionMessage(null);
|
||||
setStackActionError(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
|
||||
const openCreateFromBranch = (repoId: string, branchName: string): void => {
|
||||
setCreateRepoId(repoId);
|
||||
setCreateOnBranch(branchName);
|
||||
|
|
@ -747,7 +669,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
|
||||
const overview = repoOverviewQuery.data;
|
||||
const overviewStats = repoSummary(overview);
|
||||
const stackActionsEnabled = Boolean(overview?.stackAvailable) && !runStackAction.isPending;
|
||||
const filteredOverviewBranches = useMemo(() => {
|
||||
if (!overview?.branches?.length) {
|
||||
return [];
|
||||
|
|
@ -774,26 +695,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
}
|
||||
}, [filteredOverviewBranches, selectedOverviewBranch]);
|
||||
|
||||
const handleReparentSubmit = (): void => {
|
||||
if (!reparentBranchName || !reparentParentBranch.trim()) {
|
||||
return;
|
||||
}
|
||||
setStackActionError(null);
|
||||
void runStackAction
|
||||
.mutateAsync({
|
||||
action: "reparent_branch",
|
||||
branchName: reparentBranchName,
|
||||
parentBranch: reparentParentBranch.trim(),
|
||||
})
|
||||
.then(() => {
|
||||
setReparentBranchName(null);
|
||||
setReparentParentBranch("");
|
||||
})
|
||||
.catch(() => {
|
||||
// mutation state is surfaced above
|
||||
});
|
||||
};
|
||||
|
||||
const modalOverrides = useMemo(
|
||||
() => ({
|
||||
Dialog: {
|
||||
|
|
@ -834,7 +735,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
gap: "2px",
|
||||
})}
|
||||
>
|
||||
<LabelXSmall color="contentTertiary">Workspace</LabelXSmall>
|
||||
<LabelXSmall color="contentTertiary">Organization</LabelXSmall>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
|
|
@ -844,7 +745,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
>
|
||||
<FolderGit2 size={14} />
|
||||
<HeadingXSmall marginTop="0" marginBottom="0">
|
||||
{workspaceId}
|
||||
{organizationId}
|
||||
</HeadingXSmall>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -853,12 +754,14 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
size="compact"
|
||||
kind="secondary"
|
||||
onClick={() => {
|
||||
setAddRepoError(null);
|
||||
setAddRepoOpen(true);
|
||||
void navigate({
|
||||
to: "/organizations/$organizationId/settings",
|
||||
params: { organizationId },
|
||||
});
|
||||
}}
|
||||
data-testid="repo-add-open"
|
||||
data-testid="organization-settings-open"
|
||||
>
|
||||
Add Repo
|
||||
GitHub Settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -873,14 +776,14 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
</PanelHeader>
|
||||
|
||||
<ScrollBody>
|
||||
{workspaceState.status === "loading" ? (
|
||||
{organizationState.status === "loading" ? (
|
||||
<>
|
||||
<Skeleton rows={3} height="72px" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{workspaceState.status !== "loading" && repoGroups.length === 0 ? (
|
||||
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
|
||||
{organizationState.status !== "loading" && repoGroups.length === 0 ? (
|
||||
<EmptyState>No repos or tasks yet. Create the repository in GitHub, then sync repos from organization settings.</EmptyState>
|
||||
) : null}
|
||||
|
||||
{repoGroups.map((group) => (
|
||||
|
|
@ -894,8 +797,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
})}
|
||||
>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId/repos/$repoId"
|
||||
params={{ workspaceId, repoId: group.repoId }}
|
||||
to="/organizations/$organizationId/repos/$repoId"
|
||||
params={{ organizationId, repoId: group.repoId }}
|
||||
className={css({
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
|
|
@ -929,8 +832,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
to="/workspaces/$workspaceId/tasks/$taskId"
|
||||
params={{ workspaceId, taskId: task.id }}
|
||||
to="/organizations/$organizationId/tasks/$taskId"
|
||||
params={{ organizationId, taskId: task.id }}
|
||||
search={{ sessionId: undefined }}
|
||||
className={css({
|
||||
display: "block",
|
||||
|
|
@ -1051,41 +954,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
overrides={selectTestIdOverrides("repo-overview-filter")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="compact"
|
||||
kind="secondary"
|
||||
disabled={!stackActionsEnabled}
|
||||
onClick={() => {
|
||||
setStackActionError(null);
|
||||
void runStackAction.mutateAsync({ action: "sync_repo" });
|
||||
}}
|
||||
data-testid="repo-stack-sync"
|
||||
>
|
||||
Sync Stack
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="compact"
|
||||
kind="secondary"
|
||||
disabled={!stackActionsEnabled}
|
||||
onClick={() => {
|
||||
setStackActionError(null);
|
||||
void runStackAction.mutateAsync({ action: "restack_repo" });
|
||||
}}
|
||||
data-testid="repo-stack-restack-all"
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: theme.sizing.scale200,
|
||||
})}
|
||||
>
|
||||
<Shuffle size={14} />
|
||||
Restack All
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1099,28 +967,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
<StatusPill kind="neutral">Branches {overviewStats.total}</StatusPill>
|
||||
<StatusPill kind="positive">Mapped {overviewStats.mapped}</StatusPill>
|
||||
<StatusPill kind="warning">Unmapped {overviewStats.unmapped}</StatusPill>
|
||||
<StatusPill kind="negative">Conflicts {overviewStats.conflicts}</StatusPill>
|
||||
<StatusPill kind="neutral">Open PRs {overviewStats.openPrs}</StatusPill>
|
||||
<StatusPill kind="neutral">Needs restack {overviewStats.needsRestack}</StatusPill>
|
||||
</div>
|
||||
|
||||
{overview && !overview.stackAvailable ? (
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary" data-testid="repo-stack-unavailable">
|
||||
git-spice is unavailable for this repo. Stack actions are disabled.
|
||||
</ParagraphSmall>
|
||||
) : null}
|
||||
|
||||
{stackActionError ? (
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="repo-stack-error">
|
||||
{stackActionError}
|
||||
</ParagraphSmall>
|
||||
) : null}
|
||||
|
||||
{stackActionMessage ? (
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="positive" data-testid="repo-stack-message">
|
||||
{stackActionMessage}
|
||||
</ParagraphSmall>
|
||||
) : null}
|
||||
</PanelHeader>
|
||||
|
||||
<ScrollBody data-testid="repo-overview-center">
|
||||
|
|
@ -1139,10 +987,10 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
className={css({
|
||||
minWidth: "980px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.3fr 0.8fr 1fr 1fr 1.4fr",
|
||||
gridTemplateColumns: "2fr 1.3fr 1fr 1fr 0.9fr 1.2fr",
|
||||
})}
|
||||
>
|
||||
{["Branch", "Parent", "Ahead", "PR", "CI/Review", "Actions"].map((label) => (
|
||||
{["Branch", "Task", "PR", "CI / Review", "Updated", "Actions"].map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className={css({
|
||||
|
|
@ -1201,15 +1049,13 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
gap: theme.sizing.scale200,
|
||||
})}
|
||||
>
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
||||
{formatRelativeAge(branch.updatedAt)}
|
||||
</ParagraphSmall>
|
||||
<StatusPill kind={branch.taskId ? "positive" : "warning"}>{branch.taskId ? "task" : "unmapped"}</StatusPill>
|
||||
{branch.trackedInStack ? <StatusPill kind="neutral">stack</StatusPill> : null}
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
||||
{branch.commitSha.slice(0, 10) || "-"}
|
||||
</ParagraphSmall>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cellClass}>{branch.parentBranch ?? "-"}</div>
|
||||
<div className={cellClass}>{branch.hasUnpushed ? "yes" : "-"}</div>
|
||||
<div className={cellClass}>{branch.taskTitle ?? branch.taskId ?? "-"}</div>
|
||||
<div className={cellClass}>
|
||||
{branch.prNumber ? (
|
||||
<a
|
||||
|
|
@ -1229,6 +1075,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
<div className={cellClass}>
|
||||
{branch.ciStatus ?? "-"} / {branch.reviewStatus ?? "-"}
|
||||
</div>
|
||||
<div className={cellClass}>{formatRelativeAge(branch.updatedAt)}</div>
|
||||
<div className={cellClass}>
|
||||
<div
|
||||
className={css({
|
||||
|
|
@ -1237,55 +1084,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
gap: theme.sizing.scale200,
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
size="compact"
|
||||
kind="tertiary"
|
||||
disabled={!stackActionsEnabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setStackActionError(null);
|
||||
void runStackAction.mutateAsync({
|
||||
action: "restack_subtree",
|
||||
branchName: branch.branchName,
|
||||
});
|
||||
}}
|
||||
data-testid={`repo-overview-restack-${branchToken}`}
|
||||
>
|
||||
Restack
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="compact"
|
||||
kind="tertiary"
|
||||
disabled={!stackActionsEnabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setStackActionError(null);
|
||||
void runStackAction.mutateAsync({
|
||||
action: "rebase_branch",
|
||||
branchName: branch.branchName,
|
||||
});
|
||||
}}
|
||||
data-testid={`repo-overview-rebase-${branchToken}`}
|
||||
>
|
||||
Rebase
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="compact"
|
||||
kind="tertiary"
|
||||
disabled={!stackActionsEnabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setReparentBranchName(branch.branchName);
|
||||
setReparentParentBranch(branch.parentBranch ?? "main");
|
||||
setStackActionError(null);
|
||||
}}
|
||||
data-testid={`repo-overview-reparent-${branchToken}`}
|
||||
>
|
||||
Reparent
|
||||
</Button>
|
||||
|
||||
{!branch.taskId ? (
|
||||
<Button
|
||||
size="compact"
|
||||
|
|
@ -1300,7 +1098,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
</Button>
|
||||
) : null}
|
||||
|
||||
<StatusPill kind={branchKind(branch)}>{branch.conflictsWithMain ? "conflict" : "ok"}</StatusPill>
|
||||
<StatusPill kind={branchKind(branch)}>{branch.prState?.toLowerCase() ?? "no pr"}</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1636,7 +1434,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
>
|
||||
<MetaRow label="Remote" value={overview.remoteUrl} />
|
||||
<MetaRow label="Base Ref" value={overview.baseRef ?? "-"} mono />
|
||||
<MetaRow label="Stack Tool" value={overview.stackAvailable ? "git-spice" : "unavailable"} />
|
||||
<MetaRow label="Fetched" value={new Date(overview.fetchedAt).toLocaleTimeString()} />
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1659,10 +1456,10 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
})}
|
||||
>
|
||||
<MetaRow label="Branch" value={selectedBranchOverview.branchName} mono />
|
||||
<MetaRow label="Parent" value={selectedBranchOverview.parentBranch ?? "-"} mono />
|
||||
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
|
||||
<MetaRow label="Diff" value={formatDiffStat(selectedBranchOverview.diffStat)} />
|
||||
<MetaRow label="Task" value={selectedBranchOverview.taskTitle ?? selectedBranchOverview.taskId ?? "-"} />
|
||||
<MetaRow label="PR" value={selectedBranchOverview.prUrl ?? "-"} />
|
||||
<MetaRow label="Updated" value={new Date(selectedBranchOverview.updatedAt).toLocaleTimeString()} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -1764,49 +1561,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
</ScrollBody>
|
||||
</DetailRail>
|
||||
|
||||
<Modal isOpen={addRepoOpen} onClose={() => setAddRepoOpen(false)} overrides={modalOverrides}>
|
||||
<ModalHeader>Add Repo</ModalHeader>
|
||||
<ModalBody>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: theme.sizing.scale500,
|
||||
})}
|
||||
>
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
||||
Add a git remote URL to this workspace.
|
||||
</ParagraphSmall>
|
||||
<Input
|
||||
placeholder="Git remote (e.g. https://github.com/org/repo.git or org/repo)"
|
||||
value={addRepoRemote}
|
||||
onChange={(event) => setAddRepoRemote(event.target.value)}
|
||||
overrides={inputTestIdOverrides("repo-add-remote")}
|
||||
/>
|
||||
{addRepoError ? (
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="repo-add-error">
|
||||
{addRepoError}
|
||||
</ParagraphSmall>
|
||||
) : null}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button kind="tertiary" onClick={() => setAddRepoOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAddRepoError(null);
|
||||
void addRepo.mutateAsync(addRepoRemote);
|
||||
}}
|
||||
disabled={addRepo.isPending || addRepoRemote.trim().length === 0}
|
||||
data-testid="repo-add-submit"
|
||||
>
|
||||
Add Repo
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={createTaskOpen}
|
||||
onClose={() => {
|
||||
|
|
@ -1847,34 +1601,9 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
overrides={selectTestIdOverrides("task-create-repo")}
|
||||
/>
|
||||
{repos.length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
marginTop: theme.sizing.scale300,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.sizing.scale300,
|
||||
padding: "12px",
|
||||
borderRadius: "0",
|
||||
border: `1px solid ${theme.colors.borderOpaque}`,
|
||||
backgroundColor: theme.colors.backgroundTertiary,
|
||||
})}
|
||||
>
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
||||
No repos yet.
|
||||
</ParagraphSmall>
|
||||
<Button
|
||||
size="compact"
|
||||
kind="secondary"
|
||||
onClick={() => {
|
||||
setCreateTaskOpen(false);
|
||||
setAddRepoError(null);
|
||||
setAddRepoOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Repo
|
||||
</Button>
|
||||
</div>
|
||||
<ParagraphSmall marginTop="8px" marginBottom="0" color="contentSecondary">
|
||||
No imported repos yet. Create the repository in GitHub first, then sync repos from organization settings.
|
||||
</ParagraphSmall>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -1967,52 +1696,10 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={reparentBranchName !== null}
|
||||
onClose={() => {
|
||||
setReparentBranchName(null);
|
||||
setReparentParentBranch("");
|
||||
}}
|
||||
overrides={modalOverrides}
|
||||
>
|
||||
<ModalHeader>Reparent Branch</ModalHeader>
|
||||
<ModalBody>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: theme.sizing.scale500,
|
||||
})}
|
||||
>
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
||||
{reparentBranchName ? `Move ${reparentBranchName} onto a different parent branch.` : ""}
|
||||
</ParagraphSmall>
|
||||
<Input
|
||||
value={reparentParentBranch}
|
||||
onChange={(event) => setReparentParentBranch(event.target.value)}
|
||||
placeholder="Parent branch"
|
||||
overrides={inputTestIdOverrides("repo-overview-reparent-input")}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
kind="tertiary"
|
||||
onClick={() => {
|
||||
setReparentBranchName(null);
|
||||
setReparentParentBranch("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!reparentBranchName || !reparentParentBranch.trim()} onClick={handleReparentSubmit}>
|
||||
Reparent
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</DashboardGrid>
|
||||
{showDevPanel ? <DevPanel workspaceId={workspaceId} snapshot={devPanelSnapshot} focusedTask={devPanelFocusedTask} /> : null}
|
||||
{showDevPanel ? (
|
||||
<DevPanel organizationId={organizationId} snapshot={devPanelSnapshot} organization={activeOrg} focusedTask={devPanelFocusedTask} />
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,14 +3,14 @@ import type { TaskRecord } from "@sandbox-agent/foundry-shared";
|
|||
import { formatDiffStat, groupTasksByRepo } from "./model";
|
||||
|
||||
const base: TaskRecord = {
|
||||
workspaceId: "default",
|
||||
organizationId: "default",
|
||||
repoId: "repo-a",
|
||||
repoRemote: "https://example.com/repo-a.git",
|
||||
taskId: "task-1",
|
||||
branchName: "feature/one",
|
||||
title: "Feature one",
|
||||
task: "Ship one",
|
||||
providerId: "local",
|
||||
sandboxProviderId: "local",
|
||||
status: "running",
|
||||
statusMessage: null,
|
||||
activeSandboxId: "sandbox-1",
|
||||
|
|
@ -18,7 +18,7 @@ const base: TaskRecord = {
|
|||
sandboxes: [
|
||||
{
|
||||
sandboxId: "sandbox-1",
|
||||
providerId: "local",
|
||||
sandboxProviderId: "local",
|
||||
sandboxActorId: null,
|
||||
switchTarget: "sandbox://local/sandbox-1",
|
||||
cwd: null,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { createBackendClient } from "@sandbox-agent/foundry-client";
|
||||
import { backendEndpoint, defaultWorkspaceId, frontendClientMode } from "./env";
|
||||
import { backendEndpoint, defaultOrganizationId, frontendClientMode } from "./env";
|
||||
|
||||
export const backendClient = createBackendClient({
|
||||
endpoint: backendEndpoint,
|
||||
defaultWorkspaceId,
|
||||
defaultOrganizationId,
|
||||
mode: frontendClientMode,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
type FoundryRuntimeConfig = {
|
||||
backendEndpoint?: string;
|
||||
defaultWorkspaceId?: string;
|
||||
defaultOrganizationId?: string;
|
||||
frontendClientMode?: string;
|
||||
};
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ const runtimeConfig = typeof window !== "undefined" ? window.__FOUNDRY_RUNTIME_C
|
|||
|
||||
export const backendEndpoint = runtimeConfig?.backendEndpoint?.trim() || import.meta.env.VITE_HF_BACKEND_ENDPOINT?.trim() || resolveDefaultBackendEndpoint();
|
||||
|
||||
export const defaultWorkspaceId = runtimeConfig?.defaultWorkspaceId?.trim() || import.meta.env.VITE_HF_WORKSPACE?.trim() || "default";
|
||||
export const defaultOrganizationId = runtimeConfig?.defaultOrganizationId?.trim() || import.meta.env.VITE_HF_WORKSPACE?.trim() || "default";
|
||||
|
||||
function resolveFrontendClientMode(): "mock" | "remote" {
|
||||
const raw = runtimeConfig?.frontendClientMode?.trim().toLowerCase() || frontendEnv.FOUNDRY_FRONTEND_CLIENT_MODE?.trim().toLowerCase();
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { MockInterestManager, RemoteInterestManager } from "@sandbox-agent/foundry-client";
|
||||
import { backendClient } from "./backend";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
export const interestManager = frontendClientMode === "mock" ? new MockInterestManager() : new RemoteInterestManager(backendClient);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
createFoundryAppClient,
|
||||
useInterest,
|
||||
useSubscription,
|
||||
currentFoundryOrganization,
|
||||
currentFoundryUser,
|
||||
eligibleFoundryOrganizations,
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from "@sandbox-agent/foundry-client";
|
||||
import type { FoundryAppSnapshot, FoundryBillingPlanId, FoundryOrganization, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared";
|
||||
import { backendClient } from "./backend";
|
||||
import { interestManager } from "./interest";
|
||||
import { subscriptionManager } from "./subscription";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
const REMOTE_APP_SESSION_STORAGE_KEY = "sandbox-agent-foundry:remote-app-session";
|
||||
|
|
@ -37,10 +37,10 @@ const legacyAppClient: FoundryAppClient = createFoundryAppClient({
|
|||
|
||||
const remoteAppClient: FoundryAppClient = {
|
||||
getSnapshot(): FoundryAppSnapshot {
|
||||
return interestManager.getSnapshot("app", {}) ?? EMPTY_APP_SNAPSHOT;
|
||||
return subscriptionManager.getSnapshot("app", {}) ?? EMPTY_APP_SNAPSHOT;
|
||||
},
|
||||
subscribe(listener: () => void): () => void {
|
||||
return interestManager.subscribe("app", {}, listener);
|
||||
return subscriptionManager.subscribe("app", {}, listener);
|
||||
},
|
||||
async signInWithGithub(userId?: string): Promise<void> {
|
||||
void userId;
|
||||
|
|
@ -79,8 +79,8 @@ const remoteAppClient: FoundryAppClient = {
|
|||
async reconnectGithub(organizationId: string): Promise<void> {
|
||||
await backendClient.reconnectAppGithub(organizationId);
|
||||
},
|
||||
async recordSeatUsage(workspaceId: string): Promise<void> {
|
||||
await backendClient.recordAppSeatUsage(workspaceId);
|
||||
async recordSeatUsage(organizationId: string): Promise<void> {
|
||||
await backendClient.recordAppSeatUsage(organizationId);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ const appClient: FoundryAppClient = frontendClientMode === "remote" ? remoteAppC
|
|||
|
||||
export function useMockAppSnapshot(): FoundryAppSnapshot {
|
||||
if (frontendClientMode === "remote") {
|
||||
const app = useInterest(interestManager, "app", {});
|
||||
const app = useSubscription(subscriptionManager, "app", {});
|
||||
if (app.status !== "loading") {
|
||||
firstSnapshotDelivered = true;
|
||||
}
|
||||
|
|
|
|||
5
foundry/packages/frontend/src/lib/subscription.ts
Normal file
5
foundry/packages/frontend/src/lib/subscription.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { MockSubscriptionManager, RemoteSubscriptionManager } from "@sandbox-agent/foundry-client";
|
||||
import { backendClient } from "./backend";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
export const subscriptionManager = frontendClientMode === "mock" ? new MockSubscriptionManager() : new RemoteSubscriptionManager(backendClient);
|
||||
Loading…
Add table
Add a link
Reference in a new issue