Fix Foundry UI bugs: org names, sessions, and repo selection (#250)

* Fix Foundry auth: migrate to Better Auth adapter, fix access token retrieval

- Remove @ts-nocheck from better-auth.ts, auth-user/index.ts, app-shell.ts
  and fix all type errors
- Fix getAccessTokenForSession: read GitHub token directly from account
  record instead of calling Better Auth's internal /get-access-token
  endpoint which returns 403 on server-side calls
- Re-implement workspaceAuth helper functions (workspaceAuthColumn,
  normalizeAuthValue, workspaceAuthClause, workspaceAuthWhere) that were
  accidentally deleted
- Remove all retry logic (withRetries, isRetryableAppActorError)
- Implement CORS origin allowlist from configured environment
- Document cachedAppWorkspace singleton pattern
- Add inline org sync fallback in buildAppSnapshot for post-OAuth flow
- Add no-retry rule to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add Foundry dev panel from fix-git-data branch

Port the dev panel component that was left out when PR #243 was replaced
by PR #247. Adapted to remove runtime/mock-debug references that don't
exist on the current branch.

- Toggle with Shift+D, persists visibility to localStorage
- Shows context, session, GitHub sync status sections
- Dev-only (import.meta.env.DEV)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add full Docker image defaults, fix actor deadlocks, and improve dev experience

- Add Dockerfile.full and --all flag to install-agent CLI for pre-built images
- Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full
- Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example
- Expand Docker docs with full runnable Dockerfile
- Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning)
- Audit and convert 12 task actions from wait:true to wait:false
- Add bun --hot for dev backend hot reload
- Remove --force from pnpm install in dev Dockerfile for faster startup
- Add env_file support to compose.dev.yaml for automatic credential loading
- Add mock frontend compose config and dev panel
- Update CLAUDE.md with wait:true policy and dev environment setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* WIP: async action fixes and interest manager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation

- Fix org display name using GitHub description instead of name field
- Fix createWorkbenchSession hanging when sandbox is provisioning
- Fix auto-session creation retry storm on errors
- Fix task creation using wrong repo due to React state race conditions
- Remove Bun hot-reload from backend Dockerfile (causes port drift)
- Add GitHub sync/install status to dev panel

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-13 20:48:22 -07:00 committed by GitHub
parent 58c54156f1
commit d8b8b49f37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 9252 additions and 1933 deletions

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentType, TaskRecord, TaskSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/foundry-shared";
import { groupTaskStatus, type SandboxSessionEventRecord } from "@sandbox-agent/foundry-client";
import type { AgentType, RepoBranchRecord, RepoOverview, RepoStackAction, WorkbenchTaskStatus } from "@sandbox-agent/foundry-shared";
import { useInterest } from "@sandbox-agent/foundry-client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { Button } from "baseui/button";
@ -17,6 +17,7 @@ import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizon
import { formatDiffStat } from "../features/tasks/model";
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
import { backendClient } from "../lib/backend";
import { interestManager } from "../lib/interest";
interface WorkspaceDashboardProps {
workspaceId: string;
@ -96,11 +97,9 @@ const AGENT_OPTIONS: SelectItem[] = [
{ id: "claude", label: "claude" },
];
function statusKind(status: TaskSummary["status"]): StatusTagKind {
const group = groupTaskStatus(status);
if (group === "running") return "positive";
if (group === "queued") return "warning";
if (group === "error") return "negative";
function statusKind(status: WorkbenchTaskStatus): StatusTagKind {
if (status === "running") return "positive";
if (status === "new") return "warning";
return "neutral";
}
@ -135,26 +134,6 @@ function branchTestIdToken(value: string): string {
return token || "branch";
}
function useSessionEvents(
task: TaskRecord | null,
sessionId: string | null,
): ReturnType<typeof useQuery<{ items: SandboxSessionEventRecord[]; nextCursor?: string }, Error>> {
return useQuery({
queryKey: ["workspace", task?.workspaceId ?? "", "session", task?.taskId ?? "", sessionId ?? ""],
enabled: Boolean(task?.activeSandboxId && sessionId),
refetchInterval: 2_500,
queryFn: async () => {
if (!task?.activeSandboxId || !sessionId) {
return { items: [] };
}
return backendClient.listSandboxSessionEvents(task.workspaceId, task.providerId, task.activeSandboxId, {
sessionId,
limit: 120,
});
},
});
}
function repoSummary(overview: RepoOverview | undefined): {
total: number;
mapped: number;
@ -382,37 +361,26 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
});
const [createError, setCreateError] = useState<string | null>(null);
const tasksQuery = useQuery({
queryKey: ["workspace", workspaceId, "tasks"],
queryFn: async () => backendClient.listTasks(workspaceId),
refetchInterval: 2_500,
});
const taskDetailQuery = useQuery({
queryKey: ["workspace", workspaceId, "task-detail", selectedTaskId],
enabled: Boolean(selectedTaskId && !repoOverviewMode),
refetchInterval: 2_500,
queryFn: async () => {
if (!selectedTaskId) {
throw new Error("No task selected");
}
return backendClient.getTask(workspaceId, selectedTaskId);
},
});
const reposQuery = useQuery({
queryKey: ["workspace", workspaceId, "repos"],
queryFn: async () => backendClient.listRepos(workspaceId),
refetchInterval: 10_000,
});
const repos = reposQuery.data ?? [];
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
const repos = workspaceState.data?.repos ?? [];
const rows = workspaceState.data?.taskSummaries ?? [];
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
const taskState = useInterest(
interestManager,
"task",
!repoOverviewMode && selectedSummary
? {
workspaceId,
repoId: selectedSummary.repoId,
taskId: selectedSummary.id,
}
: null,
);
const activeRepoId = selectedRepoId ?? createRepoId;
const repoOverviewQuery = useQuery({
queryKey: ["workspace", workspaceId, "repo-overview", activeRepoId],
enabled: Boolean(repoOverviewMode && activeRepoId),
refetchInterval: 5_000,
queryFn: async () => {
if (!activeRepoId) {
throw new Error("No repo selected");
@ -427,7 +395,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
return;
}
if (!createRepoId && repos.length > 0) {
setCreateRepoId(repos[0]!.repoId);
setCreateRepoId(repos[0]!.id);
}
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]);
@ -439,9 +407,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
}
}, [newAgentType]);
const rows = tasksQuery.data ?? [];
const repoGroups = useMemo(() => {
const byRepo = new Map<string, TaskSummary[]>();
const byRepo = new Map<string, typeof rows>();
for (const row of rows) {
const bucket = byRepo.get(row.repoId);
if (bucket) {
@ -453,12 +420,12 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
return repos
.map((repo) => {
const tasks = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt);
const latestTaskAt = tasks[0]?.updatedAt ?? 0;
const tasks = [...(byRepo.get(repo.id) ?? [])].sort((a, b) => b.updatedAtMs - a.updatedAtMs);
const latestTaskAt = tasks[0]?.updatedAtMs ?? 0;
return {
repoId: repo.repoId,
repoRemote: repo.remoteUrl,
latestActivityAt: Math.max(repo.updatedAt, latestTaskAt),
repoId: repo.id,
repoLabel: repo.label,
latestActivityAt: Math.max(repo.latestActivityMs, latestTaskAt),
tasks,
};
})
@ -466,13 +433,11 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
if (a.latestActivityAt !== b.latestActivityAt) {
return b.latestActivityAt - a.latestActivityAt;
}
return a.repoRemote.localeCompare(b.repoRemote);
return a.repoLabel.localeCompare(b.repoLabel);
});
}, [repos, rows]);
const selectedSummary = useMemo(() => rows.find((row) => row.taskId === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
const selectedForSession = repoOverviewMode ? null : (taskDetailQuery.data ?? null);
const selectedForSession = repoOverviewMode ? null : (taskState.data ?? null);
const activeSandbox = useMemo(() => {
if (!selectedForSession) return null;
@ -488,7 +453,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
workspaceId,
taskId: rows[0]!.taskId,
taskId: rows[0]!.id,
},
search: { sessionId: undefined },
replace: true,
@ -499,35 +464,39 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
useEffect(() => {
setActiveSessionId(null);
setDraft("");
}, [selectedForSession?.taskId]);
}, [selectedForSession?.id]);
const sessionsQuery = useQuery({
queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"],
enabled: Boolean(activeSandbox?.sandboxId && selectedForSession),
refetchInterval: 3_000,
queryFn: async () => {
if (!activeSandbox?.sandboxId || !selectedForSession) {
return { items: [] };
}
return backendClient.listSandboxSessions(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId, {
limit: 30,
});
},
});
const sessionRows = sessionsQuery.data?.items ?? [];
const sessionRows = selectedForSession?.sessionsSummary ?? [];
const sessionSelection = useMemo(
() =>
resolveSessionSelection({
explicitSessionId: activeSessionId,
taskSessionId: selectedForSession?.activeSessionId ?? null,
sessions: sessionRows,
sessions: sessionRows.map((session) => ({
id: session.id,
agent: session.agent,
agentSessionId: session.sessionId ?? "",
lastConnectionId: "",
createdAt: 0,
status: session.status,
})),
}),
[activeSessionId, selectedForSession?.activeSessionId, sessionRows],
);
const resolvedSessionId = sessionSelection.sessionId;
const staleSessionId = sessionSelection.staleSessionId;
const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId);
const sessionState = useInterest(
interestManager,
"session",
selectedForSession && resolvedSessionId
? {
workspaceId,
repoId: selectedForSession.repoId,
taskId: selectedForSession.id,
sessionId: resolvedSessionId,
}
: null,
);
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
@ -546,9 +515,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
const createSession = useMutation({
mutationFn: async () => startSessionFromTask(),
onSuccess: async (session) => {
onSuccess: (session) => {
setActiveSessionId(session.id);
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
},
});
@ -558,7 +526,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
}
const created = await startSessionFromTask();
setActiveSessionId(created.id);
await sessionsQuery.refetch();
return created.id;
};
@ -576,13 +543,12 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
prompt,
});
},
onSuccess: async () => {
onSuccess: () => {
setDraft("");
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
},
});
const transcript = buildTranscript(eventsQuery.data?.items ?? []);
const transcript = buildTranscript(sessionState.data?.transcript ?? []);
const canCreateTask = createRepoId.trim().length > 0 && newTask.trim().length > 0;
const createTask = useMutation({
@ -613,8 +579,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
setNewBranchName("");
setCreateOnBranch(null);
setCreateTaskOpen(false);
await tasksQuery.refetch();
await repoOverviewQuery.refetch();
await navigate({
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
@ -641,7 +605,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
setAddRepoError(null);
setAddRepoRemote("");
setAddRepoOpen(false);
await reposQuery.refetch();
setCreateRepoId(created.repoId);
if (repoOverviewMode) {
await navigate({
@ -679,7 +642,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
setStackActionMessage(null);
setStackActionError(result.message);
}
await Promise.all([repoOverviewQuery.refetch(), tasksQuery.refetch()]);
await repoOverviewQuery.refetch();
},
onError: (error) => {
setStackActionMessage(null);
@ -698,7 +661,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
setCreateTaskOpen(true);
};
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]);
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [repos]);
const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null;
const selectedAgentOption = useMemo(() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!), [newAgentType]);
const selectedFilterOption = useMemo(
@ -706,7 +669,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
[overviewFilter],
);
const sessionOptions = useMemo(
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.id} (${session.status ?? "running"})` })),
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.sessionName} (${session.status})` })),
[sessionRows],
);
const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null;
@ -839,13 +802,15 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
</PanelHeader>
<ScrollBody>
{tasksQuery.isLoading ? (
{workspaceState.status === "loading" ? (
<>
<Skeleton rows={3} height="72px" />
</>
) : null}
{!tasksQuery.isLoading && repoGroups.length === 0 ? <EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState> : null}
{workspaceState.status !== "loading" && repoGroups.length === 0 ? (
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
) : null}
{repoGroups.map((group) => (
<section
@ -876,7 +841,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
})}
data-testid={group.repoId === activeRepoId ? "repo-overview-open" : `repo-overview-open-${group.repoId}`}
>
{group.repoRemote}
{group.repoLabel}
</Link>
<div
@ -887,14 +852,14 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
})}
>
{group.tasks
.filter((task) => task.status !== "archived" || task.taskId === selectedSummary?.taskId)
.filter((task) => task.status !== "archived" || task.id === selectedSummary?.id)
.map((task) => {
const isActive = !repoOverviewMode && task.taskId === selectedSummary?.taskId;
const isActive = !repoOverviewMode && task.id === selectedSummary?.id;
return (
<Link
key={task.taskId}
key={task.id}
to="/workspaces/$workspaceId/tasks/$taskId"
params={{ workspaceId, taskId: task.taskId }}
params={{ workspaceId, taskId: task.id }}
search={{ sessionId: undefined }}
className={css({
display: "block",
@ -927,7 +892,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
color="contentSecondary"
overrides={{ Block: { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } } }}
>
{task.branchName ?? "Determining branch..."}
{task.branch ?? "Determining branch..."}
</ParagraphSmall>
<StatusPill kind={statusKind(task.status)}>{task.status}</StatusPill>
</div>
@ -1396,11 +1361,11 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
backgroundColor: theme.colors.backgroundPrimary,
})}
>
{eventsQuery.isLoading ? <Skeleton rows={2} height="90px" /> : null}
{resolvedSessionId && sessionState.status === "loading" ? <Skeleton rows={2} height="90px" /> : null}
{transcript.length === 0 && !eventsQuery.isLoading ? (
{transcript.length === 0 && !(resolvedSessionId && sessionState.status === "loading") ? (
<EmptyState testId="session-transcript-empty">
{groupTaskStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage
{selectedForSession.runtimeStatus === "error" && selectedForSession.statusMessage
? `Session failed: ${selectedForSession.statusMessage}`
: !activeSandbox?.sandboxId
? selectedForSession.statusMessage
@ -1597,7 +1562,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Task" value={selectedForSession.taskId} mono />
<MetaRow label="Task" value={selectedForSession.id} mono />
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
</div>
@ -1615,7 +1580,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Branch" value={selectedForSession.branchName ?? "-"} mono />
<MetaRow label="Branch" value={selectedForSession.branch ?? "-"} mono />
<MetaRow label="Diff" value={formatDiffStat(selectedForSession.diffStat)} />
<MetaRow label="PR" value={selectedForSession.prUrl ?? "-"} />
<MetaRow label="Review" value={selectedForSession.reviewStatus ?? "-"} />
@ -1641,7 +1606,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
</div>
</section>
{groupTaskStatus(selectedForSession.status) === "error" ? (
{selectedForSession.runtimeStatus === "error" ? (
<div
className={css({
padding: "12px",