This commit is contained in:
Nathan Flurry 2026-03-14 14:38:29 -07:00 committed by GitHub
parent 70d31f819c
commit 5ea9ec5e2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2605 additions and 669 deletions

View file

@ -71,10 +71,10 @@ 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`;
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h`;
if (minutes < 60) return `${minutes}m ago`;
return `${Math.floor(minutes / 60)}h ago`;
}
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
@ -157,8 +157,11 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
}, [now]);
const repos = snapshot.repos ?? [];
const prCount = (snapshot.tasks ?? []).filter((task) => task.pullRequest != null).length;
const focusedTaskStatus = focusedTask?.runtimeStatus ?? focusedTask?.status ?? null;
const focusedTaskState = describeTaskState(focusedTaskStatus, focusedTask?.statusMessage ?? null);
const lastWebhookAt = organization?.github.lastWebhookAt ?? null;
const hasRecentWebhook = lastWebhookAt != null && now - lastWebhookAt < 5 * 60_000;
const mono = css({
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
@ -436,8 +439,28 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
<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", alignItems: "center", gap: "6px" })}>
<span
className={css({
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: hasRecentWebhook ? t.statusSuccess : t.textMuted,
flexShrink: 0,
})}
/>
<span className={css({ color: t.textPrimary, flex: 1 })}>Webhook</span>
{lastWebhookAt != null ? (
<span className={`${mono} ${css({ color: hasRecentWebhook ? t.textPrimary : t.textMuted })}`}>
{organization.github.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
</span>
) : (
<span className={`${mono} ${css({ color: t.textMuted })}`}>never received</span>
)}
</div>
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
<Stat label="repos imported" value={organization.github.importedRepoCount} t={t} css={css} />
<Stat label="repos" value={organization.github.importedRepoCount} t={t} css={css} />
<Stat label="PRs" value={prCount} t={t} css={css} />
</div>
{organization.github.connectedAccount && (
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>

View file

@ -3,14 +3,16 @@ import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import {
createErrorContext,
type FoundryOrganization,
type TaskWorkbenchSnapshot,
type WorkbenchOpenPrSummary,
type WorkbenchSessionSummary,
type WorkbenchTaskDetail,
type WorkbenchTaskSummary,
} from "@sandbox-agent/foundry-shared";
import { useInterest } from "@sandbox-agent/foundry-client";
import { PanelLeft, PanelRight } from "lucide-react";
import { CircleAlert, PanelLeft, PanelRight } from "lucide-react";
import { useFoundryTokens } from "../app/theme";
import { logger } from "../logging.js";
@ -75,6 +77,59 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
}
function githubInstallationWarningTitle(organization: FoundryOrganization): string {
return organization.github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
}
function githubInstallationWarningDetail(organization: FoundryOrganization): string {
const statusDetail = organization.github.lastSyncLabel.trim();
const requirementDetail =
organization.github.installationStatus === "install_required"
? "Webhooks are required for Foundry to function. Repo sync and PR updates will not work until the GitHub App is installed for this workspace."
: "Webhook delivery is unavailable. Repo sync and PR updates will not work until the GitHub App is reconnected.";
return statusDetail ? `${requirementDetail} ${statusDetail}.` : requirementDetail;
}
function GithubInstallationWarning({
organization,
css,
t,
}: {
organization: FoundryOrganization;
css: ReturnType<typeof useStyletron>[0];
t: ReturnType<typeof useFoundryTokens>;
}) {
if (organization.github.installationStatus === "connected") {
return null;
}
return (
<div
className={css({
position: "fixed",
bottom: "8px",
left: "8px",
zIndex: 99998,
display: "flex",
alignItems: "flex-start",
gap: "8px",
padding: "10px 12px",
backgroundColor: t.surfaceElevated,
border: `1px solid ${t.statusError}`,
borderRadius: "6px",
boxShadow: t.shadow,
maxWidth: "440px",
})}
>
<CircleAlert size={15} color={t.statusError} />
<div className={css({ display: "flex", flexDirection: "column", gap: "3px" })}>
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(organization)}</div>
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(organization)}</div>
</div>
</div>
);
}
function toLegacyTab(
summary: WorkbenchSessionSummary,
sessionDetail?: { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] },
@ -125,6 +180,40 @@ function toLegacyTask(
};
}
const OPEN_PR_TASK_PREFIX = "pr:";
function openPrTaskId(prId: string): string {
return `${OPEN_PR_TASK_PREFIX}${prId}`;
}
function isOpenPrTaskId(taskId: string): boolean {
return taskId.startsWith(OPEN_PR_TASK_PREFIX);
}
function toLegacyOpenPrTask(pullRequest: WorkbenchOpenPrSummary): Task {
return {
id: openPrTaskId(pullRequest.prId),
repoId: pullRequest.repoId,
title: pullRequest.title,
status: "new",
runtimeStatus: undefined,
statusMessage: pullRequest.authorLogin ? `@${pullRequest.authorLogin}` : null,
repoName: pullRequest.repoFullName,
updatedAtMs: pullRequest.updatedAtMs,
branch: pullRequest.headRefName,
pullRequest: {
number: pullRequest.number,
status: pullRequest.isDraft ? "draft" : "ready",
},
tabs: [],
fileChanges: [],
diffs: {},
fileTree: [],
minutesUsed: 0,
activeSandboxId: null,
};
}
function sessionStateMessage(tab: Task["tabs"][number] | null | undefined): string | null {
if (!tab) {
return null;
@ -153,7 +242,14 @@ function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[
}
interface WorkbenchActions {
createTask(input: { repoId: string; task: string; title?: string; branch?: string; model?: ModelId }): Promise<{ taskId: string; tabId?: string }>;
createTask(input: {
repoId: string;
task: string;
title?: string;
branch?: string;
onBranch?: string;
model?: ModelId;
}): Promise<{ taskId: string; tabId?: string }>;
markTaskUnread(input: { taskId: string }): Promise<void>;
renameTask(input: { taskId: string; value: string }): Promise<void>;
renameBranch(input: { taskId: string; value: string }): Promise<void>;
@ -168,6 +264,10 @@ interface WorkbenchActions {
closeTab(input: { taskId: string; tabId: string }): Promise<void>;
addTab(input: { taskId: string; model?: string }): Promise<{ tabId: string }>;
changeModel(input: { taskId: string; tabId: string; model: ModelId }): Promise<void>;
reloadGithubOrganization(): Promise<void>;
reloadGithubPullRequests(): Promise<void>;
reloadGithubRepository(repoId: string): Promise<void>;
reloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
}
const TranscriptPanel = memo(function TranscriptPanel({
@ -187,6 +287,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSidebarPeekEnd,
rightSidebarCollapsed,
onToggleRightSidebar,
selectedSessionHydrating = false,
onNavigateToUsage,
}: {
taskWorkbenchClient: WorkbenchActions;
@ -205,6 +306,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSidebarPeekEnd?: () => void;
rightSidebarCollapsed?: boolean;
onToggleRightSidebar?: () => void;
selectedSessionHydrating?: boolean;
onNavigateToUsage?: () => void;
}) {
const t = useFoundryTokens();
@ -216,6 +318,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
const [pendingHistoryTarget, setPendingHistoryTarget] = useState<{ messageId: string; tabId: string } | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
const [localDraft, setLocalDraft] = useState("");
const [localAttachments, setLocalAttachments] = useState<LineAttachment[]>([]);
const lastEditTimeRef = useRef(0);
const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDraftRef = useRef<{ text: string; attachments: LineAttachment[] } | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef(new Map<string, HTMLDivElement>());
@ -235,8 +342,27 @@ const TranscriptPanel = memo(function TranscriptPanel({
!!activeAgentTab &&
(activeAgentTab.status === "pending_provision" || activeAgentTab.status === "pending_session_create" || activeAgentTab.status === "error") &&
activeMessages.length === 0;
const draft = promptTab?.draft.text ?? "";
const attachments = promptTab?.draft.attachments ?? [];
const serverDraft = promptTab?.draft.text ?? "";
const serverAttachments = promptTab?.draft.attachments ?? [];
// Sync server → local only when user hasn't typed recently (3s cooldown)
const DRAFT_SYNC_COOLDOWN_MS = 3_000;
useEffect(() => {
if (Date.now() - lastEditTimeRef.current > DRAFT_SYNC_COOLDOWN_MS) {
setLocalDraft(serverDraft);
setLocalAttachments(serverAttachments);
}
}, [serverDraft, serverAttachments]);
// Reset local draft immediately on tab/task switch
useEffect(() => {
lastEditTimeRef.current = 0;
setLocalDraft(promptTab?.draft.text ?? "");
setLocalAttachments(promptTab?.draft.attachments ?? []);
}, [promptTab?.id, task.id]);
const draft = localDraft;
const attachments = localAttachments;
useEffect(() => {
if (scrollRef.current) {
@ -343,20 +469,53 @@ const TranscriptPanel = memo(function TranscriptPanel({
[editValue, task.id],
);
const DRAFT_THROTTLE_MS = 500;
const flushDraft = useCallback(
(text: string, nextAttachments: LineAttachment[], tabId: string) => {
void taskWorkbenchClient.updateDraft({
taskId: task.id,
tabId,
text,
attachments: nextAttachments,
});
},
[task.id],
);
// Clean up throttle timer on unmount
useEffect(() => {
return () => {
if (throttleTimerRef.current) {
clearTimeout(throttleTimerRef.current);
}
};
}, []);
const updateDraft = useCallback(
(nextText: string, nextAttachments: LineAttachment[]) => {
if (!promptTab) {
return;
}
void taskWorkbenchClient.updateDraft({
taskId: task.id,
tabId: promptTab.id,
text: nextText,
attachments: nextAttachments,
});
// Update local state immediately for responsive typing
lastEditTimeRef.current = Date.now();
setLocalDraft(nextText);
setLocalAttachments(nextAttachments);
// Throttle the network call
pendingDraftRef.current = { text: nextText, attachments: nextAttachments };
if (!throttleTimerRef.current) {
throttleTimerRef.current = setTimeout(() => {
throttleTimerRef.current = null;
if (pendingDraftRef.current) {
flushDraft(pendingDraftRef.current.text, pendingDraftRef.current.attachments, promptTab.id);
pendingDraftRef.current = null;
}
}, DRAFT_THROTTLE_MS);
}
},
[task.id, promptTab],
[promptTab, flushDraft],
);
const sendMessage = useCallback(() => {
@ -687,6 +846,33 @@ const TranscriptPanel = memo(function TranscriptPanel({
</div>
</div>
</ScrollBody>
) : selectedSessionHydrating ? (
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<div
style={{
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
alignItems: "center",
}}
>
<SpinnerDot size={16} />
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Loading session</h2>
<p style={{ margin: 0, opacity: 0.75 }}>Fetching the latest transcript for this session.</p>
</div>
</div>
</ScrollBody>
) : showPendingSessionState ? (
<ScrollBody>
<div
@ -1099,12 +1285,25 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
closeTab: (input) => backendClient.closeWorkbenchSession(workspaceId, input),
addTab: (input) => backendClient.createWorkbenchSession(workspaceId, input),
changeModel: (input) => backendClient.changeWorkbenchModel(workspaceId, input),
reloadGithubOrganization: () => backendClient.reloadGithubOrganization(workspaceId),
reloadGithubPullRequests: () => backendClient.reloadGithubPullRequests(workspaceId),
reloadGithubRepository: (repoId) => backendClient.reloadGithubRepository(workspaceId, repoId),
reloadGithubPullRequest: (repoId, prNumber) => backendClient.reloadGithubPullRequest(workspaceId, repoId, prNumber),
}),
[workspaceId],
);
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
const workspaceRepos = workspaceState.data?.repos ?? [];
const taskSummaries = workspaceState.data?.taskSummaries ?? [];
const openPullRequests = workspaceState.data?.openPullRequests ?? [];
const openPullRequestsByTaskId = useMemo(
() => new Map(openPullRequests.map((pullRequest) => [openPrTaskId(pullRequest.prId), pullRequest])),
[openPullRequests],
);
const selectedOpenPullRequest = useMemo(
() => (selectedTaskId ? (openPullRequestsByTaskId.get(selectedTaskId) ?? null) : null),
[openPullRequestsByTaskId, selectedTaskId],
);
const selectedTaskSummary = useMemo(
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
[selectedTaskId, taskSummaries],
@ -1169,10 +1368,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
}
}
return taskSummaries.map((summary) =>
const legacyTasks = taskSummaries.map((summary) =>
summary.id === selectedTaskSummary?.id ? toLegacyTask(summary, taskState.data, sessionCache) : toLegacyTask(summary),
);
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
const legacyOpenPrs = openPullRequests.map((pullRequest) => toLegacyOpenPrTask(pullRequest));
return [...legacyTasks, ...legacyOpenPrs].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [openPullRequests, selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
const rawProjects = useMemo(() => groupProjects(workspaceRepos, tasks), [tasks, workspaceRepos]);
const appSnapshot = useMockAppSnapshot();
const activeOrg = activeMockOrganization(appSnapshot);
@ -1200,9 +1401,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
const resolvingOpenPullRequestsRef = useRef<Set<string>>(new Set());
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
const [materializingOpenPrId, setMaterializingOpenPrId] = useState<string | null>(null);
const showDevPanel = useDevPanel();
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -1268,13 +1471,81 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
startRightRef.current = rightWidthRef.current;
}, []);
const activeTask = useMemo(() => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null, [tasks, selectedTaskId]);
const activeTask = useMemo(() => {
const realTasks = tasks.filter((task) => !isOpenPrTaskId(task.id));
if (selectedOpenPullRequest) {
return null;
}
if (selectedTaskId) {
return realTasks.find((task) => task.id === selectedTaskId) ?? realTasks[0] ?? null;
}
return realTasks[0] ?? null;
}, [selectedOpenPullRequest, selectedTaskId, tasks]);
const materializeOpenPullRequest = useCallback(
async (pullRequest: WorkbenchOpenPrSummary) => {
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
return;
}
resolvingOpenPullRequestsRef.current.add(pullRequest.prId);
setMaterializingOpenPrId(pullRequest.prId);
try {
const { taskId, tabId } = await taskWorkbenchClient.createTask({
repoId: pullRequest.repoId,
task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`,
model: "gpt-5.3-codex",
title: pullRequest.title,
onBranch: pullRequest.headRefName,
});
await navigate({
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
workspaceId,
taskId,
},
search: { sessionId: tabId ?? undefined },
replace: true,
});
} catch (error) {
setMaterializingOpenPrId((current) => (current === pullRequest.prId ? null : current));
resolvingOpenPullRequestsRef.current.delete(pullRequest.prId);
logger.error(
{
prId: pullRequest.prId,
repoId: pullRequest.repoId,
branchName: pullRequest.headRefName,
...createErrorContext(error),
},
"failed_to_materialize_open_pull_request_task",
);
}
},
[navigate, taskWorkbenchClient, workspaceId],
);
useEffect(() => {
if (!selectedOpenPullRequest) {
if (materializingOpenPrId) {
resolvingOpenPullRequestsRef.current.delete(materializingOpenPrId);
}
setMaterializingOpenPrId(null);
return;
}
void materializeOpenPullRequest(selectedOpenPullRequest);
}, [materializeOpenPullRequest, materializingOpenPrId, selectedOpenPullRequest]);
useEffect(() => {
if (activeTask) {
return;
}
if (selectedOpenPullRequest || materializingOpenPrId) {
return;
}
const fallbackTaskId = tasks[0]?.id;
if (!fallbackTaskId) {
return;
@ -1291,11 +1562,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
search: { sessionId: fallbackTask?.tabs[0]?.id ?? undefined },
replace: true,
});
}, [activeTask, tasks, navigate, workspaceId]);
}, [activeTask, materializingOpenPrId, navigate, selectedOpenPullRequest, tasks, workspaceId]);
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
const lastAgentTabId = activeTask ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) : null;
const activeTabId = activeTask ? sanitizeActiveTabId(activeTask, activeTabIdByTask[activeTask.id], openDiffs, lastAgentTabId) : null;
const selectedSessionHydrating = Boolean(selectedSessionId && activeTabId === selectedSessionId && sessionState.status === "loading" && !sessionState.data);
const syncRouteSession = useCallback(
(taskId: string, sessionId: string | null, replace = false) => {
@ -1395,7 +1667,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
const createTask = useCallback(
(overrideRepoId?: string) => {
(overrideRepoId?: string, options?: { title?: string; task?: string; branch?: string; onBranch?: string }) => {
void (async () => {
const repoId = overrideRepoId || selectedNewTaskRepoId;
if (!repoId) {
@ -1404,9 +1676,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
const { taskId, tabId } = await taskWorkbenchClient.createTask({
repoId,
task: "New task",
task: options?.task ?? "New task",
model: "gpt-5.3-codex",
title: "New task",
title: options?.title ?? "New task",
...(options?.branch ? { branch: options.branch } : {}),
...(options?.onBranch ? { onBranch: options.onBranch } : {}),
});
await navigate({
to: "/workspaces/$workspaceId/tasks/$taskId",
@ -1418,7 +1692,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
});
})();
},
[navigate, selectedNewTaskRepoId, workspaceId],
[navigate, selectedNewTaskRepoId, taskWorkbenchClient, workspaceId],
);
const openDiffTab = useCallback(
@ -1447,6 +1721,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
const selectTask = useCallback(
(id: string) => {
if (isOpenPrTaskId(id)) {
const pullRequest = openPullRequestsByTaskId.get(id);
if (!pullRequest) {
return;
}
void materializeOpenPullRequest(pullRequest);
return;
}
const task = tasks.find((candidate) => candidate.id === id) ?? null;
void navigate({
to: "/workspaces/$workspaceId/tasks/$taskId",
@ -1457,7 +1739,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
search: { sessionId: task?.tabs[0]?.id ?? undefined },
});
},
[tasks, navigate, workspaceId],
[materializeOpenPullRequest, navigate, openPullRequestsByTaskId, tasks, workspaceId],
);
const markTaskUnread = useCallback((id: string) => {
@ -1616,6 +1898,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
};
if (!activeTask) {
const isMaterializingSelectedOpenPr = Boolean(selectedOpenPullRequest) || materializingOpenPrId != null;
return (
<>
{dragRegion}
@ -1636,7 +1919,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
projects={projects}
newTaskRepos={workspaceRepos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId=""
activeId={selectedTaskId ?? ""}
onSelect={selectTask}
onCreate={createTask}
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
@ -1646,6 +1929,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
onReorderProjects={reorderProjects}
taskOrderByProject={taskOrderByProject}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -1712,6 +1999,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
</p>
</>
) : isMaterializingSelectedOpenPr && selectedOpenPullRequest ? (
<>
<SpinnerDot />
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Creating task from pull request</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Preparing a task for <strong>{selectedOpenPullRequest.title}</strong> on <strong>{selectedOpenPullRequest.headRefName}</strong>.
</p>
</>
) : activeOrg?.github.syncStatus === "error" ? (
<>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
@ -1766,40 +2061,7 @@ 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>
)}
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{showDevPanel && (
<DevPanel
workspaceId={workspaceId}
@ -1832,7 +2094,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
projects={projects}
newTaskRepos={workspaceRepos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId={activeTask.id}
activeId={selectedTaskId ?? activeTask.id}
onSelect={selectTask}
onCreate={createTask}
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
@ -1842,6 +2104,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
onReorderProjects={reorderProjects}
taskOrderByProject={taskOrderByProject}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -1880,7 +2146,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
projects={projects}
newTaskRepos={workspaceRepos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId={activeTask.id}
activeId={selectedTaskId ?? activeTask.id}
onSelect={(id) => {
selectTask(id);
setLeftSidebarPeeking(false);
@ -1893,6 +2159,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
onReorderProjects={reorderProjects}
taskOrderByProject={taskOrderByProject}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => {
setLeftSidebarPeeking(false);
setLeftSidebarOpen(true);
@ -1930,6 +2200,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
onSidebarPeekEnd={endPeek}
rightSidebarCollapsed={!rightSidebarOpen}
onToggleRightSidebar={() => setRightSidebarOpen(true)}
selectedSessionHydrating={selectedSessionHydrating}
onNavigateToUsage={navigateToUsage}
/>
</div>
@ -1959,40 +2230,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
</div>
</div>
</div>
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
<div
className={css({
position: "fixed",
bottom: "8px",
left: "8px",
zIndex: 99998,
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
backgroundColor: t.surfaceElevated,
border: `1px solid ${t.statusError}`,
borderRadius: "6px",
boxShadow: t.shadow,
fontSize: "11px",
color: t.textPrimary,
maxWidth: "360px",
})}
>
<span
className={css({
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: t.statusError,
flexShrink: 0,
})}
/>
<span>
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} repo sync is unavailable
</span>
</div>
)}
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{showDevPanel && (
<DevPanel
workspaceId={workspaceId}

View file

@ -97,7 +97,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
backgroundColor: "transparent",
border: "none",
margin: "0",
padding: "6px 8px",
@ -110,7 +110,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
transition: "background 160ms ease, color 160ms ease",
transition: "background-color 160ms ease, color 160ms ease",
":hover": {
backgroundColor: t.interactiveHover,
color: t.textPrimary,

View file

@ -146,7 +146,6 @@ export const ModelPicker = memo(function ModelPicker({
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
display: "flex",

View file

@ -55,7 +55,9 @@ const FileTree = memo(function FileTree({
display: "flex",
alignItems: "center",
gap: "4px",
padding: "3px 10px",
paddingTop: "3px",
paddingRight: "10px",
paddingBottom: "3px",
paddingLeft: `${10 + depth * 16}px`,
cursor: "pointer",
fontSize: "12px",
@ -175,7 +177,7 @@ export const RightSidebar = memo(function RightSidebar({
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
backgroundColor: "transparent",
border: "none",
margin: "0",
boxSizing: "border-box",
@ -202,7 +204,7 @@ export const RightSidebar = memo(function RightSidebar({
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
backgroundColor: "transparent",
border: "none",
margin: "0",
boxSizing: "border-box",
@ -230,7 +232,7 @@ export const RightSidebar = memo(function RightSidebar({
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
backgroundColor: "transparent",
border: "none",
margin: "0",
boxSizing: "border-box",
@ -312,17 +314,16 @@ export const RightSidebar = memo(function RightSidebar({
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
marginTop: "6px",
marginRight: "0",
marginBottom: "6px",
marginLeft: "6px",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
marginLeft: "6px",
borderRadius: "8px",
cursor: "pointer",
fontSize: "12px",
@ -363,15 +364,15 @@ export const RightSidebar = memo(function RightSidebar({
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
marginTop: "6px",
marginRight: "0",
marginBottom: "6px",
marginLeft: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
borderRadius: "8px",
cursor: "pointer",
fontSize: "12px",

View file

@ -13,6 +13,7 @@ import {
GitPullRequestDraft,
ListChecks,
LogOut,
MoreHorizontal,
PanelLeft,
Plus,
Settings,
@ -52,6 +53,10 @@ function projectIconColor(label: string): string {
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
}
function isPullRequestSidebarItem(task: Task): boolean {
return task.id.startsWith("pr:");
}
export const Sidebar = memo(function Sidebar({
projects,
newTaskRepos,
@ -66,6 +71,10 @@ export const Sidebar = memo(function Sidebar({
onReorderProjects,
taskOrderByProject,
onReorderTasks,
onReloadOrganization,
onReloadPullRequests,
onReloadRepository,
onReloadPullRequest,
onToggleSidebar,
}: {
projects: ProjectSection[];
@ -81,6 +90,10 @@ export const Sidebar = memo(function Sidebar({
onReorderProjects: (fromIndex: number, toIndex: number) => void;
taskOrderByProject: Record<string, string[]>;
onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void;
onReloadOrganization: () => void;
onReloadPullRequests: () => void;
onReloadRepository: (repoId: string) => void;
onReloadPullRequest: (repoId: string, prNumber: number) => void;
onToggleSidebar?: () => void;
}) {
const [css] = useStyletron();
@ -88,6 +101,8 @@ export const Sidebar = memo(function Sidebar({
const contextMenu = useContextMenu();
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({});
const [hoveredProjectId, setHoveredProjectId] = useState<string | null>(null);
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
const headerMenuRef = useRef<HTMLDivElement>(null);
// Mouse-based drag and drop state
type DragState =
@ -149,6 +164,20 @@ export const Sidebar = memo(function Sidebar({
};
}, [drag, onReorderProjects, onReorderTasks]);
useEffect(() => {
if (!headerMenuOpen) {
return;
}
const onMouseDown = (event: MouseEvent) => {
if (headerMenuRef.current?.contains(event.target as Node)) {
return;
}
setHeaderMenuOpen(false);
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [headerMenuOpen]);
const [createSelectOpen, setCreateSelectOpen] = useState(false);
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
@ -326,47 +355,111 @@ export const Sidebar = memo(function Sidebar({
/>
</div>
) : (
<div
role="button"
tabIndex={0}
aria-disabled={newTaskRepos.length === 0}
onClick={() => {
if (newTaskRepos.length === 0) return;
if (newTaskRepos.length === 1) {
onSelectNewTaskRepo(newTaskRepos[0]!.id);
onCreate(newTaskRepos[0]!.id);
} else {
setCreateSelectOpen(true);
}
}}
onKeyDown={(event) => {
if (newTaskRepos.length === 0) return;
if (event.key === "Enter" || event.key === " ") {
<div className={css({ display: "flex", alignItems: "center", gap: "6px", position: "relative" })} ref={headerMenuRef}>
<button
type="button"
onClick={() => setHeaderMenuOpen((value) => !value)}
className={css({
width: "26px",
height: "26px",
borderRadius: "8px",
border: "none",
backgroundColor: t.interactiveHover,
color: t.textPrimary,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background 200ms ease",
flexShrink: 0,
":hover": { backgroundColor: t.borderMedium },
})}
title="GitHub actions"
>
<MoreHorizontal size={14} />
</button>
{headerMenuOpen ? (
<div
className={css({
position: "absolute",
top: "32px",
right: 0,
minWidth: "180px",
padding: "6px",
borderRadius: "10px",
backgroundColor: t.surfaceElevated,
border: `1px solid ${t.borderDefault}`,
boxShadow: `${t.shadow}, 0 0 0 1px ${t.interactiveSubtle}`,
display: "flex",
flexDirection: "column",
gap: "4px",
zIndex: 20,
})}
>
<button
type="button"
onClick={() => {
setHeaderMenuOpen(false);
onReloadOrganization();
}}
className={css(menuButtonStyle(false, t))}
>
Reload organization
</button>
<button
type="button"
onClick={() => {
setHeaderMenuOpen(false);
onReloadPullRequests();
}}
className={css(menuButtonStyle(false, t))}
>
Reload all PRs
</button>
</div>
) : null}
<div
role="button"
tabIndex={0}
aria-disabled={newTaskRepos.length === 0}
onClick={() => {
if (newTaskRepos.length === 0) return;
if (newTaskRepos.length === 1) {
onSelectNewTaskRepo(newTaskRepos[0]!.id);
onCreate(newTaskRepos[0]!.id);
} else {
setCreateSelectOpen(true);
}
}
}}
className={css({
width: "26px",
height: "26px",
borderRadius: "8px",
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
color: t.textPrimary,
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background 200ms ease",
flexShrink: 0,
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
})}
>
<Plus size={14} style={{ display: "block" }} />
}}
onKeyDown={(event) => {
if (newTaskRepos.length === 0) return;
if (event.key === "Enter" || event.key === " ") {
if (newTaskRepos.length === 1) {
onSelectNewTaskRepo(newTaskRepos[0]!.id);
onCreate(newTaskRepos[0]!.id);
} else {
setCreateSelectOpen(true);
}
}
}}
className={css({
width: "26px",
height: "26px",
borderRadius: "8px",
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
color: t.textPrimary,
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background 200ms ease",
flexShrink: 0,
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
})}
>
<Plus size={14} style={{ display: "block" }} />
</div>
</div>
)}
</PanelHeaderBar>
@ -431,6 +524,12 @@ export const Sidebar = memo(function Sidebar({
}));
}
}}
onContextMenu={(event) =>
contextMenu.open(event, [
{ label: "Reload repository", onClick: () => onReloadRepository(project.id) },
{ label: "New task", onClick: () => onCreate(project.id) },
])
}
data-project-header
className={css({
display: "flex",
@ -499,13 +598,13 @@ export const Sidebar = memo(function Sidebar({
height: "26px",
borderRadius: "6px",
border: "none",
background: "none",
backgroundColor: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: t.textTertiary,
opacity: hoveredProjectId === project.id ? 1 : 0,
transition: "opacity 150ms ease, background 200ms ease, color 200ms ease",
transition: "opacity 150ms ease, background-color 200ms ease, color 200ms ease",
pointerEvents: hoveredProjectId === project.id ? "auto" : "none",
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
})}
@ -519,12 +618,14 @@ export const Sidebar = memo(function Sidebar({
{!isCollapsed &&
orderedTasks.map((task, taskIndex) => {
const isActive = task.id === activeId;
const isPullRequestItem = isPullRequestSidebarItem(task);
const isDim = task.status === "archived";
const isRunning = task.tabs.some((tab) => tab.status === "running");
const isProvisioning =
String(task.status).startsWith("init_") ||
task.status === "new" ||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create");
!isPullRequestItem &&
(String(task.status).startsWith("init_") ||
task.status === "new" ||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create"));
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);
@ -554,13 +655,20 @@ export const Sidebar = memo(function Sidebar({
onSelect(task.id);
}
}}
onContextMenu={(event) =>
onContextMenu={(event) => {
if (isPullRequestItem && task.pullRequest) {
contextMenu.open(event, [
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
{ label: "Create task", onClick: () => onSelect(task.id) },
]);
return;
}
contextMenu.open(event, [
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
])
}
]);
}}
className={css({
padding: "8px 12px",
borderRadius: "8px",
@ -596,21 +704,32 @@ export const Sidebar = memo(function Sidebar({
flexShrink: 0,
})}
>
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
{isPullRequestItem ? (
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
) : (
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
)}
</div>
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
<LabelSmall
$style={{
fontWeight: hasUnread ? 600 : 400,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
flexShrink: 1,
}}
color={hasUnread ? t.textPrimary : t.textSecondary}
>
{task.title}
</LabelSmall>
{isPullRequestItem && task.statusMessage ? (
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{task.statusMessage}
</LabelXSmall>
) : null}
</div>
<LabelSmall
$style={{
fontWeight: hasUnread ? 600 : 400,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
flexShrink: 1,
}}
color={hasUnread ? t.textPrimary : t.textSecondary}
>
{task.title}
</LabelSmall>
{task.pullRequest != null ? (
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>

View file

@ -543,7 +543,10 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
gap: "6px",
minHeight: "39px",
maxHeight: "39px",
padding: "0 14px",
paddingTop: "0",
paddingRight: "14px",
paddingBottom: "0",
paddingLeft: "14px",
borderTop: `1px solid ${t.borderDefault}`,
backgroundColor: t.surfacePrimary,
flexShrink: 0,

View file

@ -134,7 +134,6 @@ export const TranscriptHeader = memo(function TranscriptHeader({
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
margin: "0",
outline: "none",
padding: "2px 8px",

View file

@ -299,7 +299,10 @@ export const PanelHeaderBar = styled("div", ({ $theme }) => {
alignItems: "center",
minHeight: HEADER_HEIGHT,
maxHeight: HEADER_HEIGHT,
padding: "0 14px",
paddingTop: "0",
paddingRight: "14px",
paddingBottom: "0",
paddingLeft: "14px",
borderBottom: `1px solid ${t.borderDefault}`,
backgroundColor: t.surfaceTertiary,
gap: "8px",