chore(foundry): workbench action responsiveness (#254)

* wip

* wip
This commit is contained in:
Nathan Flurry 2026-03-14 20:42:18 -07:00 committed by GitHub
parent 400f9a214e
commit 99abb9d42e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
171 changed files with 7260 additions and 7342 deletions

View file

@ -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() {

View file

@ -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

View file

@ -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} />

View file

@ -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 (

View file

@ -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",

View file

@ -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;
});
}}

View file

@ -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;

View file

@ -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 (

View file

@ -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 }) => {

View file

@ -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,

View file

@ -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,

View file

@ -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" }}>

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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,
});

View file

@ -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();

View file

@ -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);

View file

@ -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;
}

View 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);