mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 23:02:04 +00:00
Fix Foundry UI bugs: org names, sessions, and repo selection (#250)
* Fix Foundry auth: migrate to Better Auth adapter, fix access token retrieval - Remove @ts-nocheck from better-auth.ts, auth-user/index.ts, app-shell.ts and fix all type errors - Fix getAccessTokenForSession: read GitHub token directly from account record instead of calling Better Auth's internal /get-access-token endpoint which returns 403 on server-side calls - Re-implement workspaceAuth helper functions (workspaceAuthColumn, normalizeAuthValue, workspaceAuthClause, workspaceAuthWhere) that were accidentally deleted - Remove all retry logic (withRetries, isRetryableAppActorError) - Implement CORS origin allowlist from configured environment - Document cachedAppWorkspace singleton pattern - Add inline org sync fallback in buildAppSnapshot for post-OAuth flow - Add no-retry rule to CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add Foundry dev panel from fix-git-data branch Port the dev panel component that was left out when PR #243 was replaced by PR #247. Adapted to remove runtime/mock-debug references that don't exist on the current branch. - Toggle with Shift+D, persists visibility to localStorage - Shows context, session, GitHub sync status sections - Dev-only (import.meta.env.DEV) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add full Docker image defaults, fix actor deadlocks, and improve dev experience - Add Dockerfile.full and --all flag to install-agent CLI for pre-built images - Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full - Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example - Expand Docker docs with full runnable Dockerfile - Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning) - Audit and convert 12 task actions from wait:true to wait:false - Add bun --hot for dev backend hot reload - Remove --force from pnpm install in dev Dockerfile for faster startup - Add env_file support to compose.dev.yaml for automatic credential loading - Add mock frontend compose config and dev panel - Update CLAUDE.md with wait:true policy and dev environment setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * WIP: async action fixes and interest manager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation - Fix org display name using GitHub description instead of name field - Fix createWorkbenchSession hanging when sandbox is provisioning - Fix auto-session creation retry storm on errors - Fix task creation using wrong repo due to React state race conditions - Remove Bun hot-reload from backend Dockerfile (causes port drift) - Add GitHub sync/install status to dev panel Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
58c54156f1
commit
d8b8b49f37
88 changed files with 9252 additions and 1933 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { type ReactNode, useEffect } from "react";
|
||||
import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client";
|
||||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { MockLayout } from "../components/mock-layout";
|
||||
import {
|
||||
|
|
@ -12,8 +13,8 @@ import {
|
|||
MockSignInPage,
|
||||
} from "../components/mock-onboarding";
|
||||
import { defaultWorkspaceId, isMockFrontendClient } from "../lib/env";
|
||||
import { interestManager } from "../lib/interest";
|
||||
import { activeMockOrganization, getMockOrganizationById, isAppSnapshotBootstrapping, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import { getTaskWorkbenchClient } from "../lib/workbench";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
|
|
@ -324,7 +325,7 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil
|
|||
}
|
||||
|
||||
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
|
||||
const taskWorkbenchClient = getTaskWorkbenchClient(workspaceId);
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
|
|
@ -332,7 +333,7 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
|
|||
repoId,
|
||||
});
|
||||
}, [repoId, workspaceId]);
|
||||
const activeTaskId = taskWorkbenchClient.getSnapshot().tasks.find((task) => task.repoId === repoId)?.id;
|
||||
const activeTaskId = workspaceState.data?.taskSummaries.find((task) => task.repoId === repoId)?.id;
|
||||
if (!activeTaskId) {
|
||||
return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId }} replace />;
|
||||
}
|
||||
|
|
|
|||
379
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
379
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useStyletron } from "baseui";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
import { isMockFrontendClient } from "../lib/env";
|
||||
import type { FoundryOrganization, TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
interface DevPanelProps {
|
||||
workspaceId: string;
|
||||
snapshot: TaskWorkbenchSnapshot;
|
||||
organization?: FoundryOrganization | null;
|
||||
}
|
||||
|
||||
interface TopicInfo {
|
||||
label: string;
|
||||
key: string;
|
||||
listenerCount: number;
|
||||
hasConnection: boolean;
|
||||
lastRefresh: number | null;
|
||||
}
|
||||
|
||||
function timeAgo(ts: number | null): string {
|
||||
if (!ts) return "never";
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||
if (seconds < 5) return "now";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
return `${Math.floor(minutes / 60)}h`;
|
||||
}
|
||||
|
||||
function taskStatusLabel(task: WorkbenchTask): string {
|
||||
if (task.status === "archived") return "archived";
|
||||
const hasRunning = task.tabs?.some((tab) => tab.status === "running");
|
||||
if (hasRunning) return "running";
|
||||
return task.status ?? "idle";
|
||||
}
|
||||
|
||||
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return t.statusSuccess;
|
||||
case "archived":
|
||||
return t.textMuted;
|
||||
case "error":
|
||||
case "failed":
|
||||
return t.statusError;
|
||||
default:
|
||||
return t.textTertiary;
|
||||
}
|
||||
}
|
||||
|
||||
function syncStatusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||
switch (status) {
|
||||
case "synced":
|
||||
return t.statusSuccess;
|
||||
case "syncing":
|
||||
case "pending":
|
||||
return t.statusWarning;
|
||||
case "error":
|
||||
return t.statusError;
|
||||
default:
|
||||
return t.textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
function installStatusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return t.statusSuccess;
|
||||
case "install_required":
|
||||
return t.statusWarning;
|
||||
case "reconnect_required":
|
||||
return t.statusError;
|
||||
default:
|
||||
return t.textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organization }: DevPanelProps) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// Tick every 2s to keep relative timestamps fresh
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const topics = useMemo((): TopicInfo[] => {
|
||||
const items: TopicInfo[] = [];
|
||||
|
||||
// Workbench subscription topic
|
||||
items.push({
|
||||
label: "Workbench",
|
||||
key: `ws:${workspaceId}`,
|
||||
listenerCount: 1,
|
||||
hasConnection: true,
|
||||
lastRefresh: now,
|
||||
});
|
||||
|
||||
// Per-task tab subscriptions
|
||||
for (const task of snapshot.tasks ?? []) {
|
||||
if (task.status === "archived") continue;
|
||||
for (const tab of task.tabs ?? []) {
|
||||
items.push({
|
||||
label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`,
|
||||
key: `${workspaceId}:${task.id}:${tab.id}`,
|
||||
listenerCount: 1,
|
||||
hasConnection: tab.status === "running",
|
||||
lastRefresh: tab.status === "running" ? now : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [workspaceId, snapshot, now]);
|
||||
|
||||
const tasks = snapshot.tasks ?? [];
|
||||
const repos = snapshot.repos ?? [];
|
||||
const projects = snapshot.projects ?? [];
|
||||
|
||||
const mono = css({
|
||||
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
|
||||
fontSize: "10px",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
right: "8px",
|
||||
width: "320px",
|
||||
maxHeight: "50vh",
|
||||
zIndex: 99999,
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.borderMedium}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "4px 8px",
|
||||
borderBottom: `1px solid ${t.borderSubtle}`,
|
||||
backgroundColor: t.surfaceTertiary,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
color: t.textSecondary,
|
||||
letterSpacing: "0.5px",
|
||||
textTransform: "uppercase",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
})}
|
||||
>
|
||||
Dev
|
||||
{isMockFrontendClient && <span className={css({ fontSize: "8px", fontWeight: 600, color: t.statusWarning, letterSpacing: "0.3px" })}>MOCK</span>}
|
||||
</span>
|
||||
<span className={css({ fontSize: "9px", color: t.textMuted })}>Shift+D</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className={css({ overflowY: "auto", padding: "6px" })}>
|
||||
{/* Interest Topics */}
|
||||
<Section label="Interest Topics" t={t} css={css}>
|
||||
{topics.map((topic) => (
|
||||
<div
|
||||
key={topic.key}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "2px 0",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: topic.hasConnection ? t.statusSuccess : t.textMuted,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||
{topic.label}
|
||||
</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key}</span>
|
||||
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(topic.lastRefresh)}</span>
|
||||
</div>
|
||||
))}
|
||||
{topics.length === 0 && <span className={css({ fontSize: "10px", color: t.textMuted })}>No active subscriptions</span>}
|
||||
</Section>
|
||||
|
||||
{/* Snapshot Summary */}
|
||||
<Section label="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="projects" value={projects.length} t={t} css={css} />
|
||||
<Stat label="tasks" value={tasks.length} t={t} css={css} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Tasks */}
|
||||
{tasks.length > 0 && (
|
||||
<Section label="Tasks" t={t} css={css}>
|
||||
{tasks.slice(0, 10).map((task) => {
|
||||
const status = taskStatusLabel(task);
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "1px 0",
|
||||
fontSize: "10px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: statusColor(status, t),
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||
{task.title || task.id.slice(0, 12)}
|
||||
</span>
|
||||
<span className={`${mono} ${css({ color: statusColor(status, t) })}`}>{status}</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{task.tabs?.length ?? 0} tabs</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* GitHub */}
|
||||
{organization && (
|
||||
<Section label="GitHub" 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: installStatusColor(organization.github.installationStatus, t),
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>App</span>
|
||||
<span className={`${mono} ${css({ color: installStatusColor(organization.github.installationStatus, t) })}`}>
|
||||
{organization.github.installationStatus.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: syncStatusColor(organization.github.syncStatus, t),
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
|
||||
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
|
||||
</div>
|
||||
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
||||
<Stat label="repos imported" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||
</div>
|
||||
{organization.github.connectedAccount && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
|
||||
)}
|
||||
{organization.github.lastSyncLabel && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {organization.github.lastSyncLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Workspace */}
|
||||
<Section label="Workspace" t={t} css={css}>
|
||||
<div className={`${mono} ${css({ color: t.textTertiary })}`}>{workspaceId}</div>
|
||||
{organization && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "2px" })}`}>
|
||||
org: {organization.settings.displayName} ({organization.kind})
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Section({
|
||||
label,
|
||||
t,
|
||||
css: cssFn,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cssFn({ marginBottom: "6px" })}>
|
||||
<div
|
||||
className={cssFn({
|
||||
fontSize: "9px",
|
||||
fontWeight: 600,
|
||||
color: t.textMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
marginBottom: "2px",
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
t,
|
||||
css: cssFn,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
}) {
|
||||
return (
|
||||
<span>
|
||||
<span className={cssFn({ fontWeight: 600, color: t.textPrimary })}>{value}</span>
|
||||
<span className={cssFn({ color: t.textTertiary, marginLeft: "2px" })}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDevPanel() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.shiftKey && e.key === "D" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
e.preventDefault();
|
||||
setVisible((prev) => !prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useStyletron } from "baseui";
|
||||
import { createErrorContext } from "@sandbox-agent/foundry-shared";
|
||||
import {
|
||||
createErrorContext,
|
||||
type TaskWorkbenchSnapshot,
|
||||
type WorkbenchSessionSummary,
|
||||
type WorkbenchTaskDetail,
|
||||
type WorkbenchTaskSummary,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
|
||||
import { PanelLeft, PanelRight } from "lucide-react";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
|
|
@ -16,6 +23,7 @@ import { TabStrip } from "./mock-layout/tab-strip";
|
|||
import { TerminalPane } from "./mock-layout/terminal-pane";
|
||||
import { TranscriptHeader } from "./mock-layout/transcript-header";
|
||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
||||
import { DevPanel, useDevPanel } from "./dev-panel";
|
||||
import {
|
||||
buildDisplayMessages,
|
||||
diffPath,
|
||||
|
|
@ -30,7 +38,8 @@ import {
|
|||
type ModelId,
|
||||
} from "./mock-layout/view-model";
|
||||
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import { getTaskWorkbenchClient } from "../lib/workbench";
|
||||
import { backendClient } from "../lib/backend";
|
||||
import { interestManager } from "../lib/interest";
|
||||
|
||||
function firstAgentTabId(task: Task): string | null {
|
||||
return task.tabs[0]?.id ?? null;
|
||||
|
|
@ -65,6 +74,81 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
|
|||
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
|
||||
}
|
||||
|
||||
function toLegacyTab(
|
||||
summary: WorkbenchSessionSummary,
|
||||
sessionDetail?: { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] },
|
||||
): Task["tabs"][number] {
|
||||
return {
|
||||
id: summary.id,
|
||||
sessionId: summary.sessionId,
|
||||
sessionName: summary.sessionName,
|
||||
agent: summary.agent,
|
||||
model: summary.model,
|
||||
status: summary.status,
|
||||
thinkingSinceMs: summary.thinkingSinceMs,
|
||||
unread: summary.unread,
|
||||
created: summary.created,
|
||||
draft: sessionDetail?.draft ?? {
|
||||
text: "",
|
||||
attachments: [],
|
||||
updatedAtMs: null,
|
||||
},
|
||||
transcript: sessionDetail?.transcript ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function toLegacyTask(
|
||||
summary: WorkbenchTaskSummary,
|
||||
detail?: WorkbenchTaskDetail,
|
||||
sessionCache?: Map<string, { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] }>,
|
||||
): Task {
|
||||
const sessions = detail?.sessionsSummary ?? summary.sessionsSummary;
|
||||
return {
|
||||
id: summary.id,
|
||||
repoId: summary.repoId,
|
||||
title: detail?.title ?? summary.title,
|
||||
status: detail?.status ?? summary.status,
|
||||
repoName: detail?.repoName ?? summary.repoName,
|
||||
updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs,
|
||||
branch: detail?.branch ?? summary.branch,
|
||||
pullRequest: detail?.pullRequest ?? summary.pullRequest,
|
||||
tabs: sessions.map((session) => toLegacyTab(session, sessionCache?.get(session.id))),
|
||||
fileChanges: detail?.fileChanges ?? [],
|
||||
diffs: detail?.diffs ?? {},
|
||||
fileTree: detail?.fileTree ?? [],
|
||||
minutesUsed: detail?.minutesUsed ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[]) {
|
||||
return repos
|
||||
.map((repo) => ({
|
||||
id: repo.id,
|
||||
label: repo.label,
|
||||
updatedAtMs: tasks.filter((task) => task.repoId === repo.id).reduce((latest, task) => Math.max(latest, task.updatedAtMs), 0),
|
||||
tasks: tasks.filter((task) => task.repoId === repo.id).sort((left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
}))
|
||||
.filter((repo) => repo.tasks.length > 0);
|
||||
}
|
||||
|
||||
interface WorkbenchActions {
|
||||
createTask(input: { repoId: string; task: string; title?: string; branch?: string; model?: ModelId }): Promise<{ taskId: string; tabId?: string }>;
|
||||
markTaskUnread(input: { taskId: string }): Promise<void>;
|
||||
renameTask(input: { taskId: string; value: string }): Promise<void>;
|
||||
renameBranch(input: { taskId: string; value: string }): Promise<void>;
|
||||
archiveTask(input: { taskId: string }): Promise<void>;
|
||||
publishPr(input: { taskId: string }): Promise<void>;
|
||||
revertFile(input: { taskId: string; path: string }): Promise<void>;
|
||||
updateDraft(input: { taskId: string; tabId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
|
||||
sendMessage(input: { taskId: string; tabId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
|
||||
stopAgent(input: { taskId: string; tabId: string }): Promise<void>;
|
||||
setSessionUnread(input: { taskId: string; tabId: string; unread: boolean }): Promise<void>;
|
||||
renameSession(input: { taskId: string; tabId: string; title: string }): Promise<void>;
|
||||
closeTab(input: { taskId: string; tabId: string }): Promise<void>;
|
||||
addTab(input: { taskId: string; model?: string }): Promise<{ tabId: string }>;
|
||||
changeModel(input: { taskId: string; tabId: string; model: ModelId }): Promise<void>;
|
||||
}
|
||||
|
||||
const TranscriptPanel = memo(function TranscriptPanel({
|
||||
taskWorkbenchClient,
|
||||
task,
|
||||
|
|
@ -83,7 +167,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onToggleRightSidebar,
|
||||
onNavigateToUsage,
|
||||
}: {
|
||||
taskWorkbenchClient: ReturnType<typeof getTaskWorkbenchClient>;
|
||||
taskWorkbenchClient: WorkbenchActions;
|
||||
task: Task;
|
||||
activeTabId: string | null;
|
||||
lastAgentTabId: string | null;
|
||||
|
|
@ -727,7 +811,7 @@ const RightRail = memo(function RightRail({
|
|||
}, [clampTerminalHeight]);
|
||||
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
(event: ReactPointerEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const startY = event.clientY;
|
||||
|
|
@ -902,19 +986,87 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const navigate = useNavigate();
|
||||
const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]);
|
||||
const viewModel = useSyncExternalStore(
|
||||
taskWorkbenchClient.subscribe.bind(taskWorkbenchClient),
|
||||
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
|
||||
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
|
||||
const taskWorkbenchClient = useMemo<WorkbenchActions>(
|
||||
() => ({
|
||||
createTask: (input) => backendClient.createWorkbenchTask(workspaceId, input),
|
||||
markTaskUnread: (input) => backendClient.markWorkbenchUnread(workspaceId, input),
|
||||
renameTask: (input) => backendClient.renameWorkbenchTask(workspaceId, input),
|
||||
renameBranch: (input) => backendClient.renameWorkbenchBranch(workspaceId, input),
|
||||
archiveTask: async (input) => backendClient.runAction(workspaceId, input.taskId, "archive"),
|
||||
publishPr: (input) => backendClient.publishWorkbenchPr(workspaceId, input),
|
||||
revertFile: (input) => backendClient.revertWorkbenchFile(workspaceId, input),
|
||||
updateDraft: (input) => backendClient.updateWorkbenchDraft(workspaceId, input),
|
||||
sendMessage: (input) => backendClient.sendWorkbenchMessage(workspaceId, input),
|
||||
stopAgent: (input) => backendClient.stopWorkbenchSession(workspaceId, input),
|
||||
setSessionUnread: (input) => backendClient.setWorkbenchSessionUnread(workspaceId, input),
|
||||
renameSession: (input) => backendClient.renameWorkbenchSession(workspaceId, input),
|
||||
closeTab: (input) => backendClient.closeWorkbenchSession(workspaceId, input),
|
||||
addTab: (input) => backendClient.createWorkbenchSession(workspaceId, input),
|
||||
changeModel: (input) => backendClient.changeWorkbenchModel(workspaceId, input),
|
||||
}),
|
||||
[workspaceId],
|
||||
);
|
||||
const tasks = viewModel.tasks ?? [];
|
||||
const rawProjects = viewModel.projects ?? [];
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const workspaceRepos = workspaceState.data?.repos ?? [];
|
||||
const taskSummaries = workspaceState.data?.taskSummaries ?? [];
|
||||
const selectedTaskSummary = useMemo(
|
||||
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
|
||||
[selectedTaskId, taskSummaries],
|
||||
);
|
||||
const taskState = useInterest(
|
||||
interestManager,
|
||||
"task",
|
||||
selectedTaskSummary
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: selectedTaskSummary.repoId,
|
||||
taskId: selectedTaskSummary.id,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const sessionState = useInterest(
|
||||
interestManager,
|
||||
"session",
|
||||
selectedTaskSummary && selectedSessionId
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: selectedTaskSummary.repoId,
|
||||
taskId: selectedTaskSummary.id,
|
||||
sessionId: selectedSessionId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const tasks = useMemo(() => {
|
||||
const sessionCache = new Map<string, { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] }>();
|
||||
if (selectedTaskSummary && taskState.data) {
|
||||
for (const session of taskState.data.sessionsSummary) {
|
||||
const cached =
|
||||
(selectedSessionId && session.id === selectedSessionId ? sessionState.data : undefined) ??
|
||||
interestManager.getSnapshot("session", {
|
||||
workspaceId,
|
||||
repoId: selectedTaskSummary.repoId,
|
||||
taskId: selectedTaskSummary.id,
|
||||
sessionId: session.id,
|
||||
});
|
||||
if (cached) {
|
||||
sessionCache.set(session.id, {
|
||||
draft: cached.draft,
|
||||
transcript: cached.transcript,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return taskSummaries.map((summary) =>
|
||||
summary.id === selectedTaskSummary?.id ? toLegacyTask(summary, taskState.data, sessionCache) : toLegacyTask(summary),
|
||||
);
|
||||
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
|
||||
const rawProjects = useMemo(() => groupProjects(workspaceRepos, tasks), [tasks, workspaceRepos]);
|
||||
const appSnapshot = useMockAppSnapshot();
|
||||
const activeOrg = activeMockOrganization(appSnapshot);
|
||||
const navigateToUsage = useCallback(() => {
|
||||
if (activeOrg) {
|
||||
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } });
|
||||
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } as never });
|
||||
}
|
||||
}, [activeOrg, navigate]);
|
||||
const [projectOrder, setProjectOrder] = useState<string[] | null>(null);
|
||||
|
|
@ -939,6 +1091,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
|
||||
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
|
||||
const showDevPanel = useDevPanel();
|
||||
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const startPeek = useCallback(() => {
|
||||
|
|
@ -1084,16 +1237,16 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
}, [activeTask, lastAgentTabIdByTask, selectedSessionId, syncRouteSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNewTaskRepoId && viewModel.repos.some((repo) => repo.id === selectedNewTaskRepoId)) {
|
||||
if (selectedNewTaskRepoId && workspaceRepos.some((repo) => repo.id === selectedNewTaskRepoId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackRepoId =
|
||||
activeTask?.repoId && viewModel.repos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (viewModel.repos[0]?.id ?? "");
|
||||
activeTask?.repoId && workspaceRepos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (workspaceRepos[0]?.id ?? "");
|
||||
if (fallbackRepoId !== selectedNewTaskRepoId) {
|
||||
setSelectedNewTaskRepoId(fallbackRepoId);
|
||||
}
|
||||
}, [activeTask?.repoId, selectedNewTaskRepoId, viewModel.repos]);
|
||||
}, [activeTask?.repoId, selectedNewTaskRepoId, workspaceRepos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTask) {
|
||||
|
|
@ -1123,35 +1276,38 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
},
|
||||
"failed_to_auto_create_workbench_session",
|
||||
);
|
||||
} finally {
|
||||
autoCreatingSessionForTaskRef.current.delete(activeTask.id);
|
||||
// Keep the guard in the set on error to prevent retry storms.
|
||||
// The guard is cleared when tabs appear (line above) or the task changes.
|
||||
}
|
||||
})();
|
||||
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
||||
|
||||
const createTask = useCallback(() => {
|
||||
void (async () => {
|
||||
const repoId = selectedNewTaskRepoId;
|
||||
if (!repoId) {
|
||||
throw new Error("Cannot create a task without an available repo");
|
||||
}
|
||||
const createTask = useCallback(
|
||||
(overrideRepoId?: string) => {
|
||||
void (async () => {
|
||||
const repoId = overrideRepoId || selectedNewTaskRepoId;
|
||||
if (!repoId) {
|
||||
throw new Error("Cannot create a task without an available repo");
|
||||
}
|
||||
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId,
|
||||
task: "New task",
|
||||
model: "gpt-4o",
|
||||
title: "New task",
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId,
|
||||
},
|
||||
search: { sessionId: tabId ?? undefined },
|
||||
});
|
||||
})();
|
||||
}, [navigate, selectedNewTaskRepoId, workspaceId]);
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId,
|
||||
task: "New task",
|
||||
model: "gpt-4o",
|
||||
title: "New task",
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId,
|
||||
},
|
||||
search: { sessionId: tabId ?? undefined },
|
||||
});
|
||||
})();
|
||||
},
|
||||
[navigate, selectedNewTaskRepoId, workspaceId],
|
||||
);
|
||||
|
||||
const openDiffTab = useCallback(
|
||||
(path: string) => {
|
||||
|
|
@ -1283,7 +1439,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const onDragMouseDown = useCallback((event: ReactPointerEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
// Tauri v2 IPC: invoke start_dragging on the webview window
|
||||
const ipc = (window as Record<string, unknown>).__TAURI_INTERNALS__ as { invoke: (cmd: string, args?: unknown) => Promise<unknown> } | undefined;
|
||||
const ipc = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ as
|
||||
| {
|
||||
invoke: (cmd: string, args?: unknown) => Promise<unknown>;
|
||||
}
|
||||
| undefined;
|
||||
if (ipc?.invoke) {
|
||||
ipc.invoke("plugin:window|start_dragging").catch(() => {});
|
||||
}
|
||||
|
|
@ -1359,10 +1519,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
transition: sidebarTransition,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={viewModel.repos}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId=""
|
||||
onSelect={selectTask}
|
||||
|
|
@ -1415,29 +1575,63 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
{viewModel.repos.length > 0
|
||||
? "Start from the sidebar to create a task on the first available repo."
|
||||
: "No repos are available in this workspace yet."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createTask}
|
||||
disabled={viewModel.repos.length === 0}
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
border: 0,
|
||||
borderRadius: "999px",
|
||||
padding: "10px 18px",
|
||||
background: viewModel.repos.length > 0 ? t.borderMedium : t.textTertiary,
|
||||
color: t.textPrimary,
|
||||
cursor: viewModel.repos.length > 0 ? "pointer" : "not-allowed",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
New task
|
||||
</button>
|
||||
{activeOrg?.github.syncStatus === "syncing" || activeOrg?.github.syncStatus === "pending" ? (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
border: `2px solid ${t.borderSubtle}`,
|
||||
borderTopColor: t.textSecondary,
|
||||
borderRadius: "50%",
|
||||
animationName: {
|
||||
from: { transform: "rotate(0deg)" },
|
||||
to: { transform: "rotate(360deg)" },
|
||||
} as unknown as string,
|
||||
animationDuration: "0.8s",
|
||||
animationIterationCount: "infinite",
|
||||
animationTimingFunction: "linear",
|
||||
alignSelf: "center",
|
||||
})}
|
||||
/>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Syncing with GitHub</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
Importing repos from @{activeOrg.github.connectedAccount || "GitHub"}...
|
||||
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
|
||||
</p>
|
||||
</>
|
||||
) : activeOrg?.github.syncStatus === "error" ? (
|
||||
<>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>There was a problem syncing repos from GitHub. Check the dev panel for details.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
{workspaceRepos.length > 0
|
||||
? "Start from the sidebar to create a task on the first available repo."
|
||||
: "No repos are available in this workspace yet."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createTask()}
|
||||
disabled={workspaceRepos.length === 0}
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
border: 0,
|
||||
borderRadius: "999px",
|
||||
padding: "10px 18px",
|
||||
background: workspaceRepos.length > 0 ? t.borderMedium : t.textTertiary,
|
||||
color: t.textPrimary,
|
||||
cursor: workspaceRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
New task
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollBody>
|
||||
|
|
@ -1460,6 +1654,47 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
fontSize: "11px",
|
||||
color: t.textPrimary,
|
||||
maxWidth: "360px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.statusError,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showDevPanel && (
|
||||
<DevPanel
|
||||
workspaceId={workspaceId}
|
||||
snapshot={{ workspaceId, repos: workspaceRepos, projects: rawProjects, tasks } as TaskWorkbenchSnapshot}
|
||||
organization={activeOrg}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1479,10 +1714,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
transition: sidebarTransition,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={viewModel.repos}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId={activeTask.id}
|
||||
onSelect={selectTask}
|
||||
|
|
@ -1530,7 +1765,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={viewModel.repos}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId={activeTask.id}
|
||||
onSelect={(id) => {
|
||||
|
|
@ -1610,6 +1845,47 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
fontSize: "11px",
|
||||
color: t.textPrimary,
|
||||
maxWidth: "360px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.statusError,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showDevPanel && (
|
||||
<DevPanel
|
||||
workspaceId={workspaceId}
|
||||
snapshot={{ workspaceId, repos: workspaceRepos, projects: rawProjects, tasks } as TaskWorkbenchSnapshot}
|
||||
organization={activeOrg}
|
||||
/>
|
||||
)}
|
||||
</Shell>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useStyletron } from "baseui";
|
||||
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
||||
import { Select, type Value } from "baseui/select";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
|
|
@ -26,6 +27,17 @@ import type { FoundryTokens } from "../../styles/tokens";
|
|||
|
||||
const PROJECT_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 {
|
||||
const slashIdx = label.indexOf("/");
|
||||
if (slashIdx < 0) return label;
|
||||
const prefix = label.slice(0, slashIdx + 1);
|
||||
if (repos.every((r) => r.label.startsWith(prefix))) {
|
||||
return label.slice(slashIdx + 1);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function projectInitial(label: string): string {
|
||||
const parts = label.split("/");
|
||||
const name = parts[parts.length - 1] ?? label;
|
||||
|
|
@ -61,7 +73,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
selectedNewTaskRepoId: string;
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onCreate: (repoId?: string) => void;
|
||||
onSelectNewTaskRepo: (repoId: string) => void;
|
||||
onMarkUnread: (id: string) => void;
|
||||
onRenameTask: (id: string) => void;
|
||||
|
|
@ -137,19 +149,8 @@ export const Sidebar = memo(function Sidebar({
|
|||
};
|
||||
}, [drag, onReorderProjects, onReorderTasks]);
|
||||
|
||||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||||
const createMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createMenuOpen) return;
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (createMenuRef.current && !createMenuRef.current.contains(event.target as Node)) {
|
||||
setCreateMenuOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [createMenuOpen]);
|
||||
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
||||
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
|
||||
|
||||
return (
|
||||
<SPanel>
|
||||
|
|
@ -232,7 +233,99 @@ export const Sidebar = memo(function Sidebar({
|
|||
<PanelLeft size={14} />
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={createMenuRef} className={css({ position: "relative", flexShrink: 0 })}>
|
||||
{createSelectOpen ? (
|
||||
<div className={css({ flex: 1, minWidth: 0 })}>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
value={[]}
|
||||
placeholder="Search repos..."
|
||||
type="search"
|
||||
openOnClick
|
||||
autoFocus
|
||||
onChange={({ value }: { value: Value }) => {
|
||||
const selected = value[0];
|
||||
if (selected) {
|
||||
onSelectNewTaskRepo(selected.id as string);
|
||||
setCreateSelectOpen(false);
|
||||
onCreate(selected.id as string);
|
||||
}
|
||||
}}
|
||||
onClose={() => setCreateSelectOpen(false)}
|
||||
overrides={{
|
||||
Root: {
|
||||
style: {
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
ControlContainer: {
|
||||
style: {
|
||||
backgroundColor: t.surfaceTertiary,
|
||||
borderTopColor: t.borderSubtle,
|
||||
borderBottomColor: t.borderSubtle,
|
||||
borderLeftColor: t.borderSubtle,
|
||||
borderRightColor: t.borderSubtle,
|
||||
borderTopWidth: "1px",
|
||||
borderBottomWidth: "1px",
|
||||
borderLeftWidth: "1px",
|
||||
borderRightWidth: "1px",
|
||||
borderTopLeftRadius: "6px",
|
||||
borderTopRightRadius: "6px",
|
||||
borderBottomLeftRadius: "6px",
|
||||
borderBottomRightRadius: "6px",
|
||||
minHeight: "28px",
|
||||
paddingLeft: "8px",
|
||||
},
|
||||
},
|
||||
ValueContainer: {
|
||||
style: {
|
||||
paddingTop: "0px",
|
||||
paddingBottom: "0px",
|
||||
},
|
||||
},
|
||||
Input: {
|
||||
style: {
|
||||
fontSize: "12px",
|
||||
color: t.textPrimary,
|
||||
},
|
||||
},
|
||||
Placeholder: {
|
||||
style: {
|
||||
fontSize: "12px",
|
||||
color: t.textMuted,
|
||||
},
|
||||
},
|
||||
Dropdown: {
|
||||
style: {
|
||||
backgroundColor: t.surfaceElevated,
|
||||
borderTopColor: t.borderDefault,
|
||||
borderBottomColor: t.borderDefault,
|
||||
borderLeftColor: t.borderDefault,
|
||||
borderRightColor: t.borderDefault,
|
||||
maxHeight: "min(320px, 50vh)",
|
||||
},
|
||||
},
|
||||
DropdownListItem: {
|
||||
style: {
|
||||
fontSize: "12px",
|
||||
paddingTop: "6px",
|
||||
paddingBottom: "6px",
|
||||
},
|
||||
},
|
||||
IconsContainer: {
|
||||
style: {
|
||||
paddingRight: "4px",
|
||||
},
|
||||
},
|
||||
SearchIconContainer: {
|
||||
style: {
|
||||
paddingLeft: "0px",
|
||||
paddingRight: "4px",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
|
@ -241,9 +334,9 @@ export const Sidebar = memo(function Sidebar({
|
|||
if (newTaskRepos.length === 0) return;
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate();
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateMenuOpen((prev) => !prev);
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
|
|
@ -251,9 +344,9 @@ export const Sidebar = memo(function Sidebar({
|
|||
if (event.key === "Enter" || event.key === " ") {
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate();
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateMenuOpen((prev) => !prev);
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
@ -275,80 +368,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
>
|
||||
<Plus size={14} style={{ display: "block" }} />
|
||||
</div>
|
||||
{createMenuOpen && newTaskRepos.length > 1 ? (
|
||||
<div
|
||||
className={css({
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
right: 0,
|
||||
marginTop: "4px",
|
||||
zIndex: 9999,
|
||||
minWidth: "200px",
|
||||
borderRadius: "10px",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
backgroundColor: t.surfaceElevated,
|
||||
boxShadow: `${t.shadow}, 0 0 0 1px ${t.interactiveSubtle}`,
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
maxHeight: "240px",
|
||||
overflowY: "auto",
|
||||
})}
|
||||
>
|
||||
{newTaskRepos.map((repo) => (
|
||||
<button
|
||||
key={repo.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectNewTaskRepo(repo.id);
|
||||
setCreateMenuOpen(false);
|
||||
onCreate();
|
||||
}}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: t.textSecondary,
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: 400,
|
||||
textAlign: "left",
|
||||
transition: "background 200ms ease, color 200ms ease",
|
||||
":hover": {
|
||||
backgroundColor: t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "4px",
|
||||
background: `linear-gradient(135deg, ${projectIconColor(repo.label)}, ${projectIconColor(repo.label + "x")})`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "9px",
|
||||
fontWeight: 700,
|
||||
color: t.textOnAccent,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{projectInitial(repo.label)}
|
||||
</span>
|
||||
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>{repo.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</PanelHeaderBar>
|
||||
<ScrollBody>
|
||||
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
|
||||
|
|
@ -458,7 +478,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{project.label}
|
||||
{stripCommonOrgPrefix(project.label, projects)}
|
||||
</LabelSmall>
|
||||
</div>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||
|
|
@ -468,7 +488,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
e.stopPropagation();
|
||||
setHoveredProjectId(null);
|
||||
onSelectNewTaskRepo(project.id);
|
||||
onCreate();
|
||||
onCreate(project.id);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={css({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { SandboxProcessRecord } from "@sandbox-agent/foundry-client";
|
||||
import { type SandboxProcessRecord, useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { ProcessTerminal } from "@sandbox-agent/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useStyletron } from "baseui";
|
||||
|
|
@ -7,6 +7,7 @@ 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";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
workspaceId: string;
|
||||
|
|
@ -135,6 +136,9 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
setProcessTabs((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(d.fromIdx, 1);
|
||||
if (!moved) {
|
||||
return prev;
|
||||
}
|
||||
next.splice(d.overIdx!, 0, moved);
|
||||
return next;
|
||||
});
|
||||
|
|
@ -180,28 +184,31 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
[listWidth],
|
||||
);
|
||||
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["mock-layout", "task", workspaceId, taskId],
|
||||
enabled: Boolean(taskId),
|
||||
staleTime: 1_000,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: (query) => (query.state.data?.activeSandboxId ? false : 2_000),
|
||||
queryFn: async () => {
|
||||
if (!taskId) {
|
||||
throw new Error("Cannot load terminal state without a task.");
|
||||
}
|
||||
return await backendClient.getTask(workspaceId, taskId);
|
||||
},
|
||||
});
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const taskSummary = useMemo(
|
||||
() => (taskId ? (workspaceState.data?.taskSummaries.find((task) => task.id === taskId) ?? null) : null),
|
||||
[taskId, workspaceState.data?.taskSummaries],
|
||||
);
|
||||
const taskState = useInterest(
|
||||
interestManager,
|
||||
"task",
|
||||
taskSummary
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: taskSummary.repoId,
|
||||
taskId: taskSummary.id,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const activeSandbox = useMemo(() => {
|
||||
const task = taskQuery.data;
|
||||
const task = taskState.data;
|
||||
if (!task?.activeSandboxId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return task.sandboxes.find((sandbox) => sandbox.sandboxId === task.activeSandboxId) ?? null;
|
||||
}, [taskQuery.data]);
|
||||
}, [taskState.data]);
|
||||
|
||||
const connectionQuery = useQuery({
|
||||
queryKey: ["mock-layout", "sandbox-agent-connection", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""],
|
||||
|
|
@ -217,30 +224,17 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
},
|
||||
});
|
||||
|
||||
const processesQuery = useQuery({
|
||||
queryKey: ["mock-layout", "sandbox-processes", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""],
|
||||
enabled: Boolean(activeSandbox?.sandboxId),
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: activeSandbox?.sandboxId ? 3_000 : false,
|
||||
queryFn: async () => {
|
||||
if (!activeSandbox) {
|
||||
throw new Error("Cannot load processes without an active sandbox.");
|
||||
}
|
||||
|
||||
return await backendClient.listSandboxProcesses(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSandbox?.sandboxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return backendClient.subscribeSandboxProcesses(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId, () => {
|
||||
void processesQuery.refetch();
|
||||
});
|
||||
}, [activeSandbox?.providerId, activeSandbox?.sandboxId, processesQuery, workspaceId]);
|
||||
const processesState = useInterest(
|
||||
interestManager,
|
||||
"sandboxProcesses",
|
||||
activeSandbox
|
||||
? {
|
||||
workspaceId,
|
||||
providerId: activeSandbox.providerId,
|
||||
sandboxId: activeSandbox.sandboxId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionQuery.data) {
|
||||
|
|
@ -311,7 +305,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
setProcessTabs([]);
|
||||
}, [taskId]);
|
||||
|
||||
const processes = processesQuery.data?.processes ?? [];
|
||||
const processes = processesState.data ?? [];
|
||||
|
||||
const openTerminalTab = useCallback((process: SandboxProcessRecord) => {
|
||||
setProcessTabs((current) => {
|
||||
|
|
@ -357,12 +351,11 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
sandboxId: activeSandbox.sandboxId,
|
||||
request: defaultShellRequest(activeSandbox.cwd),
|
||||
});
|
||||
await processesQuery.refetch();
|
||||
openTerminalTab(created);
|
||||
} finally {
|
||||
setCreatingProcess(false);
|
||||
}
|
||||
}, [activeSandbox, openTerminalTab, processesQuery, workspaceId]);
|
||||
}, [activeSandbox, openTerminalTab, workspaceId]);
|
||||
|
||||
const processTabsById = useMemo(() => new Map(processTabs.map((tab) => [tab.id, tab])), [processTabs]);
|
||||
const activeProcessTab = activeTabId ? (processTabsById.get(activeTabId) ?? null) : null;
|
||||
|
|
@ -462,9 +455,6 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
height: "100%",
|
||||
padding: "18px 16px 14px",
|
||||
}}
|
||||
onExit={() => {
|
||||
void processesQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -481,7 +471,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
);
|
||||
}
|
||||
|
||||
if (taskQuery.isLoading) {
|
||||
if (taskState.status === "loading") {
|
||||
return (
|
||||
<div className={emptyBodyClassName}>
|
||||
<div className={emptyCopyClassName}>
|
||||
|
|
@ -491,12 +481,12 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
);
|
||||
}
|
||||
|
||||
if (taskQuery.error) {
|
||||
if (taskState.error) {
|
||||
return (
|
||||
<div className={emptyBodyClassName}>
|
||||
<div className={emptyCopyClassName}>
|
||||
<strong>Could not load task state.</strong>
|
||||
<span>{taskQuery.error.message}</span>
|
||||
<span>{taskState.error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,11 @@ function DesktopDragRegion() {
|
|||
const isDesktop = !!import.meta.env.VITE_DESKTOP;
|
||||
const onDragMouseDown = useCallback((event: React.PointerEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
const ipc = (window as Record<string, unknown>).__TAURI_INTERNALS__ as { invoke: (cmd: string, args?: unknown) => Promise<unknown> } | undefined;
|
||||
const ipc = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ as
|
||||
| {
|
||||
invoke: (cmd: string, args?: unknown) => Promise<unknown>;
|
||||
}
|
||||
| undefined;
|
||||
if (ipc?.invoke) {
|
||||
ipc.invoke("plugin:window|start_dragging").catch(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { AgentType, TaskRecord, TaskSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/foundry-shared";
|
||||
import { groupTaskStatus, type SandboxSessionEventRecord } from "@sandbox-agent/foundry-client";
|
||||
import type { AgentType, RepoBranchRecord, RepoOverview, RepoStackAction, WorkbenchTaskStatus } from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "baseui/button";
|
||||
|
|
@ -17,6 +17,7 @@ import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizon
|
|||
import { formatDiffStat } from "../features/tasks/model";
|
||||
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
|
||||
import { backendClient } from "../lib/backend";
|
||||
import { interestManager } from "../lib/interest";
|
||||
|
||||
interface WorkspaceDashboardProps {
|
||||
workspaceId: string;
|
||||
|
|
@ -96,11 +97,9 @@ const AGENT_OPTIONS: SelectItem[] = [
|
|||
{ id: "claude", label: "claude" },
|
||||
];
|
||||
|
||||
function statusKind(status: TaskSummary["status"]): StatusTagKind {
|
||||
const group = groupTaskStatus(status);
|
||||
if (group === "running") return "positive";
|
||||
if (group === "queued") return "warning";
|
||||
if (group === "error") return "negative";
|
||||
function statusKind(status: WorkbenchTaskStatus): StatusTagKind {
|
||||
if (status === "running") return "positive";
|
||||
if (status === "new") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
|
|
@ -135,26 +134,6 @@ function branchTestIdToken(value: string): string {
|
|||
return token || "branch";
|
||||
}
|
||||
|
||||
function useSessionEvents(
|
||||
task: TaskRecord | null,
|
||||
sessionId: string | null,
|
||||
): ReturnType<typeof useQuery<{ items: SandboxSessionEventRecord[]; nextCursor?: string }, Error>> {
|
||||
return useQuery({
|
||||
queryKey: ["workspace", task?.workspaceId ?? "", "session", task?.taskId ?? "", sessionId ?? ""],
|
||||
enabled: Boolean(task?.activeSandboxId && sessionId),
|
||||
refetchInterval: 2_500,
|
||||
queryFn: async () => {
|
||||
if (!task?.activeSandboxId || !sessionId) {
|
||||
return { items: [] };
|
||||
}
|
||||
return backendClient.listSandboxSessionEvents(task.workspaceId, task.providerId, task.activeSandboxId, {
|
||||
sessionId,
|
||||
limit: 120,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function repoSummary(overview: RepoOverview | undefined): {
|
||||
total: number;
|
||||
mapped: number;
|
||||
|
|
@ -382,37 +361,26 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "tasks"],
|
||||
queryFn: async () => backendClient.listTasks(workspaceId),
|
||||
refetchInterval: 2_500,
|
||||
});
|
||||
|
||||
const taskDetailQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "task-detail", selectedTaskId],
|
||||
enabled: Boolean(selectedTaskId && !repoOverviewMode),
|
||||
refetchInterval: 2_500,
|
||||
queryFn: async () => {
|
||||
if (!selectedTaskId) {
|
||||
throw new Error("No task selected");
|
||||
}
|
||||
return backendClient.getTask(workspaceId, selectedTaskId);
|
||||
},
|
||||
});
|
||||
|
||||
const reposQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "repos"],
|
||||
queryFn: async () => backendClient.listRepos(workspaceId),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const repos = reposQuery.data ?? [];
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const repos = workspaceState.data?.repos ?? [];
|
||||
const rows = workspaceState.data?.taskSummaries ?? [];
|
||||
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
|
||||
const taskState = useInterest(
|
||||
interestManager,
|
||||
"task",
|
||||
!repoOverviewMode && selectedSummary
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: selectedSummary.repoId,
|
||||
taskId: selectedSummary.id,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const activeRepoId = selectedRepoId ?? createRepoId;
|
||||
|
||||
const repoOverviewQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "repo-overview", activeRepoId],
|
||||
enabled: Boolean(repoOverviewMode && activeRepoId),
|
||||
refetchInterval: 5_000,
|
||||
queryFn: async () => {
|
||||
if (!activeRepoId) {
|
||||
throw new Error("No repo selected");
|
||||
|
|
@ -427,7 +395,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
return;
|
||||
}
|
||||
if (!createRepoId && repos.length > 0) {
|
||||
setCreateRepoId(repos[0]!.repoId);
|
||||
setCreateRepoId(repos[0]!.id);
|
||||
}
|
||||
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]);
|
||||
|
||||
|
|
@ -439,9 +407,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
}
|
||||
}, [newAgentType]);
|
||||
|
||||
const rows = tasksQuery.data ?? [];
|
||||
const repoGroups = useMemo(() => {
|
||||
const byRepo = new Map<string, TaskSummary[]>();
|
||||
const byRepo = new Map<string, typeof rows>();
|
||||
for (const row of rows) {
|
||||
const bucket = byRepo.get(row.repoId);
|
||||
if (bucket) {
|
||||
|
|
@ -453,12 +420,12 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
|
||||
return repos
|
||||
.map((repo) => {
|
||||
const tasks = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const latestTaskAt = tasks[0]?.updatedAt ?? 0;
|
||||
const tasks = [...(byRepo.get(repo.id) ?? [])].sort((a, b) => b.updatedAtMs - a.updatedAtMs);
|
||||
const latestTaskAt = tasks[0]?.updatedAtMs ?? 0;
|
||||
return {
|
||||
repoId: repo.repoId,
|
||||
repoRemote: repo.remoteUrl,
|
||||
latestActivityAt: Math.max(repo.updatedAt, latestTaskAt),
|
||||
repoId: repo.id,
|
||||
repoLabel: repo.label,
|
||||
latestActivityAt: Math.max(repo.latestActivityMs, latestTaskAt),
|
||||
tasks,
|
||||
};
|
||||
})
|
||||
|
|
@ -466,13 +433,11 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
if (a.latestActivityAt !== b.latestActivityAt) {
|
||||
return b.latestActivityAt - a.latestActivityAt;
|
||||
}
|
||||
return a.repoRemote.localeCompare(b.repoRemote);
|
||||
return a.repoLabel.localeCompare(b.repoLabel);
|
||||
});
|
||||
}, [repos, rows]);
|
||||
|
||||
const selectedSummary = useMemo(() => rows.find((row) => row.taskId === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
|
||||
|
||||
const selectedForSession = repoOverviewMode ? null : (taskDetailQuery.data ?? null);
|
||||
const selectedForSession = repoOverviewMode ? null : (taskState.data ?? null);
|
||||
|
||||
const activeSandbox = useMemo(() => {
|
||||
if (!selectedForSession) return null;
|
||||
|
|
@ -488,7 +453,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId: rows[0]!.taskId,
|
||||
taskId: rows[0]!.id,
|
||||
},
|
||||
search: { sessionId: undefined },
|
||||
replace: true,
|
||||
|
|
@ -499,35 +464,39 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
useEffect(() => {
|
||||
setActiveSessionId(null);
|
||||
setDraft("");
|
||||
}, [selectedForSession?.taskId]);
|
||||
}, [selectedForSession?.id]);
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"],
|
||||
enabled: Boolean(activeSandbox?.sandboxId && selectedForSession),
|
||||
refetchInterval: 3_000,
|
||||
queryFn: async () => {
|
||||
if (!activeSandbox?.sandboxId || !selectedForSession) {
|
||||
return { items: [] };
|
||||
}
|
||||
return backendClient.listSandboxSessions(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId, {
|
||||
limit: 30,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sessionRows = sessionsQuery.data?.items ?? [];
|
||||
const sessionRows = selectedForSession?.sessionsSummary ?? [];
|
||||
const sessionSelection = useMemo(
|
||||
() =>
|
||||
resolveSessionSelection({
|
||||
explicitSessionId: activeSessionId,
|
||||
taskSessionId: selectedForSession?.activeSessionId ?? null,
|
||||
sessions: sessionRows,
|
||||
sessions: sessionRows.map((session) => ({
|
||||
id: session.id,
|
||||
agent: session.agent,
|
||||
agentSessionId: session.sessionId ?? "",
|
||||
lastConnectionId: "",
|
||||
createdAt: 0,
|
||||
status: session.status,
|
||||
})),
|
||||
}),
|
||||
[activeSessionId, selectedForSession?.activeSessionId, sessionRows],
|
||||
);
|
||||
const resolvedSessionId = sessionSelection.sessionId;
|
||||
const staleSessionId = sessionSelection.staleSessionId;
|
||||
const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId);
|
||||
const sessionState = useInterest(
|
||||
interestManager,
|
||||
"session",
|
||||
selectedForSession && resolvedSessionId
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: selectedForSession.repoId,
|
||||
taskId: selectedForSession.id,
|
||||
sessionId: resolvedSessionId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
|
||||
|
||||
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
|
||||
|
|
@ -546,9 +515,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
|
||||
const createSession = useMutation({
|
||||
mutationFn: async () => startSessionFromTask(),
|
||||
onSuccess: async (session) => {
|
||||
onSuccess: (session) => {
|
||||
setActiveSessionId(session.id);
|
||||
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -558,7 +526,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
}
|
||||
const created = await startSessionFromTask();
|
||||
setActiveSessionId(created.id);
|
||||
await sessionsQuery.refetch();
|
||||
return created.id;
|
||||
};
|
||||
|
||||
|
|
@ -576,13 +543,12 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
prompt,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: () => {
|
||||
setDraft("");
|
||||
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
|
||||
},
|
||||
});
|
||||
|
||||
const transcript = buildTranscript(eventsQuery.data?.items ?? []);
|
||||
const transcript = buildTranscript(sessionState.data?.transcript ?? []);
|
||||
const canCreateTask = createRepoId.trim().length > 0 && newTask.trim().length > 0;
|
||||
|
||||
const createTask = useMutation({
|
||||
|
|
@ -613,8 +579,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
setNewBranchName("");
|
||||
setCreateOnBranch(null);
|
||||
setCreateTaskOpen(false);
|
||||
await tasksQuery.refetch();
|
||||
await repoOverviewQuery.refetch();
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
|
|
@ -641,7 +605,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
setAddRepoError(null);
|
||||
setAddRepoRemote("");
|
||||
setAddRepoOpen(false);
|
||||
await reposQuery.refetch();
|
||||
setCreateRepoId(created.repoId);
|
||||
if (repoOverviewMode) {
|
||||
await navigate({
|
||||
|
|
@ -679,7 +642,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
setStackActionMessage(null);
|
||||
setStackActionError(result.message);
|
||||
}
|
||||
await Promise.all([repoOverviewQuery.refetch(), tasksQuery.refetch()]);
|
||||
await repoOverviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setStackActionMessage(null);
|
||||
|
|
@ -698,7 +661,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
setCreateTaskOpen(true);
|
||||
};
|
||||
|
||||
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]);
|
||||
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [repos]);
|
||||
const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null;
|
||||
const selectedAgentOption = useMemo(() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!), [newAgentType]);
|
||||
const selectedFilterOption = useMemo(
|
||||
|
|
@ -706,7 +669,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
[overviewFilter],
|
||||
);
|
||||
const sessionOptions = useMemo(
|
||||
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.id} (${session.status ?? "running"})` })),
|
||||
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.sessionName} (${session.status})` })),
|
||||
[sessionRows],
|
||||
);
|
||||
const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null;
|
||||
|
|
@ -839,13 +802,15 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
</PanelHeader>
|
||||
|
||||
<ScrollBody>
|
||||
{tasksQuery.isLoading ? (
|
||||
{workspaceState.status === "loading" ? (
|
||||
<>
|
||||
<Skeleton rows={3} height="72px" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!tasksQuery.isLoading && repoGroups.length === 0 ? <EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState> : null}
|
||||
{workspaceState.status !== "loading" && repoGroups.length === 0 ? (
|
||||
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
|
||||
) : null}
|
||||
|
||||
{repoGroups.map((group) => (
|
||||
<section
|
||||
|
|
@ -876,7 +841,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
})}
|
||||
data-testid={group.repoId === activeRepoId ? "repo-overview-open" : `repo-overview-open-${group.repoId}`}
|
||||
>
|
||||
{group.repoRemote}
|
||||
{group.repoLabel}
|
||||
</Link>
|
||||
|
||||
<div
|
||||
|
|
@ -887,14 +852,14 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
})}
|
||||
>
|
||||
{group.tasks
|
||||
.filter((task) => task.status !== "archived" || task.taskId === selectedSummary?.taskId)
|
||||
.filter((task) => task.status !== "archived" || task.id === selectedSummary?.id)
|
||||
.map((task) => {
|
||||
const isActive = !repoOverviewMode && task.taskId === selectedSummary?.taskId;
|
||||
const isActive = !repoOverviewMode && task.id === selectedSummary?.id;
|
||||
return (
|
||||
<Link
|
||||
key={task.taskId}
|
||||
key={task.id}
|
||||
to="/workspaces/$workspaceId/tasks/$taskId"
|
||||
params={{ workspaceId, taskId: task.taskId }}
|
||||
params={{ workspaceId, taskId: task.id }}
|
||||
search={{ sessionId: undefined }}
|
||||
className={css({
|
||||
display: "block",
|
||||
|
|
@ -927,7 +892,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
color="contentSecondary"
|
||||
overrides={{ Block: { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } } }}
|
||||
>
|
||||
{task.branchName ?? "Determining branch..."}
|
||||
{task.branch ?? "Determining branch..."}
|
||||
</ParagraphSmall>
|
||||
<StatusPill kind={statusKind(task.status)}>{task.status}</StatusPill>
|
||||
</div>
|
||||
|
|
@ -1396,11 +1361,11 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
})}
|
||||
>
|
||||
{eventsQuery.isLoading ? <Skeleton rows={2} height="90px" /> : null}
|
||||
{resolvedSessionId && sessionState.status === "loading" ? <Skeleton rows={2} height="90px" /> : null}
|
||||
|
||||
{transcript.length === 0 && !eventsQuery.isLoading ? (
|
||||
{transcript.length === 0 && !(resolvedSessionId && sessionState.status === "loading") ? (
|
||||
<EmptyState testId="session-transcript-empty">
|
||||
{groupTaskStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage
|
||||
{selectedForSession.runtimeStatus === "error" && selectedForSession.statusMessage
|
||||
? `Session failed: ${selectedForSession.statusMessage}`
|
||||
: !activeSandbox?.sandboxId
|
||||
? selectedForSession.statusMessage
|
||||
|
|
@ -1597,7 +1562,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
gap: theme.sizing.scale300,
|
||||
})}
|
||||
>
|
||||
<MetaRow label="Task" value={selectedForSession.taskId} mono />
|
||||
<MetaRow label="Task" value={selectedForSession.id} mono />
|
||||
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
|
||||
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
|
||||
</div>
|
||||
|
|
@ -1615,7 +1580,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
gap: theme.sizing.scale300,
|
||||
})}
|
||||
>
|
||||
<MetaRow label="Branch" value={selectedForSession.branchName ?? "-"} mono />
|
||||
<MetaRow label="Branch" value={selectedForSession.branch ?? "-"} mono />
|
||||
<MetaRow label="Diff" value={formatDiffStat(selectedForSession.diffStat)} />
|
||||
<MetaRow label="PR" value={selectedForSession.prUrl ?? "-"} />
|
||||
<MetaRow label="Review" value={selectedForSession.reviewStatus ?? "-"} />
|
||||
|
|
@ -1641,7 +1606,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{groupTaskStatus(selectedForSession.status) === "error" ? (
|
||||
{selectedForSession.runtimeStatus === "error" ? (
|
||||
<div
|
||||
className={css({
|
||||
padding: "12px",
|
||||
|
|
|
|||
5
foundry/packages/frontend/src/lib/interest.ts
Normal file
5
foundry/packages/frontend/src/lib/interest.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { MockInterestManager, RemoteInterestManager } from "@sandbox-agent/foundry-client";
|
||||
import { backendClient } from "./backend";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
export const interestManager = frontendClientMode === "mock" ? new MockInterestManager() : new RemoteInterestManager(backendClient);
|
||||
|
|
@ -1,23 +1,100 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
createFoundryAppClient,
|
||||
useInterest,
|
||||
currentFoundryOrganization,
|
||||
currentFoundryUser,
|
||||
eligibleFoundryOrganizations,
|
||||
type FoundryAppClient,
|
||||
} from "@sandbox-agent/foundry-client";
|
||||
import type { FoundryAppSnapshot, FoundryOrganization } from "@sandbox-agent/foundry-shared";
|
||||
import type { FoundryAppSnapshot, FoundryBillingPlanId, FoundryOrganization, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared";
|
||||
import { backendClient } from "./backend";
|
||||
import { interestManager } from "./interest";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
const REMOTE_APP_SESSION_STORAGE_KEY = "sandbox-agent-foundry:remote-app-session";
|
||||
|
||||
const appClient: FoundryAppClient = createFoundryAppClient({
|
||||
const EMPTY_APP_SNAPSHOT: FoundryAppSnapshot = {
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
users: [],
|
||||
organizations: [],
|
||||
};
|
||||
|
||||
const legacyAppClient: FoundryAppClient = createFoundryAppClient({
|
||||
mode: frontendClientMode,
|
||||
backend: frontendClientMode === "remote" ? backendClient : undefined,
|
||||
});
|
||||
|
||||
const remoteAppClient: FoundryAppClient = {
|
||||
getSnapshot(): FoundryAppSnapshot {
|
||||
return interestManager.getSnapshot("app", {}) ?? EMPTY_APP_SNAPSHOT;
|
||||
},
|
||||
subscribe(listener: () => void): () => void {
|
||||
return interestManager.subscribe("app", {}, listener);
|
||||
},
|
||||
async signInWithGithub(userId?: string): Promise<void> {
|
||||
void userId;
|
||||
await backendClient.signInWithGithub();
|
||||
},
|
||||
async signOut(): Promise<void> {
|
||||
await backendClient.signOutApp();
|
||||
},
|
||||
async skipStarterRepo(): Promise<void> {
|
||||
await backendClient.skipAppStarterRepo();
|
||||
},
|
||||
async starStarterRepo(organizationId: string): Promise<void> {
|
||||
await backendClient.starAppStarterRepo(organizationId);
|
||||
},
|
||||
async selectOrganization(organizationId: string): Promise<void> {
|
||||
await backendClient.selectAppOrganization(organizationId);
|
||||
},
|
||||
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
|
||||
await backendClient.updateAppOrganizationProfile(input);
|
||||
},
|
||||
async triggerGithubSync(organizationId: string): Promise<void> {
|
||||
await backendClient.triggerAppRepoImport(organizationId);
|
||||
},
|
||||
async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
|
||||
await backendClient.completeAppHostedCheckout(organizationId, planId);
|
||||
},
|
||||
async openBillingPortal(organizationId: string): Promise<void> {
|
||||
await backendClient.openAppBillingPortal(organizationId);
|
||||
},
|
||||
async cancelScheduledRenewal(organizationId: string): Promise<void> {
|
||||
await backendClient.cancelAppScheduledRenewal(organizationId);
|
||||
},
|
||||
async resumeSubscription(organizationId: string): Promise<void> {
|
||||
await backendClient.resumeAppSubscription(organizationId);
|
||||
},
|
||||
async reconnectGithub(organizationId: string): Promise<void> {
|
||||
await backendClient.reconnectAppGithub(organizationId);
|
||||
},
|
||||
async recordSeatUsage(workspaceId: string): Promise<void> {
|
||||
await backendClient.recordAppSeatUsage(workspaceId);
|
||||
},
|
||||
};
|
||||
|
||||
const appClient: FoundryAppClient = frontendClientMode === "remote" ? remoteAppClient : legacyAppClient;
|
||||
|
||||
export function useMockAppSnapshot(): FoundryAppSnapshot {
|
||||
if (frontendClientMode === "remote") {
|
||||
const app = useInterest(interestManager, "app", {});
|
||||
if (app.status !== "loading") {
|
||||
firstSnapshotDelivered = true;
|
||||
}
|
||||
return app.data ?? EMPTY_APP_SNAPSHOT;
|
||||
}
|
||||
|
||||
return useSyncExternalStore(appClient.subscribe.bind(appClient), appClient.getSnapshot.bind(appClient), appClient.getSnapshot.bind(appClient));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { createTaskWorkbenchClient, type TaskWorkbenchClient } from "@sandbox-agent/foundry-client";
|
||||
import { backendClient } from "./backend";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
const workbenchClients = new Map<string, TaskWorkbenchClient>();
|
||||
|
||||
export function getTaskWorkbenchClient(workspaceId: string): TaskWorkbenchClient {
|
||||
const existing = workbenchClients.get(workspaceId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = createTaskWorkbenchClient({
|
||||
mode: frontendClientMode,
|
||||
backend: backendClient,
|
||||
workspaceId,
|
||||
});
|
||||
workbenchClients.set(workspaceId, created);
|
||||
return created;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue