mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 01:00:32 +00:00
factory: rename project and handoff actors
This commit is contained in:
parent
3022bce2ad
commit
ea7c36a8e7
147 changed files with 6313 additions and 14364 deletions
|
|
@ -14,7 +14,6 @@ import { MockLayout } from "../components/mock-layout";
|
|||
import {
|
||||
MockHostedCheckoutPage,
|
||||
MockOrganizationBillingPage,
|
||||
MockOrganizationImportPage,
|
||||
MockOrganizationSelectorPage,
|
||||
MockOrganizationSettingsPage,
|
||||
MockSignInPage,
|
||||
|
|
@ -24,10 +23,12 @@ import {
|
|||
activeMockOrganization,
|
||||
activeMockUser,
|
||||
getMockOrganizationById,
|
||||
isAppSnapshotBootstrapping,
|
||||
eligibleOrganizations,
|
||||
useMockAppClient,
|
||||
useMockAppSnapshot,
|
||||
} from "../lib/mock-app";
|
||||
import { getHandoffWorkbenchClient, resolveRepoRouteHandoffId } from "../lib/workbench";
|
||||
import { getTaskWorkbenchClient, resolveRepoRouteTaskId } from "../lib/workbench";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
|
|
@ -51,12 +52,6 @@ const organizationsRoute = createRoute({
|
|||
component: OrganizationsRoute,
|
||||
});
|
||||
|
||||
const organizationImportRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/import",
|
||||
component: OrganizationImportRoute,
|
||||
});
|
||||
|
||||
const organizationSettingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/settings",
|
||||
|
|
@ -87,13 +82,13 @@ const workspaceIndexRoute = createRoute({
|
|||
component: WorkspaceRoute,
|
||||
});
|
||||
|
||||
const handoffRoute = createRoute({
|
||||
const taskRoute = createRoute({
|
||||
getParentRoute: () => workspaceRoute,
|
||||
path: "handoffs/$handoffId",
|
||||
path: "tasks/$taskId",
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
sessionId: typeof search.sessionId === "string" && search.sessionId.trim().length > 0 ? search.sessionId : undefined,
|
||||
}),
|
||||
component: HandoffRoute,
|
||||
component: TaskRoute,
|
||||
});
|
||||
|
||||
const repoRoute = createRoute({
|
||||
|
|
@ -106,11 +101,10 @@ const routeTree = rootRoute.addChildren([
|
|||
indexRoute,
|
||||
signInRoute,
|
||||
organizationsRoute,
|
||||
organizationImportRoute,
|
||||
organizationSettingsRoute,
|
||||
organizationBillingRoute,
|
||||
organizationCheckoutRoute,
|
||||
workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute]),
|
||||
workspaceRoute.addChildren([workspaceIndexRoute, taskRoute, repoRoute]),
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
|
|
@ -132,6 +126,9 @@ function IndexRoute() {
|
|||
|
||||
function SignInRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
if (isAppSnapshotBootstrapping(snapshot)) {
|
||||
return <AppBootstrapPending />;
|
||||
}
|
||||
|
||||
if (snapshot.auth.status === "signed_in") {
|
||||
return <NavigateToMockHome snapshot={snapshot} replace />;
|
||||
|
|
@ -142,6 +139,9 @@ function SignInRoute() {
|
|||
|
||||
function OrganizationsRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
if (isAppSnapshotBootstrapping(snapshot)) {
|
||||
return <AppBootstrapPending />;
|
||||
}
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
|
|
@ -150,24 +150,12 @@ function OrganizationsRoute() {
|
|||
return <MockOrganizationSelectorPage />;
|
||||
}
|
||||
|
||||
function OrganizationImportRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationImportRoute.useParams().organizationId);
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
return <MockOrganizationImportPage organization={organization} />;
|
||||
}
|
||||
|
||||
function OrganizationSettingsRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationSettingsRoute.useParams().organizationId);
|
||||
if (isAppSnapshotBootstrapping(snapshot)) {
|
||||
return <AppBootstrapPending />;
|
||||
}
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
|
|
@ -183,6 +171,9 @@ function OrganizationSettingsRoute() {
|
|||
function OrganizationBillingRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationBillingRoute.useParams().organizationId);
|
||||
if (isAppSnapshotBootstrapping(snapshot)) {
|
||||
return <AppBootstrapPending />;
|
||||
}
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
|
|
@ -199,6 +190,9 @@ function OrganizationCheckoutRoute() {
|
|||
const { organizationId, planId } = organizationCheckoutRoute.useParams();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationId);
|
||||
if (isAppSnapshotBootstrapping(snapshot)) {
|
||||
return <AppBootstrapPending />;
|
||||
}
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
|
|
@ -226,18 +220,18 @@ function WorkspaceRoute() {
|
|||
|
||||
return (
|
||||
<MockWorkspaceGate workspaceId={workspaceId}>
|
||||
<WorkspaceView workspaceId={workspaceId} selectedHandoffId={null} selectedSessionId={null} />
|
||||
<WorkspaceView workspaceId={workspaceId} selectedTaskId={null} selectedSessionId={null} />
|
||||
</MockWorkspaceGate>
|
||||
);
|
||||
}
|
||||
|
||||
function HandoffRoute() {
|
||||
const { workspaceId, handoffId } = handoffRoute.useParams();
|
||||
const { sessionId } = handoffRoute.useSearch();
|
||||
function TaskRoute() {
|
||||
const { workspaceId, taskId } = taskRoute.useParams();
|
||||
const { sessionId } = taskRoute.useSearch();
|
||||
|
||||
return (
|
||||
<MockWorkspaceGate workspaceId={workspaceId}>
|
||||
<WorkspaceView workspaceId={workspaceId} selectedHandoffId={handoffId} selectedSessionId={sessionId ?? null} />
|
||||
<WorkspaceView workspaceId={workspaceId} selectedTaskId={taskId} selectedSessionId={sessionId ?? null} />
|
||||
</MockWorkspaceGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -253,7 +247,7 @@ function RepoRoute() {
|
|||
}
|
||||
|
||||
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
|
||||
const client = getHandoffWorkbenchClient(workspaceId);
|
||||
const client = getTaskWorkbenchClient(workspaceId);
|
||||
const snapshot = useSyncExternalStore(
|
||||
client.subscribe.bind(client),
|
||||
client.getSnapshot.bind(client),
|
||||
|
|
@ -263,13 +257,13 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
|
|||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
handoffId: undefined,
|
||||
taskId: undefined,
|
||||
repoId,
|
||||
});
|
||||
}, [repoId, workspaceId]);
|
||||
|
||||
const activeHandoffId = resolveRepoRouteHandoffId(snapshot, repoId);
|
||||
if (!activeHandoffId) {
|
||||
const activeTaskId = resolveRepoRouteTaskId(snapshot, repoId);
|
||||
if (!activeTaskId) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/workspaces/$workspaceId"
|
||||
|
|
@ -280,10 +274,10 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
|
|||
}
|
||||
return (
|
||||
<Navigate
|
||||
to="/workspaces/$workspaceId/handoffs/$handoffId"
|
||||
to="/workspaces/$workspaceId/tasks/$taskId"
|
||||
params={{
|
||||
workspaceId,
|
||||
handoffId: activeHandoffId,
|
||||
taskId: activeTaskId,
|
||||
}}
|
||||
search={{ sessionId: undefined }}
|
||||
replace
|
||||
|
|
@ -293,14 +287,15 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
|
|||
|
||||
function WorkspaceView({
|
||||
workspaceId,
|
||||
selectedHandoffId,
|
||||
selectedTaskId,
|
||||
selectedSessionId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
selectedHandoffId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
selectedSessionId: string | null;
|
||||
}) {
|
||||
const client = getHandoffWorkbenchClient(workspaceId);
|
||||
const appClient = useMockAppClient();
|
||||
const client = getTaskWorkbenchClient(workspaceId);
|
||||
const navigate = useNavigate();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = eligibleOrganizations(snapshot).find((candidate) => candidate.workspaceId === workspaceId) ?? null;
|
||||
|
|
@ -308,16 +303,16 @@ function WorkspaceView({
|
|||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
handoffId: selectedHandoffId ?? undefined,
|
||||
taskId: selectedTaskId ?? undefined,
|
||||
repoId: undefined,
|
||||
});
|
||||
}, [selectedHandoffId, workspaceId]);
|
||||
}, [selectedTaskId, workspaceId]);
|
||||
|
||||
return (
|
||||
<MockLayout
|
||||
client={client}
|
||||
workspaceId={workspaceId}
|
||||
selectedHandoffId={selectedHandoffId}
|
||||
selectedTaskId={selectedTaskId}
|
||||
selectedSessionId={selectedSessionId}
|
||||
sidebarTitle={organization?.settings.displayName}
|
||||
sidebarSubtitle={
|
||||
|
|
@ -325,6 +320,9 @@ function WorkspaceView({
|
|||
? `${organization.billing.planId} plan · ${organization.seatAssignments.length}/${organization.billing.seatsIncluded} seats`
|
||||
: undefined
|
||||
}
|
||||
organizationGithub={organization?.github}
|
||||
onRetryGithubSync={organization ? () => void appClient.triggerGithubSync(organization.id) : undefined}
|
||||
onReconnectGithub={organization ? () => void appClient.reconnectGithub(organization.id) : undefined}
|
||||
sidebarActions={
|
||||
organization
|
||||
? [
|
||||
|
|
@ -363,6 +361,9 @@ function MockWorkspaceGate({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
if (isAppSnapshotBootstrapping(snapshot)) {
|
||||
return <AppBootstrapPending />;
|
||||
}
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
|
|
@ -379,16 +380,6 @@ function MockWorkspaceGate({
|
|||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
if (workspaceOrganization.repoImportStatus !== "ready") {
|
||||
return (
|
||||
<Navigate
|
||||
to="/organizations/$organizationId/import"
|
||||
params={{ organizationId: workspaceOrganization.id }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
|
@ -399,6 +390,10 @@ function NavigateToMockHome({
|
|||
snapshot: ReturnType<typeof useMockAppSnapshot>;
|
||||
replace?: boolean;
|
||||
}) {
|
||||
if (isAppSnapshotBootstrapping(snapshot)) {
|
||||
return <AppBootstrapPending />;
|
||||
}
|
||||
|
||||
const activeOrganization = activeMockOrganization(snapshot);
|
||||
const organizations = eligibleOrganizations(snapshot);
|
||||
const targetOrganization =
|
||||
|
|
@ -420,16 +415,6 @@ function NavigateToMockHome({
|
|||
);
|
||||
}
|
||||
|
||||
if (targetOrganization.repoImportStatus !== "ready") {
|
||||
return (
|
||||
<Navigate
|
||||
to="/organizations/$organizationId/import"
|
||||
params={{ organizationId: targetOrganization.id }}
|
||||
replace={replace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to="/workspaces/$workspaceId"
|
||||
|
|
@ -456,7 +441,7 @@ function useGuardedMockOrganization(organizationId: string) {
|
|||
}
|
||||
|
||||
function isMockBillingPlanId(planId: string): planId is MockBillingPlanId {
|
||||
return planId === "free" || planId === "team" || planId === "enterprise";
|
||||
return planId === "free" || planId === "team";
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
|
|
@ -468,6 +453,40 @@ function RootLayout() {
|
|||
);
|
||||
}
|
||||
|
||||
function AppBootstrapPending() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background:
|
||||
"radial-gradient(circle at top left, rgba(255, 79, 0, 0.16), transparent 28%), radial-gradient(circle at top right, rgba(24, 140, 255, 0.18), transparent 32%), #050505",
|
||||
color: "#ffffff",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "min(520px, calc(100vw - 40px))",
|
||||
padding: "32px",
|
||||
borderRadius: "28px",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
background: "linear-gradient(180deg, rgba(21, 21, 24, 0.96), rgba(10, 10, 11, 0.98))",
|
||||
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.36)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "12px", fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", color: "#a1a1aa" }}>
|
||||
Restoring session
|
||||
</div>
|
||||
<div style={{ marginTop: "8px", fontSize: "28px", fontWeight: 800 }}>Loading Factory state</div>
|
||||
<div style={{ marginTop: "12px", color: "#d4d4d8", lineHeight: 1.6 }}>
|
||||
Applying the returned app session and loading your organizations before routing deeper into Factory.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteContextSync() {
|
||||
const location = useRouterState({
|
||||
select: (state) => state.location,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -49,7 +49,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({
|
|||
>
|
||||
<div className={css({ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px" })}>
|
||||
<LabelXSmall color={theme.colors.contentTertiary} $style={{ letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
Handoff Events
|
||||
Task Events
|
||||
</LabelXSmall>
|
||||
<LabelXSmall color={theme.colors.contentTertiary}>{events.length}</LabelXSmall>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from "lucide-react";
|
||||
|
||||
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
|
||||
import { type FileTreeNode, type Handoff, diffTabId } from "./view-model";
|
||||
import { type FileTreeNode, type Task, diffTabId } from "./view-model";
|
||||
|
||||
const StatusCard = memo(function StatusCard({
|
||||
label,
|
||||
|
|
@ -145,7 +145,7 @@ const FileTree = memo(function FileTree({
|
|||
});
|
||||
|
||||
export const RightSidebar = memo(function RightSidebar({
|
||||
handoff,
|
||||
task,
|
||||
activeTabId,
|
||||
onOpenDiff,
|
||||
onArchive,
|
||||
|
|
@ -153,7 +153,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
onRevertFile,
|
||||
onPublishPr,
|
||||
}: {
|
||||
handoff: Handoff;
|
||||
task: Task;
|
||||
activeTabId: string | null;
|
||||
onOpenDiff: (path: string) => void;
|
||||
onArchive: () => void;
|
||||
|
|
@ -164,14 +164,14 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
const [css, theme] = useStyletron();
|
||||
const [rightTab, setRightTab] = useState<"changes" | "files">("changes");
|
||||
const contextMenu = useContextMenu();
|
||||
const changedPaths = useMemo(() => new Set(handoff.fileChanges.map((file) => file.path)), [handoff.fileChanges]);
|
||||
const isTerminal = handoff.status === "archived";
|
||||
const canPush = !isTerminal && Boolean(handoff.branch);
|
||||
const pullRequestUrl = handoff.pullRequest != null ? `https://github.com/${handoff.repoName}/pull/${handoff.pullRequest.number}` : null;
|
||||
const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]);
|
||||
const isTerminal = task.status === "archived";
|
||||
const canPush = !isTerminal && Boolean(task.branch);
|
||||
const pullRequestUrl = task.pullRequest != null ? `https://github.com/${task.repoName}/pull/${task.pullRequest.number}` : null;
|
||||
const pullRequestStatus =
|
||||
handoff.pullRequest == null
|
||||
task.pullRequest == null
|
||||
? "Not published"
|
||||
: `#${handoff.pullRequest.number} ${handoff.pullRequest.status === "draft" ? "Draft" : "Ready"}`;
|
||||
: `#${task.pullRequest.number} ${task.pullRequest.status === "draft" ? "Draft" : "Ready"}`;
|
||||
|
||||
const copyFilePath = useCallback(async (path: string) => {
|
||||
try {
|
||||
|
|
@ -309,7 +309,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
})}
|
||||
>
|
||||
Changes
|
||||
{handoff.fileChanges.length > 0 ? (
|
||||
{task.fileChanges.length > 0 ? (
|
||||
<span
|
||||
className={css({
|
||||
display: "inline-flex",
|
||||
|
|
@ -325,7 +325,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
borderRadius: "8px",
|
||||
})}
|
||||
>
|
||||
{handoff.fileChanges.length}
|
||||
{task.fileChanges.length}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
|
@ -356,17 +356,17 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
|
||||
<ScrollBody>
|
||||
<div className={css({ padding: "12px 14px 0", display: "grid", gap: "8px" })}>
|
||||
<StatusCard label="Branch" value={handoff.branch ?? "Not created"} mono />
|
||||
<StatusCard label="Branch" value={task.branch ?? "Not created"} mono />
|
||||
<StatusCard label="Pull Request" value={pullRequestStatus} />
|
||||
</div>
|
||||
{rightTab === "changes" ? (
|
||||
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
|
||||
{handoff.fileChanges.length === 0 ? (
|
||||
{task.fileChanges.length === 0 ? (
|
||||
<div className={css({ padding: "20px 0", textAlign: "center" })}>
|
||||
<LabelSmall color={theme.colors.contentTertiary}>No changes yet</LabelSmall>
|
||||
</div>
|
||||
) : null}
|
||||
{handoff.fileChanges.map((file) => {
|
||||
{task.fileChanges.map((file) => {
|
||||
const isActive = activeTabId === diffTabId(file.path);
|
||||
const TypeIcon = file.type === "A" ? FilePlus : file.type === "D" ? FileX : FileCode;
|
||||
const iconColor = file.type === "A" ? "#7ee787" : file.type === "D" ? "#ffa198" : theme.colors.contentTertiary;
|
||||
|
|
@ -421,9 +421,9 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
</div>
|
||||
) : (
|
||||
<div className={css({ padding: "6px 0" })}>
|
||||
{handoff.fileTree.length > 0 ? (
|
||||
{task.fileTree.length > 0 ? (
|
||||
<FileTree
|
||||
nodes={handoff.fileTree}
|
||||
nodes={task.fileTree}
|
||||
depth={0}
|
||||
onSelectFile={onOpenDiff}
|
||||
onFileContextMenu={openFileMenu}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { LabelSmall, LabelXSmall } from "baseui/typography";
|
|||
import { CloudUpload, GitPullRequestDraft, Plus } from "lucide-react";
|
||||
|
||||
import { SidebarSkeleton } from "./skeleton";
|
||||
import { formatRelativeAge, type Handoff, type ProjectSection } from "./view-model";
|
||||
import { formatRelativeAge, type Task, type RepoSection } from "./view-model";
|
||||
import {
|
||||
ContextMenuOverlay,
|
||||
HandoffIndicator,
|
||||
TaskIndicator,
|
||||
PanelHeaderBar,
|
||||
SPanel,
|
||||
ScrollBody,
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
export const Sidebar = memo(function Sidebar({
|
||||
workspaceId,
|
||||
repoCount,
|
||||
projects,
|
||||
repos,
|
||||
activeId,
|
||||
title,
|
||||
subtitle,
|
||||
|
|
@ -25,12 +25,12 @@ export const Sidebar = memo(function Sidebar({
|
|||
onSelect,
|
||||
onCreate,
|
||||
onMarkUnread,
|
||||
onRenameHandoff,
|
||||
onRenameTask,
|
||||
onRenameBranch,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
repoCount: number;
|
||||
projects: ProjectSection[];
|
||||
repos: RepoSection[];
|
||||
activeId: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
|
|
@ -41,12 +41,12 @@ export const Sidebar = memo(function Sidebar({
|
|||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onMarkUnread: (id: string) => void;
|
||||
onRenameHandoff: (id: string) => void;
|
||||
onRenameTask: (id: string) => void;
|
||||
onRenameBranch: (id: string) => void;
|
||||
}) {
|
||||
const [css, theme] = useStyletron();
|
||||
const contextMenu = useContextMenu();
|
||||
const [expandedProjects, setExpandedProjects] = useState<Record<string, boolean>>({});
|
||||
const [expandedRepos, setExpandedRepos] = useState<Record<string, boolean>>({});
|
||||
|
||||
return (
|
||||
<SPanel>
|
||||
|
|
@ -61,7 +61,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
</div>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
aria-label="Create handoff"
|
||||
aria-label="Create task"
|
||||
className={css({
|
||||
all: "unset",
|
||||
width: "24px",
|
||||
|
|
@ -115,16 +115,20 @@ export const Sidebar = memo(function Sidebar({
|
|||
</div>
|
||||
) : null}
|
||||
<ScrollBody>
|
||||
{projects.length === 0 ? (
|
||||
{repos.length === 0 && repoCount === 0 ? (
|
||||
<SidebarSkeleton />
|
||||
) : repos.length === 0 ? (
|
||||
<div className={css({ padding: "16px", textAlign: "center", opacity: 0.5, fontSize: "13px" })}>
|
||||
No tasks yet
|
||||
</div>
|
||||
) : (
|
||||
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
|
||||
{projects.map((project) => {
|
||||
const visibleCount = expandedProjects[project.id] ? project.handoffs.length : Math.min(project.handoffs.length, 5);
|
||||
const hiddenCount = Math.max(0, project.handoffs.length - visibleCount);
|
||||
{repos.map((repo) => {
|
||||
const visibleCount = expandedRepos[repo.id] ? repo.tasks.length : Math.min(repo.tasks.length, 5);
|
||||
const hiddenCount = Math.max(0, repo.tasks.length - visibleCount);
|
||||
|
||||
return (
|
||||
<div key={project.id} className={css({ display: "flex", flexDirection: "column", gap: "4px" })}>
|
||||
<div key={repo.id} className={css({ display: "flex", flexDirection: "column", gap: "4px" })}>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
|
|
@ -146,14 +150,14 @@ export const Sidebar = memo(function Sidebar({
|
|||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{project.label}
|
||||
{repo.label}
|
||||
</LabelSmall>
|
||||
<LabelXSmall color={theme.colors.contentTertiary}>
|
||||
{project.updatedAtMs > 0 ? formatRelativeAge(project.updatedAtMs) : "No handoffs"}
|
||||
{repo.updatedAtMs > 0 ? formatRelativeAge(repo.updatedAtMs) : "No tasks"}
|
||||
</LabelXSmall>
|
||||
</div>
|
||||
|
||||
{project.handoffs.length === 0 ? (
|
||||
{repo.tasks.length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
padding: "0 12px 10px 34px",
|
||||
|
|
@ -161,29 +165,29 @@ export const Sidebar = memo(function Sidebar({
|
|||
fontSize: "12px",
|
||||
})}
|
||||
>
|
||||
No handoffs yet
|
||||
No tasks yet
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{project.handoffs.slice(0, visibleCount).map((handoff) => {
|
||||
const isActive = handoff.id === activeId;
|
||||
const isDim = handoff.status === "archived";
|
||||
const isRunning = handoff.tabs.some((tab) => tab.status === "running");
|
||||
const hasUnread = handoff.tabs.some((tab) => tab.unread);
|
||||
const isDraft = handoff.pullRequest == null || handoff.pullRequest.status === "draft";
|
||||
const totalAdded = handoff.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
||||
const totalRemoved = handoff.fileChanges.reduce((sum, file) => sum + file.removed, 0);
|
||||
{repo.tasks.slice(0, visibleCount).map((task) => {
|
||||
const isActive = task.id === activeId;
|
||||
const isDim = task.status === "archived";
|
||||
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
||||
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;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={handoff.id}
|
||||
onClick={() => onSelect(handoff.id)}
|
||||
key={task.id}
|
||||
onClick={() => onSelect(task.id)}
|
||||
onContextMenu={(event) =>
|
||||
contextMenu.open(event, [
|
||||
{ label: "Rename handoff", onClick: () => onRenameHandoff(handoff.id) },
|
||||
{ label: "Rename branch", onClick: () => onRenameBranch(handoff.id) },
|
||||
{ label: "Mark as unread", onClick: () => onMarkUnread(handoff.id) },
|
||||
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
|
||||
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
|
||||
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
|
||||
])
|
||||
}
|
||||
className={css({
|
||||
|
|
@ -211,7 +215,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<HandoffIndicator isRunning={isRunning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||
<TaskIndicator isRunning={isRunning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||
</div>
|
||||
<LabelSmall
|
||||
$style={{
|
||||
|
|
@ -223,7 +227,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
}}
|
||||
color={isDim ? theme.colors.contentSecondary : theme.colors.contentPrimary}
|
||||
>
|
||||
{handoff.title}
|
||||
{task.title}
|
||||
</LabelSmall>
|
||||
{hasDiffs ? (
|
||||
<div className={css({ display: "flex", gap: "4px", flexShrink: 0 })}>
|
||||
|
|
@ -242,20 +246,20 @@ export const Sidebar = memo(function Sidebar({
|
|||
flexShrink: 1,
|
||||
}}
|
||||
>
|
||||
{handoff.repoName}
|
||||
{task.repoName}
|
||||
</LabelXSmall>
|
||||
{handoff.pullRequest != null ? (
|
||||
{task.pullRequest != null ? (
|
||||
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||
<LabelXSmall color={theme.colors.contentSecondary} $style={{ fontWeight: 600 }}>
|
||||
#{handoff.pullRequest.number}
|
||||
#{task.pullRequest.number}
|
||||
</LabelXSmall>
|
||||
{handoff.pullRequest.status === "draft" ? <CloudUpload size={11} color="#ff4f00" /> : null}
|
||||
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color="#ff4f00" /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<GitPullRequestDraft size={11} color={theme.colors.contentTertiary} />
|
||||
)}
|
||||
<LabelXSmall color={theme.colors.contentTertiary} $style={{ marginLeft: "auto", flexShrink: 0 }}>
|
||||
{formatRelativeAge(handoff.updatedAtMs)}
|
||||
{formatRelativeAge(task.updatedAtMs)}
|
||||
</LabelXSmall>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -266,9 +270,9 @@ export const Sidebar = memo(function Sidebar({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedProjects((current) => ({
|
||||
setExpandedRepos((current) => ({
|
||||
...current,
|
||||
[project.id]: true,
|
||||
[repo.id]: true,
|
||||
}))
|
||||
}
|
||||
className={css({
|
||||
|
|
|
|||
|
|
@ -53,16 +53,16 @@ export const SkeletonBlock = memo(function SkeletonBlock({
|
|||
return <SkeletonLine width={width} height={height} borderRadius={borderRadius} style={style} />;
|
||||
});
|
||||
|
||||
/** Sidebar skeleton: header + list of handoff placeholders */
|
||||
/** Sidebar skeleton: header + list of task placeholders */
|
||||
export const SidebarSkeleton = memo(function SidebarSkeleton() {
|
||||
return (
|
||||
<div style={{ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{/* Project header skeleton */}
|
||||
{/* Repo header skeleton */}
|
||||
<div style={{ padding: "10px 8px 4px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<SkeletonLine width="40%" height={10} />
|
||||
<SkeletonLine width={48} height={10} />
|
||||
</div>
|
||||
{/* Handoff item skeletons */}
|
||||
{/* Task item skeletons */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { LabelXSmall } from "baseui/typography";
|
|||
import { FileCode, Plus, X } from "lucide-react";
|
||||
|
||||
import { ContextMenuOverlay, TabAvatar, useContextMenu } from "./ui";
|
||||
import { diffTabId, fileName, type Handoff } from "./view-model";
|
||||
import { diffTabId, fileName, type Task } from "./view-model";
|
||||
|
||||
export const TabStrip = memo(function TabStrip({
|
||||
handoff,
|
||||
task,
|
||||
activeTabId,
|
||||
openDiffs,
|
||||
editingSessionTabId,
|
||||
|
|
@ -22,7 +22,7 @@ export const TabStrip = memo(function TabStrip({
|
|||
onCloseDiffTab,
|
||||
onAddTab,
|
||||
}: {
|
||||
handoff: Handoff;
|
||||
task: Task;
|
||||
activeTabId: string | null;
|
||||
openDiffs: string[];
|
||||
editingSessionTabId: string | null;
|
||||
|
|
@ -56,7 +56,7 @@ export const TabStrip = memo(function TabStrip({
|
|||
"::-webkit-scrollbar": { display: "none" },
|
||||
})}
|
||||
>
|
||||
{handoff.tabs.map((tab) => {
|
||||
{task.tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
|
|
@ -64,7 +64,7 @@ export const TabStrip = memo(function TabStrip({
|
|||
onClick={() => onSwitchTab(tab.id)}
|
||||
onDoubleClick={() => onStartRenamingTab(tab.id)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1 && handoff.tabs.length > 1) {
|
||||
if (event.button === 1 && task.tabs.length > 1) {
|
||||
event.preventDefault();
|
||||
onCloseTab(tab.id);
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ export const TabStrip = memo(function TabStrip({
|
|||
label: tab.unread ? "Mark as read" : "Mark as unread",
|
||||
onClick: () => onSetTabUnread(tab.id, !tab.unread),
|
||||
},
|
||||
...(handoff.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []),
|
||||
...(task.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []),
|
||||
])
|
||||
}
|
||||
className={css({
|
||||
|
|
@ -134,7 +134,7 @@ export const TabStrip = memo(function TabStrip({
|
|||
{tab.sessionName}
|
||||
</LabelXSmall>
|
||||
)}
|
||||
{handoff.tabs.length > 1 ? (
|
||||
{task.tabs.length > 1 ? (
|
||||
<X
|
||||
size={11}
|
||||
color={theme.colors.contentTertiary}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { LabelSmall } from "baseui/typography";
|
|||
import { MailOpen } from "lucide-react";
|
||||
|
||||
import { PanelHeaderBar } from "./ui";
|
||||
import { type AgentTab, type Handoff } from "./view-model";
|
||||
import { type AgentTab, type Task } from "./view-model";
|
||||
|
||||
export const TranscriptHeader = memo(function TranscriptHeader({
|
||||
handoff,
|
||||
task,
|
||||
activeTab,
|
||||
editingField,
|
||||
editValue,
|
||||
|
|
@ -17,7 +17,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
onCancelEditingField,
|
||||
onSetActiveTabUnread,
|
||||
}: {
|
||||
handoff: Handoff;
|
||||
task: Task;
|
||||
activeTab: AgentTab | null | undefined;
|
||||
editingField: "title" | "branch" | null;
|
||||
editValue: string;
|
||||
|
|
@ -59,12 +59,12 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
title="Rename"
|
||||
color={theme.colors.contentPrimary}
|
||||
$style={{ fontWeight: 600, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }}
|
||||
onClick={() => onStartEditingField("title", handoff.title)}
|
||||
onClick={() => onStartEditingField("title", task.title)}
|
||||
>
|
||||
{handoff.title}
|
||||
{task.title}
|
||||
</LabelSmall>
|
||||
)}
|
||||
{handoff.branch ? (
|
||||
{task.branch ? (
|
||||
editingField === "branch" ? (
|
||||
<input
|
||||
autoFocus
|
||||
|
|
@ -94,7 +94,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
) : (
|
||||
<span
|
||||
title="Rename"
|
||||
onClick={() => onStartEditingField("branch", handoff.branch ?? "")}
|
||||
onClick={() => onStartEditingField("branch", task.branch ?? "")}
|
||||
className={css({
|
||||
padding: "2px 8px",
|
||||
borderRadius: "999px",
|
||||
|
|
@ -108,7 +108,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
":hover": { borderColor: "rgba(255, 255, 255, 0.3)" },
|
||||
})}
|
||||
>
|
||||
{handoff.branch}
|
||||
{task.branch}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export const UnreadDot = memo(function UnreadDot() {
|
|||
);
|
||||
});
|
||||
|
||||
export const HandoffIndicator = memo(function HandoffIndicator({
|
||||
export const TaskIndicator = memo(function TaskIndicator({
|
||||
isRunning,
|
||||
hasUnread,
|
||||
isDraft,
|
||||
|
|
|
|||
|
|
@ -4,18 +4,19 @@ import type {
|
|||
WorkbenchDiffLineKind as DiffLineKind,
|
||||
WorkbenchFileChange as FileChange,
|
||||
WorkbenchFileTreeNode as FileTreeNode,
|
||||
WorkbenchHandoff as Handoff,
|
||||
WorkbenchTask as WorkbenchTask,
|
||||
WorkbenchHistoryEvent as HistoryEvent,
|
||||
WorkbenchLineAttachment as LineAttachment,
|
||||
WorkbenchModelGroup as ModelGroup,
|
||||
WorkbenchModelId as ModelId,
|
||||
WorkbenchParsedDiffLine as ParsedDiffLine,
|
||||
WorkbenchProjectSection as ProjectSection,
|
||||
WorkbenchRepoSection as WorkbenchRepoSection,
|
||||
WorkbenchTranscriptEvent as TranscriptEvent,
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { extractEventText } from "../../features/sessions/model";
|
||||
|
||||
export type { ProjectSection };
|
||||
export type Task = WorkbenchTask;
|
||||
export type RepoSection = WorkbenchRepoSection;
|
||||
|
||||
export const MODEL_GROUPS: ModelGroup[] = [
|
||||
{
|
||||
|
|
@ -329,7 +330,6 @@ export type {
|
|||
DiffLineKind,
|
||||
FileChange,
|
||||
FileTreeNode,
|
||||
Handoff,
|
||||
HistoryEvent,
|
||||
LineAttachment,
|
||||
ModelGroup,
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ import {
|
|||
type FactoryUser,
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, BadgeCheck, Building2, CreditCard, Github, LoaderCircle, ShieldCheck, Users } from "lucide-react";
|
||||
import { ArrowLeft, BadgeCheck, Building2, CreditCard, Github, ShieldCheck, Users } from "lucide-react";
|
||||
import {
|
||||
activeMockUser,
|
||||
eligibleOrganizations,
|
||||
useMockAppClient,
|
||||
useMockAppSnapshot,
|
||||
} from "../lib/mock-app";
|
||||
import { isMockFrontendClient } from "../lib/env";
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
|
|
@ -41,12 +42,6 @@ const planCatalog: Record<
|
|||
seats: "5 seats included",
|
||||
summary: "GitHub org onboarding, shared billing, and seat accrual on first prompt.",
|
||||
},
|
||||
enterprise: {
|
||||
label: "Enterprise",
|
||||
price: "$1,200/mo",
|
||||
seats: "25 seats included",
|
||||
summary: "Enterprise controls, larger seat pools, and procurement-ready billing.",
|
||||
},
|
||||
};
|
||||
|
||||
function appSurfaceStyle(): React.CSSProperties {
|
||||
|
|
@ -162,10 +157,6 @@ function billingPath(organization: FactoryOrganization): string {
|
|||
return `/organizations/${organization.id}/billing`;
|
||||
}
|
||||
|
||||
function importPath(organization: FactoryOrganization): string {
|
||||
return `/organizations/${organization.id}/import`;
|
||||
}
|
||||
|
||||
function checkoutPath(organization: FactoryOrganization, planId: FactoryBillingPlanId): string {
|
||||
return `/organizations/${organization.id}/checkout/${planId}`;
|
||||
}
|
||||
|
|
@ -330,8 +321,9 @@ export function MockSignInPage() {
|
|||
Sign in and land directly in the org onboarding funnel.
|
||||
</div>
|
||||
<div style={{ fontSize: "16px", lineHeight: 1.6, color: "#d4d4d8", maxWidth: "56ch" }}>
|
||||
This mock screen stands in for a basic GitHub OAuth sign-in page. After sign-in, the user moves into the
|
||||
separate organization selector and then the rest of the onboarding funnel.
|
||||
{isMockFrontendClient
|
||||
? "This mock screen stands in for a basic GitHub OAuth sign-in page. After sign-in, the user moves into the separate organization selector and then the rest of the onboarding funnel."
|
||||
: "GitHub OAuth starts here. After the callback exchange completes, the app restores the signed-in session and continues into organization selection."}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
|
||||
<div style={badgeStyle("rgba(255, 255, 255, 0.06)")}>
|
||||
|
|
@ -361,16 +353,19 @@ export function MockSignInPage() {
|
|||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
<div style={{ fontSize: "22px", fontWeight: 800 }}>Continue to Sandbox Agent</div>
|
||||
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
|
||||
This mock sign-in uses a single GitHub account so the org selection step remains the place where the
|
||||
user chooses their workspace.
|
||||
{isMockFrontendClient
|
||||
? "This mock sign-in uses a single GitHub account so the org selection step remains the place where the user chooses their workspace."
|
||||
: "This starts the live GitHub OAuth flow and restores the app session when the callback returns."}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await client.signInWithGithub("user-nathan");
|
||||
await navigate({ to: "/organizations" });
|
||||
await client.signInWithGithub(isMockFrontendClient ? "user-nathan" : undefined);
|
||||
if (isMockFrontendClient) {
|
||||
await navigate({ to: "/organizations" });
|
||||
}
|
||||
})();
|
||||
}}
|
||||
style={{
|
||||
|
|
@ -403,10 +398,14 @@ export function MockSignInPage() {
|
|||
@{mockAccount.githubLogin} · {mockAccount.email}
|
||||
</div>
|
||||
</div>
|
||||
<span style={badgeStyle("rgba(24, 140, 255, 0.16)", "#b9d8ff")}>{mockAccount.label}</span>
|
||||
<span style={badgeStyle("rgba(24, 140, 255, 0.16)", "#b9d8ff")}>
|
||||
{isMockFrontendClient ? mockAccount.label : "Live GitHub identity"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: "#a1a1aa", fontSize: "13px", lineHeight: 1.5 }}>
|
||||
Sign-in always lands as this single mock user. Organization choice happens on the next screen.
|
||||
{isMockFrontendClient
|
||||
? "Sign-in always lands as this single mock user. Organization choice happens on the next screen."
|
||||
: "In remote mode this card is replaced by the live GitHub user once the OAuth callback completes."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -492,9 +491,7 @@ export function MockOrganizationSelectorPage() {
|
|||
onClick={() => {
|
||||
void (async () => {
|
||||
await client.selectOrganization(organization.id);
|
||||
await navigate({
|
||||
to: organization.repoImportStatus === "ready" ? workspacePath(organization) : importPath(organization),
|
||||
});
|
||||
await navigate({ to: workspacePath(organization) });
|
||||
})();
|
||||
}}
|
||||
style={primaryButtonStyle()}
|
||||
|
|
@ -515,110 +512,6 @@ export function MockOrganizationSelectorPage() {
|
|||
);
|
||||
}
|
||||
|
||||
export function MockOrganizationImportPage({ organization }: { organization: FactoryOrganization }) {
|
||||
const client = useMockAppClient();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const user = activeMockUser(snapshot);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (organization.repoImportStatus === "ready") {
|
||||
void navigate({ to: workspacePath(organization), replace: true });
|
||||
}
|
||||
}, [navigate, organization]);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
user={user}
|
||||
title={`Importing ${organization.settings.displayName}`}
|
||||
eyebrow="Repository Sync"
|
||||
description="This mock view stands in for the post-auth GitHub installation and repository import step. Organization onboarding blocks until repo metadata is ready so the user lands in a populated workspace."
|
||||
actions={
|
||||
<button type="button" onClick={() => void navigate({ to: "/organizations" })} style={secondaryButtonStyle()}>
|
||||
<ArrowLeft size={15} />
|
||||
Back
|
||||
</button>
|
||||
}
|
||||
onSignOut={() => {
|
||||
void (async () => {
|
||||
await client.signOut();
|
||||
await navigate({ to: "/signin" });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(320px, 0.8fr)", gap: "18px" }}>
|
||||
<div style={{ ...cardStyle(), padding: "28px", display: "flex", flexDirection: "column", gap: "18px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<LoaderCircle size={24} style={{ animation: "hf-spin 1s linear infinite" }} />
|
||||
<div>
|
||||
<div style={{ fontSize: "22px", fontWeight: 800 }}>
|
||||
{organization.repoImportStatus === "ready" ? "Import complete" : "Preparing repository catalog"}
|
||||
</div>
|
||||
<div style={{ color: "#a1a1aa", fontSize: "14px" }}>{organization.github.lastSyncLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "#d4d4d8", lineHeight: 1.6 }}>
|
||||
The mock client now simulates the expected onboarding pause: GitHub app access is validated, repository metadata
|
||||
is imported, and the resulting workspace stays blocked until ready.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: "12px",
|
||||
borderRadius: "999px",
|
||||
overflow: "hidden",
|
||||
background: "rgba(255, 255, 255, 0.08)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: organization.repoImportStatus === "ready" ? "100%" : "76%",
|
||||
height: "100%",
|
||||
borderRadius: "999px",
|
||||
background: "linear-gradient(90deg, #ff4f00, #ff9d00)",
|
||||
transition: "width 200ms ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||
{organization.github.installationStatus !== "connected" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void client.reconnectGithub(organization.id)}
|
||||
style={primaryButtonStyle()}
|
||||
>
|
||||
Reconnect GitHub App
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => void client.triggerRepoImport(organization.id)} style={secondaryButtonStyle()}>
|
||||
Re-run import
|
||||
</button>
|
||||
<button type="button" onClick={() => void navigate({ to: settingsPath(organization) })} style={subtleButtonStyle()}>
|
||||
Review org settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: "14px" }}>
|
||||
<StatCard
|
||||
label="GitHub"
|
||||
value={organization.github.connectedAccount}
|
||||
caption={organization.github.installationStatus.replaceAll("_", " ")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Repositories"
|
||||
value={`${organization.repoCatalog.length}`}
|
||||
caption="Imported into the mock workspace catalog"
|
||||
/>
|
||||
<StatCard
|
||||
label="Billing"
|
||||
value={planCatalog[organization.billing.planId]!.label}
|
||||
caption={`${organization.seatAssignments.length} seats accrued so far`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function MockOrganizationSettingsPage({ organization }: { organization: FactoryOrganization }) {
|
||||
const client = useMockAppClient();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
|
|
@ -631,6 +524,12 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
|
|||
() => `${organization.seatAssignments.length} of ${organization.billing.seatsIncluded} seats already accrued`,
|
||||
[organization.billing.seatsIncluded, organization.seatAssignments.length],
|
||||
);
|
||||
const openWorkspace = () => {
|
||||
void (async () => {
|
||||
await client.selectOrganization(organization.id);
|
||||
await navigate({ to: workspacePath(organization) });
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(organization.settings.displayName);
|
||||
|
|
@ -648,7 +547,7 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
|
|||
user={user}
|
||||
title={`${organization.settings.displayName} settings`}
|
||||
eyebrow="Organization"
|
||||
description="This mock settings surface covers the org profile, GitHub installation state, repository import controls, and the seat-accrual rule from the spec. It is intentionally product-shaped even though the real backend is not wired yet."
|
||||
description="This mock settings surface covers the org profile, GitHub installation state, background repository sync controls, and the seat-accrual rule from the spec. It is intentionally product-shaped even though the real backend is not wired yet."
|
||||
actions={
|
||||
<>
|
||||
<button type="button" onClick={() => void navigate({ to: "/organizations" })} style={secondaryButtonStyle()}>
|
||||
|
|
@ -658,7 +557,7 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
|
|||
<button type="button" onClick={() => void navigate({ to: billingPath(organization) })} style={subtleButtonStyle()}>
|
||||
Billing
|
||||
</button>
|
||||
<button type="button" onClick={() => void navigate({ to: workspacePath(organization) })} style={primaryButtonStyle()}>
|
||||
<button type="button" onClick={openWorkspace} style={primaryButtonStyle()}>
|
||||
Open workspace
|
||||
</button>
|
||||
</>
|
||||
|
|
@ -719,8 +618,8 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
|
|||
>
|
||||
Save settings
|
||||
</button>
|
||||
<button type="button" onClick={() => void client.triggerRepoImport(organization.id)} style={secondaryButtonStyle()}>
|
||||
Refresh repo import
|
||||
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={secondaryButtonStyle()}>
|
||||
Refresh repo sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -741,8 +640,8 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
|
|||
<button type="button" onClick={() => void client.reconnectGithub(organization.id)} style={secondaryButtonStyle()}>
|
||||
Reconnect GitHub
|
||||
</button>
|
||||
<button type="button" onClick={() => void navigate({ to: importPath(organization) })} style={subtleButtonStyle()}>
|
||||
Open import flow
|
||||
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={subtleButtonStyle()}>
|
||||
Retry sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -784,19 +683,32 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
|
|||
const snapshot = useMockAppSnapshot();
|
||||
const user = activeMockUser(snapshot);
|
||||
const navigate = useNavigate();
|
||||
const hasStripeCustomer = organization.billing.stripeCustomerId.trim().length > 0;
|
||||
const effectivePlanId: FactoryBillingPlanId = hasStripeCustomer ? organization.billing.planId : "free";
|
||||
const effectiveSeatsIncluded = hasStripeCustomer ? organization.billing.seatsIncluded : 1;
|
||||
const openWorkspace = () => {
|
||||
void (async () => {
|
||||
await client.selectOrganization(organization.id);
|
||||
await navigate({ to: workspacePath(organization) });
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
user={user}
|
||||
title={`${organization.settings.displayName} billing`}
|
||||
eyebrow="Stripe Billing"
|
||||
description="This mock page covers plan selection, hosted checkout entry, renewal controls, seat usage, and invoice history. It is the reviewable UI surface for Milestone 2 billing without wiring the real Stripe backend yet."
|
||||
description={
|
||||
isMockFrontendClient
|
||||
? "This mock page covers plan selection, hosted checkout entry, renewal controls, seat usage, and invoice history. It is the reviewable UI surface for Milestone 2 billing without wiring the real Stripe backend yet."
|
||||
: "This billing surface drives live Stripe checkout, portal management, renewal controls, seat usage, and invoice history from the persisted organization billing model."
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<button type="button" onClick={() => void navigate({ to: settingsPath(organization) })} style={secondaryButtonStyle()}>
|
||||
Org settings
|
||||
</button>
|
||||
<button type="button" onClick={() => void navigate({ to: workspacePath(organization) })} style={primaryButtonStyle()}>
|
||||
<button type="button" onClick={openWorkspace} style={primaryButtonStyle()}>
|
||||
Open workspace
|
||||
</button>
|
||||
</>
|
||||
|
|
@ -811,12 +723,12 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
|
|||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: "14px" }}>
|
||||
<StatCard
|
||||
label="Current plan"
|
||||
value={planCatalog[organization.billing.planId]!.label}
|
||||
value={planCatalog[effectivePlanId]!.label}
|
||||
caption={organization.billing.status.replaceAll("_", " ")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Seats used"
|
||||
value={`${organization.seatAssignments.length}/${organization.billing.seatsIncluded}`}
|
||||
value={`${organization.seatAssignments.length}/${effectiveSeatsIncluded}`}
|
||||
caption="Seat accrual happens on first prompt in the workspace."
|
||||
/>
|
||||
<StatCard
|
||||
|
|
@ -828,7 +740,7 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
|
|||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: "18px" }}>
|
||||
{(Object.entries(planCatalog) as Array<[FactoryBillingPlanId, (typeof planCatalog)[FactoryBillingPlanId]]>).map(([planId, plan]) => {
|
||||
const isCurrent = organization.billing.planId === planId;
|
||||
const isCurrent = effectivePlanId === planId;
|
||||
return (
|
||||
<div key={planId} style={{ ...cardStyle(), padding: "22px", display: "grid", gap: "14px" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px" }}>
|
||||
|
|
@ -863,25 +775,41 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
|
|||
<div style={{ fontSize: "20px", fontWeight: 800 }}>Subscription controls</div>
|
||||
</div>
|
||||
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
|
||||
Stripe customer {organization.billing.stripeCustomerId}. This mock screen intentionally mirrors a hosted
|
||||
billing portal entry point and the in-product summary beside it.
|
||||
Stripe customer {organization.billing.stripeCustomerId || "pending"}.{" "}
|
||||
{isMockFrontendClient
|
||||
? "This mock screen intentionally mirrors a hosted billing portal entry point and the in-product summary beside it."
|
||||
: hasStripeCustomer
|
||||
? "Use the portal for payment method management and invoices, while in-product controls keep renewal state visible in the app shell."
|
||||
: "Complete checkout first, then use the portal and renewal controls once Stripe has created the customer and subscription."}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||
{organization.billing.status === "scheduled_cancel" ? (
|
||||
<button type="button" onClick={() => void client.resumeSubscription(organization.id)} style={primaryButtonStyle()}>
|
||||
Resume subscription
|
||||
</button>
|
||||
{hasStripeCustomer ? (
|
||||
organization.billing.status === "scheduled_cancel" ? (
|
||||
<button type="button" onClick={() => void client.resumeSubscription(organization.id)} style={primaryButtonStyle()}>
|
||||
Resume subscription
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => void client.cancelScheduledRenewal(organization.id)} style={secondaryButtonStyle()}>
|
||||
Cancel at period end
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button type="button" onClick={() => void client.cancelScheduledRenewal(organization.id)} style={secondaryButtonStyle()}>
|
||||
Cancel at period end
|
||||
<button type="button" onClick={() => void navigate({ to: checkoutPath(organization, "team") })} style={primaryButtonStyle()}>
|
||||
Start Team checkout
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: checkoutPath(organization, organization.billing.planId) })}
|
||||
onClick={() =>
|
||||
void (isMockFrontendClient
|
||||
? navigate({ to: checkoutPath(organization, effectivePlanId) })
|
||||
: hasStripeCustomer
|
||||
? client.openBillingPortal(organization.id)
|
||||
: navigate({ to: checkoutPath(organization, "team") }))
|
||||
}
|
||||
style={subtleButtonStyle()}
|
||||
>
|
||||
Open hosted checkout mock
|
||||
{isMockFrontendClient ? "Open hosted checkout mock" : hasStripeCustomer ? "Open Stripe portal" : "Go to checkout"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -951,7 +879,11 @@ export function MockHostedCheckoutPage({
|
|||
user={user}
|
||||
title={`Checkout ${plan.label}`}
|
||||
eyebrow="Hosted Checkout"
|
||||
description="This is the mock hosted Stripe step. Completing checkout updates the org billing state in the client package and returns the reviewer to the billing screen."
|
||||
description={
|
||||
isMockFrontendClient
|
||||
? "This is the mock hosted Stripe step. Completing checkout updates the org billing state in the client package and returns the reviewer to the billing screen."
|
||||
: "This hands off to a live Stripe Checkout session. After payment succeeds, the backend finalizes the session and routes back into the billing screen."
|
||||
}
|
||||
actions={
|
||||
<button type="button" onClick={() => void navigate({ to: billingPath(organization) })} style={secondaryButtonStyle()}>
|
||||
<ArrowLeft size={15} />
|
||||
|
|
@ -975,7 +907,7 @@ export function MockHostedCheckoutPage({
|
|||
<CheckoutLine label="Plan" value={plan.label} />
|
||||
<CheckoutLine label="Price" value={plan.price} />
|
||||
<CheckoutLine label="Included seats" value={plan.seats} />
|
||||
<CheckoutLine label="Payment method" value={planId === "enterprise" ? "ACH mandate" : "Visa ending in 4242"} />
|
||||
<CheckoutLine label="Payment method" value="Visa ending in 4242" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "16px" }}>
|
||||
|
|
@ -995,12 +927,14 @@ export function MockHostedCheckoutPage({
|
|||
onClick={() => {
|
||||
void (async () => {
|
||||
await client.completeHostedCheckout(organization.id, planId);
|
||||
await navigate({ to: billingPath(organization), replace: true });
|
||||
if (isMockFrontendClient) {
|
||||
await navigate({ to: billingPath(organization), replace: true });
|
||||
}
|
||||
})();
|
||||
}}
|
||||
style={primaryButtonStyle()}
|
||||
>
|
||||
Complete checkout
|
||||
{isMockFrontendClient ? "Complete checkout" : "Continue to Stripe"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/factory-shared";
|
||||
import type { AgentType, TaskRecord, TaskSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/factory-shared";
|
||||
import type { SandboxSessionEventRecord } from "@sandbox-agent/factory-client";
|
||||
import { groupHandoffStatus } from "@sandbox-agent/factory-client/view-model";
|
||||
import { groupTaskStatus } from "@sandbox-agent/factory-client/view-model";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "baseui/button";
|
||||
|
|
@ -15,13 +15,13 @@ 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 { formatDiffStat } from "../features/handoffs/model";
|
||||
import { formatDiffStat } from "../features/tasks/model";
|
||||
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
|
||||
import { backendClient } from "../lib/backend";
|
||||
|
||||
interface WorkspaceDashboardProps {
|
||||
workspaceId: string;
|
||||
selectedHandoffId?: string;
|
||||
selectedTaskId?: string;
|
||||
selectedRepoId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ const DetailRail = styled("aside", ({ $theme }) => ({
|
|||
|
||||
const FILTER_OPTIONS: SelectItem[] = [
|
||||
{ id: "active", label: "Active + Unmapped" },
|
||||
{ id: "archived", label: "Archived Handoffs" },
|
||||
{ id: "archived", label: "Archived Tasks" },
|
||||
{ id: "unmapped", label: "Unmapped Only" },
|
||||
{ id: "all", label: "All Branches" },
|
||||
];
|
||||
|
|
@ -97,8 +97,8 @@ const AGENT_OPTIONS: SelectItem[] = [
|
|||
{ id: "claude", label: "claude" },
|
||||
];
|
||||
|
||||
function statusKind(status: HandoffSummary["status"]): StatusTagKind {
|
||||
const group = groupHandoffStatus(status);
|
||||
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";
|
||||
|
|
@ -137,21 +137,21 @@ function branchTestIdToken(value: string): string {
|
|||
}
|
||||
|
||||
function useSessionEvents(
|
||||
handoff: HandoffRecord | null,
|
||||
task: TaskRecord | null,
|
||||
sessionId: string | null
|
||||
): ReturnType<typeof useQuery<{ items: SandboxSessionEventRecord[]; nextCursor?: string }, Error>> {
|
||||
return useQuery({
|
||||
queryKey: ["workspace", handoff?.workspaceId ?? "", "session", handoff?.handoffId ?? "", sessionId ?? ""],
|
||||
enabled: Boolean(handoff?.activeSandboxId && sessionId),
|
||||
queryKey: ["workspace", task?.workspaceId ?? "", "session", task?.taskId ?? "", sessionId ?? ""],
|
||||
enabled: Boolean(task?.activeSandboxId && sessionId),
|
||||
refetchInterval: 2_500,
|
||||
queryFn: async () => {
|
||||
if (!handoff?.activeSandboxId || !sessionId) {
|
||||
if (!task?.activeSandboxId || !sessionId) {
|
||||
return { items: [] };
|
||||
}
|
||||
return backendClient.listSandboxSessionEvents(
|
||||
handoff.workspaceId,
|
||||
handoff.providerId,
|
||||
handoff.activeSandboxId,
|
||||
task.workspaceId,
|
||||
task.providerId,
|
||||
task.activeSandboxId,
|
||||
{
|
||||
sessionId,
|
||||
limit: 120,
|
||||
|
|
@ -186,7 +186,7 @@ function repoSummary(overview: RepoOverview | undefined): {
|
|||
let openPrs = 0;
|
||||
|
||||
for (const row of overview.branches) {
|
||||
if (row.handoffId) {
|
||||
if (row.taskId) {
|
||||
mapped += 1;
|
||||
}
|
||||
if (row.conflictsWithMain) {
|
||||
|
|
@ -225,13 +225,13 @@ function branchKind(row: RepoBranchRecord): StatusTagKind {
|
|||
|
||||
function matchesOverviewFilter(branch: RepoBranchRecord, filter: RepoOverviewFilter): boolean {
|
||||
if (filter === "archived") {
|
||||
return branch.handoffStatus === "archived";
|
||||
return branch.taskStatus === "archived";
|
||||
}
|
||||
if (filter === "unmapped") {
|
||||
return branch.handoffId === null;
|
||||
return branch.taskId === null;
|
||||
}
|
||||
if (filter === "active") {
|
||||
return branch.handoffStatus !== "archived";
|
||||
return branch.taskStatus !== "archived";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -364,7 +364,7 @@ function MetaRow({ label, value, mono = false }: { label: string; value: string;
|
|||
);
|
||||
}
|
||||
|
||||
export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRepoId }: WorkspaceDashboardProps) {
|
||||
export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId }: WorkspaceDashboardProps) {
|
||||
const [css, theme] = useStyletron();
|
||||
const navigate = useNavigate();
|
||||
const repoOverviewMode = typeof selectedRepoId === "string" && selectedRepoId.length > 0;
|
||||
|
|
@ -377,7 +377,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [createOnBranch, setCreateOnBranch] = useState<string | null>(null);
|
||||
const [addRepoOpen, setAddRepoOpen] = useState(false);
|
||||
const [createHandoffOpen, setCreateHandoffOpen] = 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);
|
||||
|
|
@ -396,21 +396,21 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const handoffsQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "handoffs"],
|
||||
queryFn: async () => backendClient.listHandoffs(workspaceId),
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "tasks"],
|
||||
queryFn: async () => backendClient.listTasks(workspaceId),
|
||||
refetchInterval: 2_500,
|
||||
});
|
||||
|
||||
const handoffDetailQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "handoff-detail", selectedHandoffId],
|
||||
enabled: Boolean(selectedHandoffId && !repoOverviewMode),
|
||||
const taskDetailQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "task-detail", selectedTaskId],
|
||||
enabled: Boolean(selectedTaskId && !repoOverviewMode),
|
||||
refetchInterval: 2_500,
|
||||
queryFn: async () => {
|
||||
if (!selectedHandoffId) {
|
||||
throw new Error("No handoff");
|
||||
if (!selectedTaskId) {
|
||||
throw new Error("No task");
|
||||
}
|
||||
return backendClient.getHandoff(workspaceId, selectedHandoffId);
|
||||
return backendClient.getTask(workspaceId, selectedTaskId);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -453,9 +453,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
}
|
||||
}, [newAgentType]);
|
||||
|
||||
const rows = handoffsQuery.data ?? [];
|
||||
const rows = tasksQuery.data ?? [];
|
||||
const repoGroups = useMemo(() => {
|
||||
const byRepo = new Map<string, HandoffSummary[]>();
|
||||
const byRepo = new Map<string, TaskSummary[]>();
|
||||
for (const row of rows) {
|
||||
const bucket = byRepo.get(row.repoId);
|
||||
if (bucket) {
|
||||
|
|
@ -467,13 +467,13 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
|
||||
return repos
|
||||
.map((repo) => {
|
||||
const handoffs = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const latestHandoffAt = handoffs[0]?.updatedAt ?? 0;
|
||||
const tasks = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const latestTaskAt = tasks[0]?.updatedAt ?? 0;
|
||||
return {
|
||||
repoId: repo.repoId,
|
||||
repoRemote: repo.remoteUrl,
|
||||
latestActivityAt: Math.max(repo.updatedAt, latestHandoffAt),
|
||||
handoffs,
|
||||
latestActivityAt: Math.max(repo.updatedAt, latestTaskAt),
|
||||
tasks,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
|
|
@ -485,11 +485,11 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
}, [repos, rows]);
|
||||
|
||||
const selectedSummary = useMemo(
|
||||
() => rows.find((row) => row.handoffId === selectedHandoffId) ?? rows[0] ?? null,
|
||||
[rows, selectedHandoffId]
|
||||
() => rows.find((row) => row.taskId === selectedTaskId) ?? rows[0] ?? null,
|
||||
[rows, selectedTaskId]
|
||||
);
|
||||
|
||||
const selectedForSession = repoOverviewMode ? null : (handoffDetailQuery.data ?? null);
|
||||
const selectedForSession = repoOverviewMode ? null : (taskDetailQuery.data ?? null);
|
||||
|
||||
const activeSandbox = useMemo(() => {
|
||||
if (!selectedForSession) return null;
|
||||
|
|
@ -500,23 +500,23 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
}, [selectedForSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoOverviewMode && !selectedHandoffId && rows.length > 0) {
|
||||
if (!repoOverviewMode && !selectedTaskId && rows.length > 0) {
|
||||
void navigate({
|
||||
to: "/workspaces/$workspaceId/handoffs/$handoffId",
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
handoffId: rows[0]!.handoffId,
|
||||
taskId: rows[0]!.taskId,
|
||||
},
|
||||
search: { sessionId: undefined },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [navigate, repoOverviewMode, rows, selectedHandoffId, workspaceId]);
|
||||
}, [navigate, repoOverviewMode, rows, selectedTaskId, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSessionId(null);
|
||||
setDraft("");
|
||||
}, [selectedForSession?.handoffId]);
|
||||
}, [selectedForSession?.taskId]);
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"],
|
||||
|
|
@ -537,7 +537,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
() =>
|
||||
resolveSessionSelection({
|
||||
explicitSessionId: activeSessionId,
|
||||
handoffSessionId: selectedForSession?.activeSessionId ?? null,
|
||||
taskSessionId: selectedForSession?.activeSessionId ?? null,
|
||||
sessions: sessionRows,
|
||||
}),
|
||||
[activeSessionId, selectedForSession?.activeSessionId, sessionRows]
|
||||
|
|
@ -547,9 +547,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId);
|
||||
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
|
||||
|
||||
const startSessionFromHandoff = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
|
||||
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
|
||||
if (!selectedForSession || !activeSandbox?.sandboxId) {
|
||||
throw new Error("No sandbox is available for this handoff");
|
||||
throw new Error("No sandbox is available for this task");
|
||||
}
|
||||
return backendClient.createSandboxSession({
|
||||
workspaceId,
|
||||
|
|
@ -562,7 +562,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
};
|
||||
|
||||
const createSession = useMutation({
|
||||
mutationFn: async () => startSessionFromHandoff(),
|
||||
mutationFn: async () => startSessionFromTask(),
|
||||
onSuccess: async (session) => {
|
||||
setActiveSessionId(session.id);
|
||||
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
|
||||
|
|
@ -573,7 +573,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
if (resolvedSessionId) {
|
||||
return resolvedSessionId;
|
||||
}
|
||||
const created = await startSessionFromHandoff();
|
||||
const created = await startSessionFromTask();
|
||||
setActiveSessionId(created.id);
|
||||
await sessionsQuery.refetch();
|
||||
return created.id;
|
||||
|
|
@ -582,7 +582,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
const sendPrompt = useMutation({
|
||||
mutationFn: async (prompt: string) => {
|
||||
if (!selectedForSession || !activeSandbox?.sandboxId) {
|
||||
throw new Error("No sandbox is available for this handoff");
|
||||
throw new Error("No sandbox is available for this task");
|
||||
}
|
||||
const sessionId = await ensureSessionForPrompt();
|
||||
await backendClient.sendSandboxPrompt({
|
||||
|
|
@ -600,9 +600,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
});
|
||||
|
||||
const transcript = buildTranscript(eventsQuery.data?.items ?? []);
|
||||
const canCreateHandoff = createRepoId.trim().length > 0 && newTask.trim().length > 0;
|
||||
const canCreateTask = createRepoId.trim().length > 0 && newTask.trim().length > 0;
|
||||
|
||||
const createHandoff = useMutation({
|
||||
const createTask = useMutation({
|
||||
mutationFn: async () => {
|
||||
const repoId = createRepoId.trim();
|
||||
const task = newTask.trim();
|
||||
|
|
@ -613,7 +613,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
const draftTitle = newTitle.trim();
|
||||
const draftBranchName = newBranchName.trim();
|
||||
|
||||
return backendClient.createHandoff({
|
||||
return backendClient.createTask({
|
||||
workspaceId,
|
||||
repoId,
|
||||
task,
|
||||
|
|
@ -623,20 +623,20 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
onBranch: createOnBranch ?? undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: async (handoff) => {
|
||||
onSuccess: async (task) => {
|
||||
setCreateError(null);
|
||||
setNewTask("");
|
||||
setNewTitle("");
|
||||
setNewBranchName("");
|
||||
setCreateOnBranch(null);
|
||||
setCreateHandoffOpen(false);
|
||||
await handoffsQuery.refetch();
|
||||
setCreateTaskOpen(false);
|
||||
await tasksQuery.refetch();
|
||||
await repoOverviewQuery.refetch();
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/handoffs/$handoffId",
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
handoffId: handoff.handoffId,
|
||||
taskId: task.taskId,
|
||||
},
|
||||
search: { sessionId: undefined },
|
||||
});
|
||||
|
|
@ -696,7 +696,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
setStackActionMessage(null);
|
||||
setStackActionError(result.message);
|
||||
}
|
||||
await Promise.all([repoOverviewQuery.refetch(), handoffsQuery.refetch()]);
|
||||
await Promise.all([repoOverviewQuery.refetch(), tasksQuery.refetch()]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setStackActionMessage(null);
|
||||
|
|
@ -712,7 +712,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
if (!newTask.trim()) {
|
||||
setNewTask(`Continue work on ${branchName}`);
|
||||
}
|
||||
setCreateHandoffOpen(true);
|
||||
setCreateTaskOpen(true);
|
||||
};
|
||||
|
||||
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]);
|
||||
|
|
@ -858,19 +858,19 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
borderTop: `1px solid ${theme.colors.borderOpaque}`,
|
||||
})}
|
||||
>
|
||||
<LabelXSmall color="contentSecondary">Handoffs</LabelXSmall>
|
||||
<LabelXSmall color="contentSecondary">Tasks</LabelXSmall>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<ScrollBody>
|
||||
{handoffsQuery.isLoading ? (
|
||||
{tasksQuery.isLoading ? (
|
||||
<>
|
||||
<Skeleton rows={3} height="72px" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!handoffsQuery.isLoading && repoGroups.length === 0 ? (
|
||||
<EmptyState>No repos or handoffs yet. Add a repo to start a workspace.</EmptyState>
|
||||
{!tasksQuery.isLoading && repoGroups.length === 0 ? (
|
||||
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
|
||||
) : null}
|
||||
|
||||
{repoGroups.map((group) => (
|
||||
|
|
@ -912,15 +912,15 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
gap: "0",
|
||||
})}
|
||||
>
|
||||
{group.handoffs
|
||||
.filter((handoff) => handoff.status !== "archived" || handoff.handoffId === selectedSummary?.handoffId)
|
||||
.map((handoff) => {
|
||||
const isActive = !repoOverviewMode && handoff.handoffId === selectedSummary?.handoffId;
|
||||
{group.tasks
|
||||
.filter((task) => task.status !== "archived" || task.taskId === selectedSummary?.taskId)
|
||||
.map((task) => {
|
||||
const isActive = !repoOverviewMode && task.taskId === selectedSummary?.taskId;
|
||||
return (
|
||||
<Link
|
||||
key={handoff.handoffId}
|
||||
to="/workspaces/$workspaceId/handoffs/$handoffId"
|
||||
params={{ workspaceId, handoffId: handoff.handoffId }}
|
||||
key={task.taskId}
|
||||
to="/workspaces/$workspaceId/tasks/$taskId"
|
||||
params={{ workspaceId, taskId: task.taskId }}
|
||||
search={{ sessionId: undefined }}
|
||||
className={css({
|
||||
display: "block",
|
||||
|
|
@ -929,7 +929,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
borderTop: `1px solid ${theme.colors.borderOpaque}`,
|
||||
backgroundColor: isActive
|
||||
? "rgba(143, 180, 255, 0.08)"
|
||||
: handoff.status === "archived"
|
||||
: task.status === "archived"
|
||||
? "rgba(255, 255, 255, 0.02)"
|
||||
: "transparent",
|
||||
padding: "10px 12px 10px 14px",
|
||||
|
|
@ -940,7 +940,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
})}
|
||||
>
|
||||
<LabelSmall marginTop="0" marginBottom="0">
|
||||
{handoff.title ?? "Determining title..."}
|
||||
{task.title ?? "Determining title..."}
|
||||
</LabelSmall>
|
||||
<div
|
||||
className={css({
|
||||
|
|
@ -957,9 +957,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
color="contentSecondary"
|
||||
overrides={{ Block: { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } } }}
|
||||
>
|
||||
{handoff.branchName ?? "Determining branch..."}
|
||||
{task.branchName ?? "Determining branch..."}
|
||||
</ParagraphSmall>
|
||||
<StatusPill kind={statusKind(handoff.status)}>{handoff.status}</StatusPill>
|
||||
<StatusPill kind={statusKind(task.status)}>{task.status}</StatusPill>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
|
@ -982,11 +982,11 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
setCreateRepoId(group.repoId);
|
||||
setCreateOnBranch(null);
|
||||
setCreateError(null);
|
||||
setCreateHandoffOpen(true);
|
||||
setCreateTaskOpen(true);
|
||||
}}
|
||||
data-testid={group.repoId === createRepoId ? "handoff-create-open" : `handoff-create-open-${group.repoId}`}
|
||||
data-testid={group.repoId === createRepoId ? "task-create-open" : `task-create-open-${group.repoId}`}
|
||||
>
|
||||
Create Handoff
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1198,8 +1198,8 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
||||
{formatRelativeAge(branch.updatedAt)}
|
||||
</ParagraphSmall>
|
||||
<StatusPill kind={branch.handoffId ? "positive" : "warning"}>
|
||||
{branch.handoffId ? "handoff" : "unmapped"}
|
||||
<StatusPill kind={branch.taskId ? "positive" : "warning"}>
|
||||
{branch.taskId ? "task" : "unmapped"}
|
||||
</StatusPill>
|
||||
{branch.trackedInStack ? <StatusPill kind="neutral">stack</StatusPill> : null}
|
||||
</div>
|
||||
|
|
@ -1282,7 +1282,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
Reparent
|
||||
</Button>
|
||||
|
||||
{!branch.handoffId ? (
|
||||
{!branch.taskId ? (
|
||||
<Button
|
||||
size="compact"
|
||||
kind="secondary"
|
||||
|
|
@ -1292,7 +1292,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
}}
|
||||
data-testid={`repo-overview-create-${branchToken}`}
|
||||
>
|
||||
Create Handoff
|
||||
Create Task
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1332,7 +1332,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
>
|
||||
<Bot size={16} />
|
||||
<HeadingXSmall marginTop="0" marginBottom="0">
|
||||
{selectedForSession ? selectedForSession.title ?? "Determining title..." : "No handoff selected"}
|
||||
{selectedForSession ? selectedForSession.title ?? "Determining title..." : "No task selected"}
|
||||
</HeadingXSmall>
|
||||
{selectedForSession ? (
|
||||
<StatusPill kind={statusKind(selectedForSession.status)}>{selectedForSession.status}</StatusPill>
|
||||
|
|
@ -1365,7 +1365,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
})}
|
||||
>
|
||||
{!selectedForSession ? (
|
||||
<EmptyState>Select a handoff from the left sidebar.</EmptyState>
|
||||
<EmptyState>Select a task from the left sidebar.</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -1417,7 +1417,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
setActiveSessionId(next);
|
||||
}
|
||||
}}
|
||||
overrides={selectTestIdOverrides("handoff-session-select")}
|
||||
overrides={selectTestIdOverrides("task-session-select")}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1436,17 +1436,17 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
|
||||
{transcript.length === 0 && !eventsQuery.isLoading ? (
|
||||
<EmptyState testId="session-transcript-empty">
|
||||
{groupHandoffStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage
|
||||
{groupTaskStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage
|
||||
? `Session failed: ${selectedForSession.statusMessage}`
|
||||
: !activeSandbox?.sandboxId
|
||||
? selectedForSession.statusMessage
|
||||
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
|
||||
: "This handoff is still provisioning its sandbox."
|
||||
: "This task is still provisioning its sandbox."
|
||||
: staleSessionId
|
||||
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
|
||||
: resolvedSessionId
|
||||
? "No transcript events yet. Send a prompt to start this session."
|
||||
: "No active session for this handoff."}
|
||||
: "No active session for this task."}
|
||||
</EmptyState>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1519,7 +1519,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
placeholder="Send a follow-up prompt to this session"
|
||||
rows={5}
|
||||
disabled={!activeSandbox?.sandboxId}
|
||||
overrides={textareaTestIdOverrides("handoff-session-prompt")}
|
||||
overrides={textareaTestIdOverrides("task-session-prompt")}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
|
|
@ -1566,7 +1566,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
<DetailRail>
|
||||
<PanelHeader>
|
||||
<HeadingSmall marginTop="0" marginBottom="0">
|
||||
{repoOverviewMode ? "Repo Details" : "Handoff Details"}
|
||||
{repoOverviewMode ? "Repo Details" : "Task Details"}
|
||||
</HeadingSmall>
|
||||
</PanelHeader>
|
||||
|
||||
|
|
@ -1619,8 +1619,8 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
|
||||
<MetaRow label="Diff" value={formatDiffStat(selectedBranchOverview.diffStat)} />
|
||||
<MetaRow
|
||||
label="Handoff"
|
||||
value={selectedBranchOverview.handoffTitle ?? selectedBranchOverview.handoffId ?? "-"}
|
||||
label="Task"
|
||||
value={selectedBranchOverview.taskTitle ?? selectedBranchOverview.taskId ?? "-"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1629,7 +1629,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
)
|
||||
) : !selectedForSession ? (
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
||||
No handoff selected.
|
||||
No task selected.
|
||||
</ParagraphSmall>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -1645,7 +1645,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
gap: theme.sizing.scale300,
|
||||
})}
|
||||
>
|
||||
<MetaRow label="Handoff" value={selectedForSession.handoffId} mono />
|
||||
<MetaRow label="Task" value={selectedForSession.taskId} mono />
|
||||
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
|
||||
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
|
||||
</div>
|
||||
|
|
@ -1689,7 +1689,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{groupHandoffStatus(selectedForSession.status) === "error" ? (
|
||||
{groupTaskStatus(selectedForSession.status) === "error" ? (
|
||||
<div
|
||||
className={css({
|
||||
padding: "12px",
|
||||
|
|
@ -1767,14 +1767,14 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={createHandoffOpen}
|
||||
isOpen={createTaskOpen}
|
||||
onClose={() => {
|
||||
setCreateHandoffOpen(false);
|
||||
setCreateTaskOpen(false);
|
||||
setCreateOnBranch(null);
|
||||
}}
|
||||
overrides={modalOverrides}
|
||||
>
|
||||
<ModalHeader>Create Handoff</ModalHeader>
|
||||
<ModalHeader>Create Task</ModalHeader>
|
||||
<ModalBody>
|
||||
<div
|
||||
className={css({
|
||||
|
|
@ -1784,7 +1784,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
})}
|
||||
>
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
||||
Pick a repo, describe the task, and the backend will create a handoff.
|
||||
Pick a repo, describe the task, and the backend will create a task.
|
||||
</ParagraphSmall>
|
||||
|
||||
<div>
|
||||
|
|
@ -1803,7 +1803,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
setCreateRepoId(next);
|
||||
}
|
||||
}}
|
||||
overrides={selectTestIdOverrides("handoff-create-repo")}
|
||||
overrides={selectTestIdOverrides("task-create-repo")}
|
||||
/>
|
||||
{repos.length === 0 ? (
|
||||
<div
|
||||
|
|
@ -1826,7 +1826,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
size="compact"
|
||||
kind="secondary"
|
||||
onClick={() => {
|
||||
setCreateHandoffOpen(false);
|
||||
setCreateTaskOpen(false);
|
||||
setAddRepoError(null);
|
||||
setAddRepoOpen(true);
|
||||
}}
|
||||
|
|
@ -1852,7 +1852,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
setNewAgentType(next);
|
||||
}
|
||||
}}
|
||||
overrides={selectTestIdOverrides("handoff-create-agent")}
|
||||
overrides={selectTestIdOverrides("task-create-agent")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -1865,7 +1865,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
onChange={(event) => setNewTask(event.target.value)}
|
||||
placeholder="Task"
|
||||
rows={6}
|
||||
overrides={textareaTestIdOverrides("handoff-create-task")}
|
||||
overrides={textareaTestIdOverrides("task-create-task")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -1877,7 +1877,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
placeholder="Title (optional)"
|
||||
value={newTitle}
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
overrides={inputTestIdOverrides("handoff-create-title")}
|
||||
overrides={inputTestIdOverrides("task-create-title")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -1886,19 +1886,19 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
Branch
|
||||
</LabelXSmall>
|
||||
{createOnBranch ? (
|
||||
<Input value={createOnBranch} disabled overrides={inputTestIdOverrides("handoff-create-branch")} />
|
||||
<Input value={createOnBranch} disabled overrides={inputTestIdOverrides("task-create-branch")} />
|
||||
) : (
|
||||
<Input
|
||||
placeholder="Branch name (optional)"
|
||||
value={newBranchName}
|
||||
onChange={(event) => setNewBranchName(event.target.value)}
|
||||
overrides={inputTestIdOverrides("handoff-create-branch")}
|
||||
overrides={inputTestIdOverrides("task-create-branch")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createError ? (
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="handoff-create-error">
|
||||
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="task-create-error">
|
||||
{createError}
|
||||
</ParagraphSmall>
|
||||
) : null}
|
||||
|
|
@ -1908,21 +1908,21 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
|
|||
<Button
|
||||
kind="tertiary"
|
||||
onClick={() => {
|
||||
setCreateHandoffOpen(false);
|
||||
setCreateTaskOpen(false);
|
||||
setCreateOnBranch(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canCreateHandoff || createHandoff.isPending}
|
||||
disabled={!canCreateTask || createTask.isPending}
|
||||
onClick={() => {
|
||||
setCreateError(null);
|
||||
void createHandoff.mutateAsync();
|
||||
void createTask.mutateAsync();
|
||||
}}
|
||||
data-testid="handoff-create-submit"
|
||||
data-testid="task-create-submit"
|
||||
>
|
||||
Create Handoff
|
||||
Create Task
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
declare module "@sandbox-agent/factory-client/view-model" {
|
||||
export {
|
||||
HANDOFF_STATUS_GROUPS,
|
||||
groupHandoffStatus,
|
||||
groupTaskStatus,
|
||||
} from "@sandbox-agent/factory-client";
|
||||
export type { HandoffStatusGroup } from "@sandbox-agent/factory-client";
|
||||
export type { TaskStatusGroup } from "@sandbox-agent/factory-client";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export interface RepoGroup {
|
||||
repoId: string;
|
||||
repoRemote: string;
|
||||
handoffs: HandoffRecord[];
|
||||
}
|
||||
|
||||
export function groupHandoffsByRepo(handoffs: HandoffRecord[]): RepoGroup[] {
|
||||
const groups = new Map<string, RepoGroup>();
|
||||
|
||||
for (const handoff of handoffs) {
|
||||
const group = groups.get(handoff.repoId);
|
||||
if (group) {
|
||||
group.handoffs.push(handoff);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(handoff.repoId, {
|
||||
repoId: handoff.repoId,
|
||||
repoRemote: handoff.repoRemote,
|
||||
handoffs: [handoff],
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.map((group) => ({
|
||||
...group,
|
||||
handoffs: [...group.handoffs].sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aLatest = a.handoffs[0]?.updatedAt ?? 0;
|
||||
const bLatest = b.handoffs[0]?.updatedAt ?? 0;
|
||||
if (aLatest !== bLatest) {
|
||||
return bLatest - aLatest;
|
||||
}
|
||||
return a.repoRemote.localeCompare(b.repoRemote);
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDiffStat(diffStat: string | null | undefined): string {
|
||||
const normalized = diffStat?.trim();
|
||||
if (!normalized) {
|
||||
return "-";
|
||||
}
|
||||
if (normalized === "+0/-0" || normalized === "+0 -0" || normalized === "0 files changed") {
|
||||
return "No changes";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ describe("resolveSessionSelection", () => {
|
|||
it("prefers explicit selection when present in session list", () => {
|
||||
const resolved = resolveSessionSelection({
|
||||
explicitSessionId: "session-2",
|
||||
handoffSessionId: "session-1",
|
||||
taskSessionId: "session-1",
|
||||
sessions: [session("session-1"), session("session-2")]
|
||||
});
|
||||
|
||||
|
|
@ -102,10 +102,10 @@ describe("resolveSessionSelection", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("falls back to handoff session when explicit selection is missing", () => {
|
||||
it("falls back to task session when explicit selection is missing", () => {
|
||||
const resolved = resolveSessionSelection({
|
||||
explicitSessionId: null,
|
||||
handoffSessionId: "session-1",
|
||||
taskSessionId: "session-1",
|
||||
sessions: [session("session-1")]
|
||||
});
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ describe("resolveSessionSelection", () => {
|
|||
it("falls back to the newest available session when configured session IDs are stale", () => {
|
||||
const resolved = resolveSessionSelection({
|
||||
explicitSessionId: null,
|
||||
handoffSessionId: "session-stale",
|
||||
taskSessionId: "session-stale",
|
||||
sessions: [session("session-fresh")]
|
||||
});
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ describe("resolveSessionSelection", () => {
|
|||
it("marks stale session when no sessions are available", () => {
|
||||
const resolved = resolveSessionSelection({
|
||||
explicitSessionId: null,
|
||||
handoffSessionId: "session-stale",
|
||||
taskSessionId: "session-stale",
|
||||
sessions: []
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export function buildTranscript(events: SandboxSessionEventRecord[]): Array<{
|
|||
|
||||
export function resolveSessionSelection(input: {
|
||||
explicitSessionId: string | null;
|
||||
handoffSessionId: string | null;
|
||||
taskSessionId: string | null;
|
||||
sessions: SandboxSessionRecord[];
|
||||
}): {
|
||||
sessionId: string | null;
|
||||
|
|
@ -120,8 +120,8 @@ export function resolveSessionSelection(input: {
|
|||
return { sessionId: input.explicitSessionId, staleSessionId: null };
|
||||
}
|
||||
|
||||
if (hasSession(input.handoffSessionId)) {
|
||||
return { sessionId: input.handoffSessionId, staleSessionId: null };
|
||||
if (hasSession(input.taskSessionId)) {
|
||||
return { sessionId: input.taskSessionId, staleSessionId: null };
|
||||
}
|
||||
|
||||
const fallbackSessionId = input.sessions[0]?.id ?? null;
|
||||
|
|
@ -133,8 +133,8 @@ export function resolveSessionSelection(input: {
|
|||
return { sessionId: null, staleSessionId: input.explicitSessionId };
|
||||
}
|
||||
|
||||
if (input.handoffSessionId) {
|
||||
return { sessionId: null, staleSessionId: input.handoffSessionId };
|
||||
if (input.taskSessionId) {
|
||||
return { sessionId: null, staleSessionId: input.taskSessionId };
|
||||
}
|
||||
|
||||
return { sessionId: null, staleSessionId: null };
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import { formatDiffStat, groupHandoffsByRepo } from "./model";
|
||||
import type { TaskRecord } from "@sandbox-agent/factory-shared";
|
||||
import { formatDiffStat, groupTasksByRepo } from "./model";
|
||||
|
||||
const base: HandoffRecord = {
|
||||
const base: TaskRecord = {
|
||||
workspaceId: "default",
|
||||
repoId: "repo-a",
|
||||
repoRemote: "https://example.com/repo-a.git",
|
||||
handoffId: "handoff-1",
|
||||
taskId: "task-1",
|
||||
branchName: "feature/one",
|
||||
title: "Feature one",
|
||||
task: "Ship one",
|
||||
|
|
@ -41,27 +41,27 @@ const base: HandoffRecord = {
|
|||
updatedAt: 10,
|
||||
};
|
||||
|
||||
describe("groupHandoffsByRepo", () => {
|
||||
describe("groupTasksByRepo", () => {
|
||||
it("groups by repo and sorts by recency", () => {
|
||||
const rows: HandoffRecord[] = [
|
||||
{ ...base, handoffId: "h1", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 10 },
|
||||
{ ...base, handoffId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 50 },
|
||||
{ ...base, handoffId: "h3", repoId: "repo-b", repoRemote: "https://example.com/repo-b.git", updatedAt: 30 },
|
||||
const rows: TaskRecord[] = [
|
||||
{ ...base, taskId: "h1", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 10 },
|
||||
{ ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 50 },
|
||||
{ ...base, taskId: "h3", repoId: "repo-b", repoRemote: "https://example.com/repo-b.git", updatedAt: 30 },
|
||||
];
|
||||
|
||||
const groups = groupHandoffsByRepo(rows);
|
||||
const groups = groupTasksByRepo(rows);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]?.repoId).toBe("repo-a");
|
||||
expect(groups[0]?.handoffs[0]?.handoffId).toBe("h2");
|
||||
expect(groups[0]?.tasks[0]?.taskId).toBe("h2");
|
||||
});
|
||||
|
||||
it("sorts repo groups by latest handoff activity first", () => {
|
||||
const rows: HandoffRecord[] = [
|
||||
{ ...base, handoffId: "h1", repoId: "repo-z", repoRemote: "https://example.com/repo-z.git", updatedAt: 200 },
|
||||
{ ...base, handoffId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 100 },
|
||||
it("sorts repo groups by latest task activity first", () => {
|
||||
const rows: TaskRecord[] = [
|
||||
{ ...base, taskId: "h1", repoId: "repo-z", repoRemote: "https://example.com/repo-z.git", updatedAt: 200 },
|
||||
{ ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 100 },
|
||||
];
|
||||
|
||||
const groups = groupHandoffsByRepo(rows);
|
||||
const groups = groupTasksByRepo(rows);
|
||||
expect(groups[0]?.repoId).toBe("repo-z");
|
||||
expect(groups[1]?.repoId).toBe("repo-a");
|
||||
});
|
||||
54
factory/packages/frontend/src/features/tasks/model.ts
Normal file
54
factory/packages/frontend/src/features/tasks/model.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { TaskRecord } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export interface RepoGroup {
|
||||
repoId: string;
|
||||
repoRemote: string;
|
||||
tasks: TaskRecord[];
|
||||
}
|
||||
|
||||
export function groupTasksByRepo(tasks: TaskRecord[]): RepoGroup[] {
|
||||
const groups = new Map<string, RepoGroup>();
|
||||
|
||||
for (const task of tasks) {
|
||||
const linkedRepoIds = task.repoIds?.length ? task.repoIds : [task.repoId];
|
||||
for (const repoId of linkedRepoIds) {
|
||||
const group = groups.get(repoId);
|
||||
if (group) {
|
||||
group.tasks.push(task);
|
||||
group.tasks = group.tasks;
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(repoId, {
|
||||
repoId,
|
||||
repoRemote: task.repoRemote,
|
||||
tasks: [task],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.map((group) => ({
|
||||
...group,
|
||||
tasks: [...group.tasks].sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aLatest = a.tasks[0]?.updatedAt ?? 0;
|
||||
const bLatest = b.tasks[0]?.updatedAt ?? 0;
|
||||
if (aLatest !== bLatest) {
|
||||
return bLatest - aLatest;
|
||||
}
|
||||
return a.repoRemote.localeCompare(b.repoRemote);
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDiffStat(diffStat: string | null | undefined): string {
|
||||
const normalized = diffStat?.trim();
|
||||
if (!normalized) {
|
||||
return "-";
|
||||
}
|
||||
if (normalized === "+0/-0" || normalized === "+0 -0" || normalized === "0 files changed") {
|
||||
return "No changes";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import type { FactoryAppSnapshot, FactoryOrganization } from "@sandbox-agent/fac
|
|||
import { backendClient } from "./backend";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
const REMOTE_APP_SESSION_STORAGE_KEY = "sandbox-agent-factory:remote-app-session";
|
||||
|
||||
const appClient: FactoryAppClient = createFactoryAppClient({
|
||||
mode: frontendClientMode,
|
||||
backend: frontendClientMode === "remote" ? backendClient : undefined,
|
||||
|
|
@ -31,10 +33,47 @@ export const activeMockUser = currentFactoryUser;
|
|||
export const activeMockOrganization = currentFactoryOrganization;
|
||||
export const eligibleOrganizations = eligibleFactoryOrganizations;
|
||||
|
||||
// Track whether the remote client has delivered its first real snapshot.
|
||||
// Before the first fetch completes the snapshot is the default empty signed_out state,
|
||||
// so we show a loading screen. Once the fetch returns we know the truth.
|
||||
let firstSnapshotDelivered = false;
|
||||
|
||||
// The remote client notifies listeners after refresh(), which sets `firstSnapshotDelivered`.
|
||||
const origSubscribe = appClient.subscribe.bind(appClient);
|
||||
appClient.subscribe = (listener: () => void): (() => void) => {
|
||||
const wrappedListener = () => {
|
||||
firstSnapshotDelivered = true;
|
||||
listener();
|
||||
};
|
||||
return origSubscribe(wrappedListener);
|
||||
};
|
||||
|
||||
export function isAppSnapshotBootstrapping(snapshot: FactoryAppSnapshot): boolean {
|
||||
if (frontendClientMode !== "remote" || typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasStoredSession = window.localStorage.getItem(REMOTE_APP_SESSION_STORAGE_KEY)?.trim().length;
|
||||
if (!hasStoredSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the backend has already responded and we're still signed_out, the session is stale.
|
||||
if (firstSnapshotDelivered) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Still waiting for the initial fetch — show the loading screen.
|
||||
return (
|
||||
snapshot.auth.status === "signed_out" &&
|
||||
snapshot.users.length === 0 &&
|
||||
snapshot.organizations.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function getMockOrganizationById(
|
||||
snapshot: FactoryAppSnapshot,
|
||||
organizationId: string,
|
||||
): FactoryOrganization | null {
|
||||
return snapshot.organizations.find((organization) => organization.id === organizationId) ?? null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
|
||||
import type { TaskWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export function resolveRepoRouteHandoffId(
|
||||
snapshot: HandoffWorkbenchSnapshot,
|
||||
export function resolveRepoRouteTaskId(
|
||||
snapshot: TaskWorkbenchSnapshot,
|
||||
repoId: string,
|
||||
): string | null {
|
||||
return snapshot.handoffs.find((handoff) => handoff.repoId === repoId)?.id ?? null;
|
||||
const tasks = (snapshot as TaskWorkbenchSnapshot & { tasks?: TaskWorkbenchSnapshot["tasks"] }).tasks ?? snapshot.tasks;
|
||||
return tasks.find((task) =>
|
||||
(task.repoIds?.length ? task.repoIds : [task.repoId]).includes(repoId)
|
||||
)?.id ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import {
|
||||
createHandoffWorkbenchClient,
|
||||
type HandoffWorkbenchClient,
|
||||
createTaskWorkbenchClient,
|
||||
type TaskWorkbenchClient,
|
||||
} from "@sandbox-agent/factory-client/workbench";
|
||||
|
||||
export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient {
|
||||
return createHandoffWorkbenchClient({
|
||||
export function createWorkbenchRuntimeClient(workspaceId: string): TaskWorkbenchClient {
|
||||
return createTaskWorkbenchClient({
|
||||
mode: "mock",
|
||||
workspaceId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import {
|
||||
createHandoffWorkbenchClient,
|
||||
type HandoffWorkbenchClient,
|
||||
createTaskWorkbenchClient,
|
||||
type TaskWorkbenchClient,
|
||||
} from "@sandbox-agent/factory-client/workbench";
|
||||
import { backendClient } from "./backend";
|
||||
|
||||
export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient {
|
||||
return createHandoffWorkbenchClient({
|
||||
export function createWorkbenchRuntimeClient(workspaceId: string): TaskWorkbenchClient {
|
||||
return createTaskWorkbenchClient({
|
||||
mode: "remote",
|
||||
backend: backendClient,
|
||||
workspaceId,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
|
||||
import { resolveRepoRouteHandoffId } from "./workbench-routing";
|
||||
import type { TaskWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
|
||||
import { resolveRepoRouteTaskId } from "./workbench-routing";
|
||||
|
||||
const snapshot: HandoffWorkbenchSnapshot = {
|
||||
const snapshot: TaskWorkbenchSnapshot = {
|
||||
workspaceId: "default",
|
||||
repos: [
|
||||
{ id: "repo-a", label: "acme/repo-a" },
|
||||
{ id: "repo-b", label: "acme/repo-b" },
|
||||
],
|
||||
projects: [],
|
||||
handoffs: [
|
||||
repoSections: [],
|
||||
tasks: [
|
||||
{
|
||||
id: "handoff-a",
|
||||
id: "task-a",
|
||||
repoId: "repo-a",
|
||||
title: "Alpha",
|
||||
status: "idle",
|
||||
|
|
@ -27,12 +27,12 @@ const snapshot: HandoffWorkbenchSnapshot = {
|
|||
],
|
||||
};
|
||||
|
||||
describe("resolveRepoRouteHandoffId", () => {
|
||||
it("finds the active handoff for a repo route", () => {
|
||||
expect(resolveRepoRouteHandoffId(snapshot, "repo-a")).toBe("handoff-a");
|
||||
describe("resolveRepoRouteTaskId", () => {
|
||||
it("finds the active task for a repo route", () => {
|
||||
expect(resolveRepoRouteTaskId(snapshot, "repo-a")).toBe("task-a");
|
||||
});
|
||||
|
||||
it("returns null when a repo has no handoff yet", () => {
|
||||
expect(resolveRepoRouteHandoffId(snapshot, "repo-b")).toBeNull();
|
||||
it("returns null when a repo has no task yet", () => {
|
||||
expect(resolveRepoRouteTaskId(snapshot, "repo-b")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { HandoffWorkbenchClient } from "@sandbox-agent/factory-client/workbench";
|
||||
import type { TaskWorkbenchClient } from "@sandbox-agent/factory-client/workbench";
|
||||
import { createWorkbenchRuntimeClient } from "@workbench-runtime";
|
||||
import { frontendClientMode } from "./env";
|
||||
export { resolveRepoRouteHandoffId } from "./workbench-routing";
|
||||
export { resolveRepoRouteTaskId } from "./workbench-routing";
|
||||
|
||||
const workbenchClientCache = new Map<string, HandoffWorkbenchClient>();
|
||||
const workbenchClientCache = new Map<string, TaskWorkbenchClient>();
|
||||
|
||||
export function getHandoffWorkbenchClient(workspaceId: string): HandoffWorkbenchClient {
|
||||
export function getTaskWorkbenchClient(workspaceId: string): TaskWorkbenchClient {
|
||||
const cacheKey = `${frontendClientMode}:${workspaceId}`;
|
||||
const existing = workbenchClientCache.get(cacheKey);
|
||||
if (existing) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue