mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 22:01:43 +00:00
Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation
- Fix org display name using GitHub description instead of name field - Fix createWorkbenchSession hanging when sandbox is provisioning - Fix auto-session creation retry storm on errors - Fix task creation using wrong repo due to React state race conditions - Remove Bun hot-reload from backend Dockerfile (causes port drift) - Add GitHub sync/install status to dev panel Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
14d5413f8a
commit
689d968397
17 changed files with 2569 additions and 479 deletions
|
|
@ -3,7 +3,6 @@ import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/
|
|||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { DevPanel } from "../components/dev-panel";
|
||||
import { MockLayout } from "../components/mock-layout";
|
||||
import {
|
||||
MockAccountSettingsPage,
|
||||
|
|
@ -346,7 +345,6 @@ function RootLayout() {
|
|||
<>
|
||||
<RouteContextSync />
|
||||
<Outlet />
|
||||
<DevPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,304 +1,379 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouterState } from "@tanstack/react-router";
|
||||
import { Bug, RefreshCw, Wifi } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useStyletron } from "baseui";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
import { isMockFrontendClient } from "../lib/env";
|
||||
import { activeMockOrganization, activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import type { FoundryOrganization, TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
const DEV_PANEL_STORAGE_KEY = "sandbox-agent-foundry:dev-panel-visible";
|
||||
interface DevPanelProps {
|
||||
workspaceId: string;
|
||||
snapshot: TaskWorkbenchSnapshot;
|
||||
organization?: FoundryOrganization | null;
|
||||
}
|
||||
|
||||
function readStoredVisibility(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const stored = window.localStorage.getItem(DEV_PANEL_STORAGE_KEY);
|
||||
return stored == null ? true : stored === "true";
|
||||
} catch {
|
||||
return true;
|
||||
interface TopicInfo {
|
||||
label: string;
|
||||
key: string;
|
||||
listenerCount: number;
|
||||
hasConnection: boolean;
|
||||
lastRefresh: number | null;
|
||||
}
|
||||
|
||||
function timeAgo(ts: number | null): string {
|
||||
if (!ts) return "never";
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||
if (seconds < 5) return "now";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
return `${Math.floor(minutes / 60)}h`;
|
||||
}
|
||||
|
||||
function taskStatusLabel(task: WorkbenchTask): string {
|
||||
if (task.status === "archived") return "archived";
|
||||
const hasRunning = task.tabs?.some((tab) => tab.status === "running");
|
||||
if (hasRunning) return "running";
|
||||
return task.status ?? "idle";
|
||||
}
|
||||
|
||||
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return t.statusSuccess;
|
||||
case "archived":
|
||||
return t.textMuted;
|
||||
case "error":
|
||||
case "failed":
|
||||
return t.statusError;
|
||||
default:
|
||||
return t.textTertiary;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredVisibility(value: boolean): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(DEV_PANEL_STORAGE_KEY, String(value));
|
||||
} catch {
|
||||
// ignore
|
||||
function syncStatusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||
switch (status) {
|
||||
case "synced":
|
||||
return t.statusSuccess;
|
||||
case "syncing":
|
||||
case "pending":
|
||||
return t.statusWarning;
|
||||
case "error":
|
||||
return t.statusError;
|
||||
default:
|
||||
return t.textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
function sectionStyle(borderColor: string, background: string) {
|
||||
return {
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
padding: "12px",
|
||||
borderRadius: "12px",
|
||||
border: `1px solid ${borderColor}`,
|
||||
background,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function labelStyle(color: string) {
|
||||
return {
|
||||
fontSize: "11px",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase" as const,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
function mergedRouteParams(matches: Array<{ params: Record<string, unknown> }>): Record<string, string> {
|
||||
return matches.reduce<Record<string, string>>((acc, match) => {
|
||||
for (const [key, value] of Object.entries(match.params)) {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
acc[key] = value;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function DevPanel() {
|
||||
if (!import.meta.env.DEV) {
|
||||
return null;
|
||||
function installStatusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return t.statusSuccess;
|
||||
case "install_required":
|
||||
return t.statusWarning;
|
||||
case "reconnect_required":
|
||||
return t.statusError;
|
||||
default:
|
||||
return t.textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
const client = useMockAppClient();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = activeMockOrganization(snapshot);
|
||||
const user = activeMockUser(snapshot);
|
||||
const organizations = eligibleOrganizations(snapshot);
|
||||
export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organization }: DevPanelProps) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const routeContext = useRouterState({
|
||||
select: (state) => ({
|
||||
location: state.location,
|
||||
params: mergedRouteParams(state.matches as Array<{ params: Record<string, unknown> }>),
|
||||
}),
|
||||
});
|
||||
const [visible, setVisible] = useState<boolean>(() => readStoredVisibility());
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// Tick every 2s to keep relative timestamps fresh
|
||||
useEffect(() => {
|
||||
writeStoredVisibility(visible);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.shiftKey && event.key.toLowerCase() === "d") {
|
||||
event.preventDefault();
|
||||
setVisible((current) => !current);
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
const id = setInterval(() => setNow(Date.now()), 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const modeLabel = isMockFrontendClient ? "Mock" : "Live";
|
||||
const selectedWorkspaceId = routeContext.params.workspaceId ?? null;
|
||||
const selectedTaskId = routeContext.params.taskId ?? null;
|
||||
const selectedRepoId = routeContext.params.repoId ?? null;
|
||||
const selectedSessionId =
|
||||
routeContext.location.search && typeof routeContext.location.search === "object" && "sessionId" in routeContext.location.search
|
||||
? (((routeContext.location.search as Record<string, unknown>).sessionId as string | undefined) ?? null)
|
||||
: null;
|
||||
const contextOrganization =
|
||||
(routeContext.params.organizationId ? (snapshot.organizations.find((candidate) => candidate.id === routeContext.params.organizationId) ?? null) : null) ??
|
||||
(selectedWorkspaceId ? (snapshot.organizations.find((candidate) => candidate.workspaceId === selectedWorkspaceId) ?? null) : null) ??
|
||||
organization;
|
||||
const github = contextOrganization?.github ?? null;
|
||||
const topics = useMemo((): TopicInfo[] => {
|
||||
const items: TopicInfo[] = [];
|
||||
|
||||
const pillButtonStyle = useCallback(
|
||||
(active = false) =>
|
||||
({
|
||||
border: `1px solid ${active ? t.accent : t.borderDefault}`,
|
||||
background: active ? t.surfacePrimary : t.surfaceSecondary,
|
||||
color: t.textPrimary,
|
||||
borderRadius: "999px",
|
||||
padding: "6px 10px",
|
||||
fontSize: "11px",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}) as const,
|
||||
[t],
|
||||
);
|
||||
// Workbench subscription topic
|
||||
items.push({
|
||||
label: "Workbench",
|
||||
key: `ws:${workspaceId}`,
|
||||
listenerCount: 1,
|
||||
hasConnection: true,
|
||||
lastRefresh: now,
|
||||
});
|
||||
|
||||
if (!visible) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(true)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: "16px",
|
||||
bottom: "16px",
|
||||
zIndex: 1000,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
background: "rgba(9, 9, 11, 0.78)",
|
||||
color: t.textPrimary,
|
||||
borderRadius: "999px",
|
||||
padding: "9px 12px",
|
||||
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.22)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Bug size={14} />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "8px", fontSize: "12px", lineHeight: 1 }}>
|
||||
<span style={{ color: t.textSecondary }}>Show Dev Panel</span>
|
||||
<span
|
||||
style={{
|
||||
padding: "4px 7px",
|
||||
borderRadius: "999px",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
fontSize: "11px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.03em",
|
||||
}}
|
||||
>
|
||||
Shift+D
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
// Per-task tab subscriptions
|
||||
for (const task of snapshot.tasks ?? []) {
|
||||
if (task.status === "archived") continue;
|
||||
for (const tab of task.tabs ?? []) {
|
||||
items.push({
|
||||
label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`,
|
||||
key: `${workspaceId}:${task.id}:${tab.id}`,
|
||||
listenerCount: 1,
|
||||
hasConnection: tab.status === "running",
|
||||
lastRefresh: tab.status === "running" ? now : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [workspaceId, snapshot, now]);
|
||||
|
||||
const tasks = snapshot.tasks ?? [];
|
||||
const repos = snapshot.repos ?? [];
|
||||
const projects = snapshot.projects ?? [];
|
||||
|
||||
const mono = css({
|
||||
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
|
||||
fontSize: "10px",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
className={css({
|
||||
position: "fixed",
|
||||
right: "16px",
|
||||
bottom: "16px",
|
||||
width: "360px",
|
||||
maxHeight: "calc(100vh - 32px)",
|
||||
overflowY: "auto",
|
||||
zIndex: 1000,
|
||||
borderRadius: "18px",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
background: t.surfacePrimary,
|
||||
color: t.textPrimary,
|
||||
boxShadow: "0 24px 60px rgba(0, 0, 0, 0.35)",
|
||||
}}
|
||||
bottom: "8px",
|
||||
right: "8px",
|
||||
width: "320px",
|
||||
maxHeight: "50vh",
|
||||
zIndex: 99999,
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.borderMedium}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "14px 16px",
|
||||
borderBottom: `1px solid ${t.borderDefault}`,
|
||||
background: t.surfacePrimary,
|
||||
}}
|
||||
padding: "4px 8px",
|
||||
borderBottom: `1px solid ${t.borderSubtle}`,
|
||||
backgroundColor: t.surfaceTertiary,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div style={{ display: "grid", gap: "2px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<Bug size={14} />
|
||||
<strong style={{ fontSize: "13px" }}>Dev Panel</strong>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: t.textMuted,
|
||||
}}
|
||||
>
|
||||
{modeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: t.textMuted }}>{routeContext.location.pathname}</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => setVisible(false)} style={pillButtonStyle()}>
|
||||
Hide
|
||||
</button>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
color: t.textSecondary,
|
||||
letterSpacing: "0.5px",
|
||||
textTransform: "uppercase",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
})}
|
||||
>
|
||||
Dev
|
||||
{isMockFrontendClient && <span className={css({ fontSize: "8px", fontWeight: 600, color: t.statusWarning, letterSpacing: "0.3px" })}>MOCK</span>}
|
||||
</span>
|
||||
<span className={css({ fontSize: "9px", color: t.textMuted })}>Shift+D</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "12px", padding: "14px" }}>
|
||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||
<div style={labelStyle(t.textMuted)}>Context</div>
|
||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||
<div>Organization: {contextOrganization?.settings.displayName ?? "None selected"}</div>
|
||||
<div>Workspace: {selectedWorkspaceId ?? "None selected"}</div>
|
||||
<div>Task: {selectedTaskId ?? "None selected"}</div>
|
||||
<div>Repo: {selectedRepoId ?? "None selected"}</div>
|
||||
<div>Session: {selectedSessionId ?? "None selected"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className={css({ overflowY: "auto", padding: "6px" })}>
|
||||
{/* Interest Topics */}
|
||||
<Section label="Interest Topics" t={t} css={css}>
|
||||
{topics.map((topic) => (
|
||||
<div
|
||||
key={topic.key}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "2px 0",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: topic.hasConnection ? t.statusSuccess : t.textMuted,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||
{topic.label}
|
||||
</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key}</span>
|
||||
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(topic.lastRefresh)}</span>
|
||||
</div>
|
||||
))}
|
||||
{topics.length === 0 && <span className={css({ fontSize: "10px", color: t.textMuted })}>No active subscriptions</span>}
|
||||
</Section>
|
||||
|
||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||
<div style={labelStyle(t.textMuted)}>Session</div>
|
||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||
<div>Auth: {snapshot.auth.status}</div>
|
||||
<div>User: {user ? `${user.name} (@${user.githubLogin})` : "None"}</div>
|
||||
<div>Active org: {organization?.settings.displayName ?? "None selected"}</div>
|
||||
{/* Snapshot Summary */}
|
||||
<Section label="Snapshot" t={t} css={css}>
|
||||
<div className={css({ display: "flex", gap: "10px", fontSize: "10px" })}>
|
||||
<Stat label="repos" value={repos.length} t={t} css={css} />
|
||||
<Stat label="projects" value={projects.length} t={t} css={css} />
|
||||
<Stat label="tasks" value={tasks.length} t={t} css={css} />
|
||||
</div>
|
||||
{isMockFrontendClient ? (
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
{snapshot.auth.status === "signed_in" ? (
|
||||
<button type="button" onClick={() => void client.signOut()} style={pillButtonStyle()}>
|
||||
Sign out
|
||||
</button>
|
||||
) : (
|
||||
snapshot.users.map((candidate) => (
|
||||
<button key={candidate.id} type="button" onClick={() => void client.signInWithGithub(candidate.id)} style={pillButtonStyle()}>
|
||||
Sign in as {candidate.githubLogin}
|
||||
</button>
|
||||
))
|
||||
</Section>
|
||||
|
||||
{/* Tasks */}
|
||||
{tasks.length > 0 && (
|
||||
<Section label="Tasks" t={t} css={css}>
|
||||
{tasks.slice(0, 10).map((task) => {
|
||||
const status = taskStatusLabel(task);
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "1px 0",
|
||||
fontSize: "10px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: statusColor(status, t),
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||
{task.title || task.id.slice(0, 12)}
|
||||
</span>
|
||||
<span className={`${mono} ${css({ color: statusColor(status, t) })}`}>{status}</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{task.tabs?.length ?? 0} tabs</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* GitHub */}
|
||||
{organization && (
|
||||
<Section label="GitHub" t={t} css={css}>
|
||||
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: installStatusColor(organization.github.installationStatus, t),
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>App</span>
|
||||
<span className={`${mono} ${css({ color: installStatusColor(organization.github.installationStatus, t) })}`}>
|
||||
{organization.github.installationStatus.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: syncStatusColor(organization.github.syncStatus, t),
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
|
||||
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
|
||||
</div>
|
||||
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
||||
<Stat label="repos imported" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||
</div>
|
||||
{organization.github.connectedAccount && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
|
||||
)}
|
||||
{organization.github.lastSyncLabel && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {organization.github.lastSyncLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||
<div style={labelStyle(t.textMuted)}>GitHub</div>
|
||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||
<div>Installation: {github?.installationStatus ?? "n/a"}</div>
|
||||
<div>Sync: {github?.syncStatus ?? "n/a"}</div>
|
||||
<div>Repos: {github?.importedRepoCount ?? 0}</div>
|
||||
<div>Last sync: {github?.lastSyncLabel ?? "n/a"}</div>
|
||||
</div>
|
||||
{contextOrganization ? (
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
<button type="button" onClick={() => void client.triggerGithubSync(contextOrganization.id)} style={pillButtonStyle()}>
|
||||
<RefreshCw size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||
Sync
|
||||
</button>
|
||||
<button type="button" onClick={() => void client.reconnectGithub(contextOrganization.id)} style={pillButtonStyle()}>
|
||||
<Wifi size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||
Reconnect
|
||||
</button>
|
||||
{/* Workspace */}
|
||||
<Section label="Workspace" t={t} css={css}>
|
||||
<div className={`${mono} ${css({ color: t.textTertiary })}`}>{workspaceId}</div>
|
||||
{organization && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "2px" })}`}>
|
||||
org: {organization.settings.displayName} ({organization.kind})
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isMockFrontendClient && organizations.length > 0 ? (
|
||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||
<div style={labelStyle(t.textMuted)}>Mock Organization</div>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
||||
{organizations.map((candidate) => (
|
||||
<button
|
||||
key={candidate.id}
|
||||
type="button"
|
||||
onClick={() => void client.selectOrganization(candidate.id)}
|
||||
style={pillButtonStyle(contextOrganization?.id === candidate.id)}
|
||||
>
|
||||
{candidate.settings.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Section({
|
||||
label,
|
||||
t,
|
||||
css: cssFn,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cssFn({ marginBottom: "6px" })}>
|
||||
<div
|
||||
className={cssFn({
|
||||
fontSize: "9px",
|
||||
fontWeight: 600,
|
||||
color: t.textMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
marginBottom: "2px",
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
t,
|
||||
css: cssFn,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
}) {
|
||||
return (
|
||||
<span>
|
||||
<span className={cssFn({ fontWeight: 600, color: t.textPrimary })}>{value}</span>
|
||||
<span className={cssFn({ color: t.textTertiary, marginLeft: "2px" })}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDevPanel() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.shiftKey && e.key === "D" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
e.preventDefault();
|
||||
setVisible((prev) => !prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useStyletron } from "baseui";
|
||||
import { createErrorContext, type WorkbenchSessionSummary, type WorkbenchTaskDetail, type WorkbenchTaskSummary } from "@sandbox-agent/foundry-shared";
|
||||
import {
|
||||
createErrorContext,
|
||||
type TaskWorkbenchSnapshot,
|
||||
type WorkbenchSessionSummary,
|
||||
type WorkbenchTaskDetail,
|
||||
type WorkbenchTaskSummary,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
|
||||
import { PanelLeft, PanelRight } from "lucide-react";
|
||||
|
|
@ -1085,6 +1091,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
|
||||
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
|
||||
const showDevPanel = useDevPanel();
|
||||
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const startPeek = useCallback(() => {
|
||||
|
|
@ -1269,35 +1276,38 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
},
|
||||
"failed_to_auto_create_workbench_session",
|
||||
);
|
||||
} finally {
|
||||
autoCreatingSessionForTaskRef.current.delete(activeTask.id);
|
||||
// Keep the guard in the set on error to prevent retry storms.
|
||||
// The guard is cleared when tabs appear (line above) or the task changes.
|
||||
}
|
||||
})();
|
||||
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
||||
|
||||
const createTask = useCallback(() => {
|
||||
void (async () => {
|
||||
const repoId = selectedNewTaskRepoId;
|
||||
if (!repoId) {
|
||||
throw new Error("Cannot create a task without an available repo");
|
||||
}
|
||||
const createTask = useCallback(
|
||||
(overrideRepoId?: string) => {
|
||||
void (async () => {
|
||||
const repoId = overrideRepoId || selectedNewTaskRepoId;
|
||||
if (!repoId) {
|
||||
throw new Error("Cannot create a task without an available repo");
|
||||
}
|
||||
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId,
|
||||
task: "New task",
|
||||
model: "gpt-4o",
|
||||
title: "New task",
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId,
|
||||
},
|
||||
search: { sessionId: tabId ?? undefined },
|
||||
});
|
||||
})();
|
||||
}, [navigate, selectedNewTaskRepoId, workspaceId]);
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId,
|
||||
task: "New task",
|
||||
model: "gpt-4o",
|
||||
title: "New task",
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId,
|
||||
},
|
||||
search: { sessionId: tabId ?? undefined },
|
||||
});
|
||||
})();
|
||||
},
|
||||
[navigate, selectedNewTaskRepoId, workspaceId],
|
||||
);
|
||||
|
||||
const openDiffTab = useCallback(
|
||||
(path: string) => {
|
||||
|
|
@ -1509,7 +1519,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
transition: sidebarTransition,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={workspaceRepos}
|
||||
|
|
@ -1565,29 +1575,63 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
{workspaceRepos.length > 0
|
||||
? "Start from the sidebar to create a task on the first available repo."
|
||||
: "No repos are available in this workspace yet."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createTask}
|
||||
disabled={workspaceRepos.length === 0}
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
border: 0,
|
||||
borderRadius: "999px",
|
||||
padding: "10px 18px",
|
||||
background: workspaceRepos.length > 0 ? t.borderMedium : t.textTertiary,
|
||||
color: t.textPrimary,
|
||||
cursor: workspaceRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
New task
|
||||
</button>
|
||||
{activeOrg?.github.syncStatus === "syncing" || activeOrg?.github.syncStatus === "pending" ? (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
border: `2px solid ${t.borderSubtle}`,
|
||||
borderTopColor: t.textSecondary,
|
||||
borderRadius: "50%",
|
||||
animationName: {
|
||||
from: { transform: "rotate(0deg)" },
|
||||
to: { transform: "rotate(360deg)" },
|
||||
} as unknown as string,
|
||||
animationDuration: "0.8s",
|
||||
animationIterationCount: "infinite",
|
||||
animationTimingFunction: "linear",
|
||||
alignSelf: "center",
|
||||
})}
|
||||
/>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Syncing with GitHub</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
Importing repos from @{activeOrg.github.connectedAccount || "GitHub"}...
|
||||
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
|
||||
</p>
|
||||
</>
|
||||
) : activeOrg?.github.syncStatus === "error" ? (
|
||||
<>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>There was a problem syncing repos from GitHub. Check the dev panel for details.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
{workspaceRepos.length > 0
|
||||
? "Start from the sidebar to create a task on the first available repo."
|
||||
: "No repos are available in this workspace yet."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createTask()}
|
||||
disabled={workspaceRepos.length === 0}
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
border: 0,
|
||||
borderRadius: "999px",
|
||||
padding: "10px 18px",
|
||||
background: workspaceRepos.length > 0 ? t.borderMedium : t.textTertiary,
|
||||
color: t.textPrimary,
|
||||
cursor: workspaceRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
New task
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollBody>
|
||||
|
|
@ -1610,6 +1654,47 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
fontSize: "11px",
|
||||
color: t.textPrimary,
|
||||
maxWidth: "360px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.statusError,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showDevPanel && (
|
||||
<DevPanel
|
||||
workspaceId={workspaceId}
|
||||
snapshot={{ workspaceId, repos: workspaceRepos, projects: rawProjects, tasks } as TaskWorkbenchSnapshot}
|
||||
organization={activeOrg}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1629,7 +1714,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
transition: sidebarTransition,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={workspaceRepos}
|
||||
|
|
@ -1760,7 +1845,47 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showDevPanel && <DevPanel workspaceId={workspaceId} snapshot={viewModel} />}
|
||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
fontSize: "11px",
|
||||
color: t.textPrimary,
|
||||
maxWidth: "360px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.statusError,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showDevPanel && (
|
||||
<DevPanel
|
||||
workspaceId={workspaceId}
|
||||
snapshot={{ workspaceId, repos: workspaceRepos, projects: rawProjects, tasks } as TaskWorkbenchSnapshot}
|
||||
organization={activeOrg}
|
||||
/>
|
||||
)}
|
||||
</Shell>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useStyletron } from "baseui";
|
||||
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
||||
import { Select, type Value } from "baseui/select";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
|
|
@ -26,6 +27,17 @@ import type { FoundryTokens } from "../../styles/tokens";
|
|||
|
||||
const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
||||
|
||||
/** Strip the org prefix (e.g. "rivet-dev/") when all repos share the same org. */
|
||||
function stripCommonOrgPrefix(label: string, repos: Array<{ label: string }>): string {
|
||||
const slashIdx = label.indexOf("/");
|
||||
if (slashIdx < 0) return label;
|
||||
const prefix = label.slice(0, slashIdx + 1);
|
||||
if (repos.every((r) => r.label.startsWith(prefix))) {
|
||||
return label.slice(slashIdx + 1);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function projectInitial(label: string): string {
|
||||
const parts = label.split("/");
|
||||
const name = parts[parts.length - 1] ?? label;
|
||||
|
|
@ -61,7 +73,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
selectedNewTaskRepoId: string;
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onCreate: (repoId?: string) => void;
|
||||
onSelectNewTaskRepo: (repoId: string) => void;
|
||||
onMarkUnread: (id: string) => void;
|
||||
onRenameTask: (id: string) => void;
|
||||
|
|
@ -137,19 +149,8 @@ export const Sidebar = memo(function Sidebar({
|
|||
};
|
||||
}, [drag, onReorderProjects, onReorderTasks]);
|
||||
|
||||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||||
const createMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createMenuOpen) return;
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (createMenuRef.current && !createMenuRef.current.contains(event.target as Node)) {
|
||||
setCreateMenuOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [createMenuOpen]);
|
||||
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
||||
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
|
||||
|
||||
return (
|
||||
<SPanel>
|
||||
|
|
@ -232,7 +233,99 @@ export const Sidebar = memo(function Sidebar({
|
|||
<PanelLeft size={14} />
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={createMenuRef} className={css({ position: "relative", flexShrink: 0 })}>
|
||||
{createSelectOpen ? (
|
||||
<div className={css({ flex: 1, minWidth: 0 })}>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
value={[]}
|
||||
placeholder="Search repos..."
|
||||
type="search"
|
||||
openOnClick
|
||||
autoFocus
|
||||
onChange={({ value }: { value: Value }) => {
|
||||
const selected = value[0];
|
||||
if (selected) {
|
||||
onSelectNewTaskRepo(selected.id as string);
|
||||
setCreateSelectOpen(false);
|
||||
onCreate(selected.id as string);
|
||||
}
|
||||
}}
|
||||
onClose={() => setCreateSelectOpen(false)}
|
||||
overrides={{
|
||||
Root: {
|
||||
style: {
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
ControlContainer: {
|
||||
style: {
|
||||
backgroundColor: t.surfaceTertiary,
|
||||
borderTopColor: t.borderSubtle,
|
||||
borderBottomColor: t.borderSubtle,
|
||||
borderLeftColor: t.borderSubtle,
|
||||
borderRightColor: t.borderSubtle,
|
||||
borderTopWidth: "1px",
|
||||
borderBottomWidth: "1px",
|
||||
borderLeftWidth: "1px",
|
||||
borderRightWidth: "1px",
|
||||
borderTopLeftRadius: "6px",
|
||||
borderTopRightRadius: "6px",
|
||||
borderBottomLeftRadius: "6px",
|
||||
borderBottomRightRadius: "6px",
|
||||
minHeight: "28px",
|
||||
paddingLeft: "8px",
|
||||
},
|
||||
},
|
||||
ValueContainer: {
|
||||
style: {
|
||||
paddingTop: "0px",
|
||||
paddingBottom: "0px",
|
||||
},
|
||||
},
|
||||
Input: {
|
||||
style: {
|
||||
fontSize: "12px",
|
||||
color: t.textPrimary,
|
||||
},
|
||||
},
|
||||
Placeholder: {
|
||||
style: {
|
||||
fontSize: "12px",
|
||||
color: t.textMuted,
|
||||
},
|
||||
},
|
||||
Dropdown: {
|
||||
style: {
|
||||
backgroundColor: t.surfaceElevated,
|
||||
borderTopColor: t.borderDefault,
|
||||
borderBottomColor: t.borderDefault,
|
||||
borderLeftColor: t.borderDefault,
|
||||
borderRightColor: t.borderDefault,
|
||||
maxHeight: "min(320px, 50vh)",
|
||||
},
|
||||
},
|
||||
DropdownListItem: {
|
||||
style: {
|
||||
fontSize: "12px",
|
||||
paddingTop: "6px",
|
||||
paddingBottom: "6px",
|
||||
},
|
||||
},
|
||||
IconsContainer: {
|
||||
style: {
|
||||
paddingRight: "4px",
|
||||
},
|
||||
},
|
||||
SearchIconContainer: {
|
||||
style: {
|
||||
paddingLeft: "0px",
|
||||
paddingRight: "4px",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
|
@ -241,9 +334,9 @@ export const Sidebar = memo(function Sidebar({
|
|||
if (newTaskRepos.length === 0) return;
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate();
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateMenuOpen((prev) => !prev);
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
|
|
@ -251,9 +344,9 @@ export const Sidebar = memo(function Sidebar({
|
|||
if (event.key === "Enter" || event.key === " ") {
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate();
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateMenuOpen((prev) => !prev);
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
@ -275,80 +368,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
>
|
||||
<Plus size={14} style={{ display: "block" }} />
|
||||
</div>
|
||||
{createMenuOpen && newTaskRepos.length > 1 ? (
|
||||
<div
|
||||
className={css({
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
right: 0,
|
||||
marginTop: "4px",
|
||||
zIndex: 9999,
|
||||
minWidth: "200px",
|
||||
borderRadius: "10px",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
backgroundColor: t.surfaceElevated,
|
||||
boxShadow: `${t.shadow}, 0 0 0 1px ${t.interactiveSubtle}`,
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
maxHeight: "240px",
|
||||
overflowY: "auto",
|
||||
})}
|
||||
>
|
||||
{newTaskRepos.map((repo) => (
|
||||
<button
|
||||
key={repo.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectNewTaskRepo(repo.id);
|
||||
setCreateMenuOpen(false);
|
||||
onCreate();
|
||||
}}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: t.textSecondary,
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: 400,
|
||||
textAlign: "left",
|
||||
transition: "background 200ms ease, color 200ms ease",
|
||||
":hover": {
|
||||
backgroundColor: t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "4px",
|
||||
background: `linear-gradient(135deg, ${projectIconColor(repo.label)}, ${projectIconColor(repo.label + "x")})`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "9px",
|
||||
fontWeight: 700,
|
||||
color: t.textOnAccent,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{projectInitial(repo.label)}
|
||||
</span>
|
||||
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>{repo.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</PanelHeaderBar>
|
||||
<ScrollBody>
|
||||
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
|
||||
|
|
@ -458,7 +478,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{project.label}
|
||||
{stripCommonOrgPrefix(project.label, projects)}
|
||||
</LabelSmall>
|
||||
</div>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||
|
|
@ -468,7 +488,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
e.stopPropagation();
|
||||
setHoveredProjectId(null);
|
||||
onSelectNewTaskRepo(project.id);
|
||||
onCreate();
|
||||
onCreate(project.id);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={css({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue