Finalize Foundry sync flow

This commit is contained in:
Nathan Flurry 2026-03-12 17:19:26 -07:00
parent 5c70cbcd23
commit 1c852cc5f8
14 changed files with 768 additions and 187 deletions

View file

@ -51,6 +51,17 @@ function labelStyle(color: string) {
};
}
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;
@ -62,7 +73,12 @@ export function DevPanel() {
const user = activeMockUser(snapshot);
const organizations = eligibleOrganizations(snapshot);
const t = useFoundryTokens();
const location = useRouterState({ select: (state) => state.location });
const routeContext = useRouterState({
select: (state) => ({
location: state.location,
params: mergedRouteParams(state.matches as Array<{ params: Record<string, unknown> }>),
}),
});
const [visible, setVisible] = useState<boolean>(() => readStoredVisibility());
useEffect(() => {
@ -84,8 +100,19 @@ export function DevPanel() {
}, []);
const modeLabel = isMockFrontendClient ? "Mock" : "Live";
const github = organization?.github ?? null;
const runtime = organization?.runtime ?? null;
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 runtime = contextOrganization?.runtime ?? null;
const runtimeSummary = useMemo(() => {
if (!runtime || runtime.errorCount === 0) {
return "No actor errors";
@ -122,16 +149,31 @@ export function DevPanel() {
alignItems: "center",
gap: "8px",
border: `1px solid ${t.borderDefault}`,
background: t.surfacePrimary,
background: "rgba(9, 9, 11, 0.78)",
color: t.textPrimary,
borderRadius: "999px",
padding: "10px 14px",
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.28)",
padding: "9px 12px",
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.22)",
cursor: "pointer",
}}
>
<Bug size={14} />
Dev
<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>
);
}
@ -181,7 +223,7 @@ export function DevPanel() {
{modeLabel}
</span>
</div>
<div style={{ fontSize: "11px", color: t.textMuted }}>{location.pathname}</div>
<div style={{ fontSize: "11px", color: t.textMuted }}>{routeContext.location.pathname}</div>
</div>
<button type="button" onClick={() => setVisible(false)} style={pillButtonStyle()}>
Hide
@ -189,12 +231,23 @@ export function DevPanel() {
</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>
<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>Organization: {organization?.settings.displayName ?? "None selected"}</div>
<div>Active org: {organization?.settings.displayName ?? "None selected"}</div>
</div>
{isMockFrontendClient ? (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
@ -221,26 +274,26 @@ export function DevPanel() {
<div>Repos: {github?.importedRepoCount ?? 0}</div>
<div>Last sync: {github?.lastSyncLabel ?? "n/a"}</div>
</div>
{organization ? (
{contextOrganization ? (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={pillButtonStyle()}>
<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(organization.id)} style={pillButtonStyle()}>
<button type="button" onClick={() => void client.reconnectGithub(contextOrganization.id)} style={pillButtonStyle()}>
<Wifi size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
Reconnect
</button>
</div>
) : null}
{isMockFrontendClient && organization && client.setMockDebugOrganizationState ? (
{isMockFrontendClient && contextOrganization && client.setMockDebugOrganizationState ? (
<div style={{ display: "grid", gap: "8px" }}>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{(["pending", "syncing", "synced", "error"] as const).map((status) => (
<button
key={status}
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubSyncStatus: status })}
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: contextOrganization.id, githubSyncStatus: status })}
style={pillButtonStyle(github?.syncStatus === status)}
>
{status}
@ -252,7 +305,7 @@ export function DevPanel() {
<button
key={status}
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubInstallationStatus: status })}
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: contextOrganization.id, githubInstallationStatus: status })}
style={pillButtonStyle(github?.installationStatus === status)}
>
{status}
@ -270,13 +323,13 @@ export function DevPanel() {
<div>{runtimeSummary}</div>
{runtime?.issues[0] ? <div>Latest: {runtime.issues[0].message}</div> : null}
</div>
{organization ? (
{contextOrganization ? (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{isMockFrontendClient && client.setMockDebugOrganizationState ? (
<>
<button
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "error" })}
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: contextOrganization.id, runtimeStatus: "error" })}
style={pillButtonStyle(runtime?.status === "error")}
>
<ShieldAlert size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
@ -284,7 +337,7 @@ export function DevPanel() {
</button>
<button
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "healthy" })}
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: contextOrganization.id, runtimeStatus: "healthy" })}
style={pillButtonStyle(runtime?.status === "healthy")}
>
Healthy
@ -292,7 +345,7 @@ export function DevPanel() {
</>
) : null}
{runtime?.errorCount ? (
<button type="button" onClick={() => void client.clearOrganizationRuntimeIssues(organization.id)} style={pillButtonStyle()}>
<button type="button" onClick={() => void client.clearOrganizationRuntimeIssues(contextOrganization.id)} style={pillButtonStyle()}>
Clear actor errors
</button>
) : null}
@ -309,7 +362,7 @@ export function DevPanel() {
key={candidate.id}
type="button"
onClick={() => void client.selectOrganization(candidate.id)}
style={pillButtonStyle(organization?.id === candidate.id)}
style={pillButtonStyle(contextOrganization?.id === candidate.id)}
>
{candidate.settings.displayName}
</button>

View file

@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
ChevronUp,
@ -14,6 +15,7 @@ import {
LogOut,
PanelLeft,
Plus,
RefreshCw,
Settings,
User,
} from "lucide-react";
@ -21,6 +23,7 @@ import {
import { formatRelativeAge, type Task, type ProjectSection } from "./view-model";
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app";
import { getMockOrganizationStatus } from "../../lib/mock-organization-status";
import { useFoundryTokens } from "../../app/theme";
import type { FoundryTokens } from "../../styles/tokens";
@ -40,6 +43,28 @@ function projectIconColor(label: string): string {
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
}
function organizationStatusToneStyles(tokens: FoundryTokens, tone: "info" | "warning" | "error") {
if (tone === "error") {
return {
backgroundColor: "rgba(255, 79, 0, 0.14)",
borderColor: "rgba(255, 79, 0, 0.3)",
color: "#ffd6c7",
};
}
if (tone === "warning") {
return {
backgroundColor: "rgba(255, 193, 7, 0.16)",
borderColor: "rgba(255, 193, 7, 0.24)",
color: "#ffe6a6",
};
}
return {
backgroundColor: "rgba(24, 140, 255, 0.16)",
borderColor: "rgba(24, 140, 255, 0.24)",
color: "#b9d8ff",
};
}
export const Sidebar = memo(function Sidebar({
projects,
newTaskRepos,
@ -694,6 +719,7 @@ function SidebarFooter() {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const organization = activeMockOrganization(snapshot);
const organizationStatus = organization ? getMockOrganizationStatus(organization) : null;
const [open, setOpen] = useState(false);
const [workspaceFlyoutOpen, setWorkspaceFlyoutOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@ -802,6 +828,41 @@ function SidebarFooter() {
gap: "2px",
});
const statusChipClass =
organizationStatus != null
? css({
display: "inline-flex",
alignItems: "center",
gap: "6px",
flexShrink: 0,
padding: "4px 7px",
borderRadius: "999px",
border: `1px solid ${organizationStatusToneStyles(t, organizationStatus.tone).borderColor}`,
backgroundColor: organizationStatusToneStyles(t, organizationStatus.tone).backgroundColor,
color: organizationStatusToneStyles(t, organizationStatus.tone).color,
fontSize: "10px",
fontWeight: 600,
lineHeight: 1,
})
: "";
const footerStatusClass =
organizationStatus != null
? css({
margin: "0 8px 4px",
padding: "8px 10px",
borderRadius: "10px",
border: `1px solid ${organizationStatusToneStyles(t, organizationStatus.tone).borderColor}`,
backgroundColor: organizationStatusToneStyles(t, organizationStatus.tone).backgroundColor,
color: organizationStatusToneStyles(t, organizationStatus.tone).color,
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "11px",
lineHeight: 1.3,
})
: "";
return (
<div ref={containerRef} className={css({ position: "relative", flexShrink: 0 })}>
{open ? (
@ -851,6 +912,7 @@ function SidebarFooter() {
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{organization.settings.displayName}
</span>
{organizationStatus ? <span className={statusChipClass}>{organizationStatus.label}</span> : null}
<ChevronRight size={12} className={css({ flexShrink: 0, color: t.textMuted })} />
</button>
</div>
@ -919,6 +981,30 @@ function SidebarFooter() {
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{org.settings.displayName}
</span>
{(() => {
const orgStatus = getMockOrganizationStatus(org);
if (!orgStatus) return null;
const tone = organizationStatusToneStyles(t, orgStatus.tone);
return (
<span
className={css({
display: "inline-flex",
alignItems: "center",
padding: "4px 7px",
borderRadius: "999px",
border: `1px solid ${tone.borderColor}`,
backgroundColor: tone.backgroundColor,
color: tone.color,
fontSize: "10px",
fontWeight: 600,
lineHeight: 1,
flexShrink: 0,
})}
>
{orgStatus.label}
</span>
);
})()}
</button>
);
})}
@ -949,6 +1035,15 @@ function SidebarFooter() {
</div>
</div>
) : null}
{organizationStatus ? (
<div className={footerStatusClass}>
{organizationStatus.key === "syncing" ? <RefreshCw size={12} /> : <AlertTriangle size={12} />}
<div className={css({ display: "grid", gap: "2px", minWidth: 0 })}>
<div className={css({ fontWeight: 600 })}>{organizationStatus.label}</div>
<div className={css({ color: t.textSecondary, fontSize: "10px" })}>{organizationStatus.detail}</div>
</div>
</div>
) : null}
<div className={css({ padding: "8px" })}>
<button
type="button"

View file

@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Clock, CreditCard, FileText, Github, LogOut, Moon, Settings, Sun, Users } from "lucide-react";
import { activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
import { isMockFrontendClient } from "../lib/env";
import { getMockOrganizationStatus } from "../lib/mock-organization-status";
import { useColorMode, useFoundryTokens } from "../app/theme";
import type { FoundryTokens } from "../styles/tokens";
import { appSurfaceStyle, primaryButtonStyle, secondaryButtonStyle, subtleButtonStyle, cardStyle, badgeStyle, inputStyle } from "../styles/shared-styles";
@ -134,6 +135,40 @@ function githubBadge(t: FoundryTokens, organization: FoundryOrganization) {
return <span style={badgeStyle(t, t.borderSubtle)}>Install GitHub App</span>;
}
function organizationStatusBadge(t: FoundryTokens, organization: FoundryOrganization) {
const status = getMockOrganizationStatus(organization);
if (!status) {
return null;
}
const toneStyles =
status.tone === "error"
? { background: "rgba(255, 79, 0, 0.18)", color: "#ffd6c7", borderColor: "rgba(255, 79, 0, 0.35)" }
: status.tone === "warning"
? { background: "rgba(255, 193, 7, 0.18)", color: "#ffe6a6", borderColor: "rgba(255, 193, 7, 0.28)" }
: { background: "rgba(24, 140, 255, 0.18)", color: "#b9d8ff", borderColor: "rgba(24, 140, 255, 0.28)" };
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 8px",
borderRadius: "999px",
border: `1px solid ${toneStyles.borderColor}`,
background: toneStyles.background,
color: toneStyles.color,
fontSize: "11px",
fontWeight: 600,
lineHeight: 1,
}}
>
{status.label}
</span>
);
}
function StatCard({ label, value, caption }: { label: string; value: string; caption: string }) {
const t = useFoundryTokens();
return (
@ -410,7 +445,10 @@ export function MockOrganizationSelectorPage() {
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "14px", fontWeight: 500, lineHeight: 1.3 }}>{organization.settings.displayName}</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<div style={{ fontSize: "14px", fontWeight: 500, lineHeight: 1.3 }}>{organization.settings.displayName}</div>
{organizationStatusBadge(t, organization)}
</div>
<div style={{ fontSize: "12px", color: t.textTertiary, lineHeight: 1.3, marginTop: "1px" }}>
{organization.kind === "personal" ? "Personal" : "Organization"} · {planCatalog[organization.billing.planId]!.label} ·{" "}
{organization.members.length} member{organization.members.length !== 1 ? "s" : ""}

View file

@ -0,0 +1,63 @@
import type { FoundryOrganization } from "@sandbox-agent/foundry-shared";
export type MockOrganizationStatusTone = "info" | "warning" | "error";
export interface MockOrganizationStatus {
key: "syncing" | "pending" | "sync_error" | "reconnect_required" | "install_required";
label: string;
detail: string;
tone: MockOrganizationStatusTone;
}
export function getMockOrganizationStatus(organization: FoundryOrganization): MockOrganizationStatus | null {
if (organization.kind === "personal") {
return null;
}
if (organization.github.installationStatus === "reconnect_required") {
return {
key: "reconnect_required",
label: "Connection issue",
detail: "Reconnect GitHub",
tone: "error",
};
}
if (organization.github.installationStatus === "install_required") {
return {
key: "install_required",
label: "Link GitHub",
detail: "Install GitHub App",
tone: "warning",
};
}
if (organization.github.syncStatus === "syncing") {
return {
key: "syncing",
label: "Syncing",
detail: "Syncing repositories",
tone: "info",
};
}
if (organization.github.syncStatus === "pending") {
return {
key: "pending",
label: "Needs sync",
detail: "Waiting for first sync",
tone: "warning",
};
}
if (organization.github.syncStatus === "error") {
return {
key: "sync_error",
label: "Sync failed",
detail: "Last GitHub sync failed",
tone: "error",
};
}
return null;
}