Foundry UI polish: terminal empty state, history minimap redesign, styling tweaks (#242)

- Hide terminal pane body when no terminal tabs exist
- Redesign history minimap from orange bar to single icon with popover dropdown
- Simplify popover items to single-line user messages with ellipsis
- Adjust min-used badge hover padding
- Add right padding to message list for history icon clearance

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicholas Kissel 2026-03-12 11:03:04 -07:00 committed by GitHub
parent f09b9090bb
commit fde8b481bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 4164 additions and 1018 deletions

View file

@ -16,11 +16,11 @@ import { TranscriptHeader } from "./mock-layout/transcript-header";
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
import {
buildDisplayMessages,
buildHistoryEvents,
diffPath,
diffTabId,
formatThinkingDuration,
isDiffTab,
buildHistoryEvents,
type Task,
type HistoryEvent,
type LineAttachment,
@ -79,6 +79,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSidebarPeekEnd,
rightSidebarCollapsed,
onToggleRightSidebar,
onNavigateToUsage,
}: {
taskWorkbenchClient: ReturnType<typeof getTaskWorkbenchClient>;
task: Task;
@ -95,6 +96,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSidebarPeekEnd?: () => void;
rightSidebarCollapsed?: boolean;
onToggleRightSidebar?: () => void;
onNavigateToUsage?: () => void;
}) {
const t = useFoundryTokens();
const [defaultModel, setDefaultModel] = useState<ModelId>("claude-sonnet-4");
@ -466,6 +468,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSidebarPeekEnd={onSidebarPeekEnd}
rightSidebarCollapsed={rightSidebarCollapsed}
onToggleRightSidebar={onToggleRightSidebar}
onNavigateToUsage={onNavigateToUsage}
/>
<div
style={{
@ -694,7 +697,7 @@ const RightRail = memo(function RightRail({
const railHeight = railRef.current?.getBoundingClientRect().height ?? 0;
const maxHeight = Math.max(RIGHT_RAIL_MIN_SECTION_HEIGHT, railHeight - RIGHT_RAIL_MIN_SECTION_HEIGHT - RIGHT_RAIL_SPLITTER_HEIGHT);
return Math.min(Math.max(nextHeight, RIGHT_RAIL_MIN_SECTION_HEIGHT), maxHeight);
return Math.min(Math.max(nextHeight, 43), maxHeight);
}, []);
useEffect(() => {
@ -770,43 +773,36 @@ const RightRail = memo(function RightRail({
onToggleSidebar={onToggleSidebar}
/>
</div>
<div
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal panel"
onPointerDown={startResize}
className={css({
height: `${RIGHT_RAIL_SPLITTER_HEIGHT}px`,
flexShrink: 0,
cursor: "ns-resize",
position: "relative",
backgroundColor: t.surfacePrimary,
borderRight: `1px solid ${t.borderDefault}`,
":before": {
content: '""',
position: "absolute",
left: "50%",
top: "50%",
width: "42px",
height: "4px",
borderRadius: "999px",
transform: "translate(-50%, -50%)",
backgroundColor: t.borderMedium,
},
})}
/>
<div
className={css({
height: `${terminalHeight}px`,
minHeight: `${RIGHT_RAIL_MIN_SECTION_HEIGHT}px`,
minHeight: "43px",
backgroundColor: t.surfacePrimary,
overflow: "hidden",
borderBottomRightRadius: "12px",
borderRight: `1px solid ${t.borderDefault}`,
borderBottom: `1px solid ${t.borderDefault}`,
display: "flex",
flexDirection: "column",
})}
>
<TerminalPane workspaceId={workspaceId} taskId={task.id} />
<TerminalPane
workspaceId={workspaceId}
taskId={task.id}
onStartResize={startResize}
isExpanded={(() => {
const railHeight = railRef.current?.getBoundingClientRect().height ?? 0;
return railHeight > 0 && terminalHeight >= railHeight * 0.7;
})()}
onExpand={() => {
const railHeight = railRef.current?.getBoundingClientRect().height ?? 0;
const maxHeight = Math.max(RIGHT_RAIL_MIN_SECTION_HEIGHT, railHeight - RIGHT_RAIL_SPLITTER_HEIGHT - 42);
setTerminalHeight(maxHeight);
}}
onCollapse={() => {
setTerminalHeight(43);
}}
/>
</div>
</div>
);
@ -906,6 +902,13 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
);
const tasks = viewModel.tasks ?? [];
const rawProjects = viewModel.projects ?? [];
const appSnapshot = useMockAppSnapshot();
const activeOrg = activeMockOrganization(appSnapshot);
const navigateToUsage = useCallback(() => {
if (activeOrg) {
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } });
}
}, [activeOrg, navigate]);
const [projectOrder, setProjectOrder] = useState<string[] | null>(null);
const projects = useMemo(() => {
if (!projectOrder) return rawProjects;
@ -916,15 +919,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
}
return ordered;
}, [rawProjects, projectOrder]);
const reorderProjects = useCallback(
(fromIndex: number, toIndex: number) => {
const ids = projects.map((p) => p.id);
const [moved] = ids.splice(fromIndex, 1);
ids.splice(toIndex, 0, moved!);
setProjectOrder(ids);
},
[projects],
);
const [activeTabIdByTask, setActiveTabIdByTask] = useState<Record<string, string | null>>({});
const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState<Record<string, string | null>>({});
const [openDiffsByTask, setOpenDiffsByTask] = useState<Record<string, string[]>>({});
@ -948,6 +942,30 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
peekTimeoutRef.current = setTimeout(() => setLeftSidebarPeeking(false), 200);
}, []);
const reorderProjects = useCallback(
(fromIndex: number, toIndex: number) => {
const ids = projects.map((p) => p.id);
const [moved] = ids.splice(fromIndex, 1);
ids.splice(toIndex, 0, moved!);
setProjectOrder(ids);
},
[projects],
);
const [taskOrderByProject, setTaskOrderByProject] = useState<Record<string, string[]>>({});
const reorderTasks = useCallback(
(projectId: string, fromIndex: number, toIndex: number) => {
const project = projects.find((p) => p.id === projectId);
if (!project) return;
const currentOrder = taskOrderByProject[projectId] ?? project.tasks.map((t) => t.id);
const ids = [...currentOrder];
const [moved] = ids.splice(fromIndex, 1);
ids.splice(toIndex, 0, moved!);
setTaskOrderByProject((prev) => ({ ...prev, [projectId]: ids }));
},
[projects, taskOrderByProject],
);
useEffect(() => {
leftWidthRef.current = leftWidth;
window.localStorage.setItem(LEFT_WIDTH_STORAGE_KEY, String(leftWidth));
@ -1340,6 +1358,8 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderProjects={reorderProjects}
taskOrderByProject={taskOrderByProject}
onReorderTasks={reorderTasks}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -1458,6 +1478,8 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderProjects={reorderProjects}
taskOrderByProject={taskOrderByProject}
onReorderTasks={reorderTasks}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -1507,6 +1529,8 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderProjects={reorderProjects}
taskOrderByProject={taskOrderByProject}
onReorderTasks={reorderTasks}
onToggleSidebar={() => {
setLeftSidebarPeeking(false);
setLeftSidebarOpen(true);
@ -1543,6 +1567,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
onSidebarPeekEnd={endPeek}
rightSidebarCollapsed={!rightSidebarOpen}
onToggleRightSidebar={() => setRightSidebarOpen(true)}
onNavigateToUsage={navigateToUsage}
/>
</div>
{rightSidebarOpen ? <PanelResizeHandle onResizeStart={onRightResizeStart} onResize={onRightResize} /> : null}

View file

@ -1,6 +1,7 @@
import { memo, useEffect, useState } from "react";
import { memo, useEffect, useRef, useState } from "react";
import { useStyletron } from "baseui";
import { LabelXSmall } from "baseui/typography";
import { History } from "lucide-react";
import { useFoundryTokens } from "../../app/theme";
import { formatMessageTimestamp, type HistoryEvent } from "./view-model";
@ -9,13 +10,18 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
const [css] = useStyletron();
const t = useFoundryTokens();
const [open, setOpen] = useState(false);
const [activeEventId, setActiveEventId] = useState<string | null>(events[events.length - 1]?.id ?? null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!events.some((event) => event.id === activeEventId)) {
setActiveEventId(events[events.length - 1]?.id ?? null);
}
}, [activeEventId, events]);
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
if (events.length === 0) {
return null;
@ -23,112 +29,100 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
return (
<div
ref={containerRef}
className={css({
position: "absolute",
top: "20px",
right: "16px",
zIndex: 3,
display: "flex",
alignItems: "flex-start",
gap: "12px",
flexDirection: "column",
alignItems: "flex-end",
gap: "6px",
})}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div
role="button"
tabIndex={0}
onClick={() => setOpen((prev) => !prev)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setOpen((prev) => !prev);
}}
className={css({
width: "26px",
height: "26px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: open ? t.textSecondary : t.textTertiary,
backgroundColor: open ? t.interactiveHover : "transparent",
transition: "background 200ms ease, color 200ms ease",
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
<History size={14} />
</div>
{open ? (
<div
className={css({
width: "220px",
width: "240px",
maxHeight: "320px",
overflowY: "auto",
padding: "8px",
borderRadius: "10px",
backgroundColor: "rgba(32, 32, 32, 0.98)",
backdropFilter: "blur(12px)",
border: `1px solid ${t.borderDefault}`,
boxShadow: `0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px ${t.interactiveSubtle}`,
})}
>
<div className={css({ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px" })}>
<LabelXSmall color={t.textTertiary} $style={{ letterSpacing: "0.08em", textTransform: "uppercase" }}>
Task Events
<div className={css({ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px", padding: "0 4px" })}>
<LabelXSmall color={t.textTertiary} $style={{ letterSpacing: "0.02em" }}>
Task events
</LabelXSmall>
<LabelXSmall color={t.textTertiary}>{events.length}</LabelXSmall>
</div>
<div className={css({ display: "flex", flexDirection: "column", gap: "6px" })}>
{events.map((event) => {
const isActive = event.id === activeEventId;
return (
<button
key={event.id}
type="button"
onMouseEnter={() => setActiveEventId(event.id)}
onFocus={() => setActiveEventId(event.id)}
onClick={() => onSelect(event)}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
display: "grid",
gridTemplateColumns: "1fr auto",
gap: "10px",
alignItems: "center",
padding: "9px 10px",
borderRadius: "12px",
cursor: "pointer",
backgroundColor: isActive ? t.borderSubtle : "transparent",
color: isActive ? t.textPrimary : t.textSecondary,
transition: "background 160ms ease, color 160ms ease",
":hover": {
backgroundColor: t.borderSubtle,
color: t.textPrimary,
},
})}
>
<div className={css({ minWidth: 0, display: "flex", flexDirection: "column", gap: "4px" })}>
<div
className={css({
fontSize: "12px",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
})}
>
{event.preview}
</div>
<LabelXSmall color={t.textTertiary}>{event.sessionName}</LabelXSmall>
</div>
<LabelXSmall color={t.textTertiary}>{formatMessageTimestamp(event.createdAtMs)}</LabelXSmall>
</button>
);
})}
<div className={css({ display: "flex", flexDirection: "column", gap: "2px" })}>
{events.map((event) => (
<button
key={event.id}
type="button"
onClick={() => {
onSelect(event);
setOpen(false);
}}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
padding: "6px 8px",
borderRadius: "6px",
cursor: "pointer",
color: t.textSecondary,
fontSize: "12px",
fontWeight: 500,
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
transition: "background 160ms ease, color 160ms ease",
":hover": {
backgroundColor: t.interactiveHover,
color: t.textPrimary,
},
})}
>
{event.preview}
</button>
))}
</div>
</div>
) : null}
<div
className={css({
width: "18px",
padding: "4px 0",
display: "flex",
flexDirection: "column",
gap: "5px",
alignItems: "stretch",
})}
>
{events.map((event) => {
const isActive = event.id === activeEventId;
return (
<div
key={event.id}
className={css({
height: "3px",
borderRadius: "999px",
backgroundColor: isActive ? t.accent : t.textMuted,
opacity: isActive ? 1 : 0.75,
transition: "background 160ms ease, opacity 160ms ease",
})}
/>
);
})}
</div>
</div>
);
});

View file

@ -204,7 +204,7 @@ export const MessageList = memo(function MessageList({
<div
ref={scrollRef}
className={css({
padding: "16px 20px 16px 20px",
padding: "16px 52px 16px 20px",
display: "flex",
flexDirection: "column",
flex: 1,

View file

@ -1,7 +1,7 @@
import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { StatefulPopover, PLACEMENT } from "baseui/popover";
import { ChevronDown, ChevronUp, Star } from "lucide-react";
import { ChevronUp, Star } from "lucide-react";
import { useFoundryTokens } from "../../app/theme";
import { AgentIcon } from "./ui";
@ -107,6 +107,7 @@ export const ModelPicker = memo(function ModelPicker({
const [css] = useStyletron();
const t = useFoundryTokens();
const [isOpen, setIsOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
return (
<StatefulPopover
@ -140,10 +141,13 @@ export const ModelPicker = memo(function ModelPicker({
>
<div className={css({ display: "inline-flex" })}>
<button
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
display: "flex",
alignItems: "center",
@ -153,14 +157,14 @@ export const ModelPicker = memo(function ModelPicker({
borderRadius: "6px",
fontSize: "12px",
fontWeight: 500,
color: t.textSecondary,
backgroundColor: t.borderDefault,
border: `1px solid ${t.borderMedium}`,
":hover": { color: t.textPrimary, backgroundColor: t.borderMedium },
color: t.textTertiary,
backgroundColor: "transparent",
transition: "background 200ms ease, color 200ms ease",
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
{modelLabel(value)}
{isOpen ? <ChevronDown size={11} /> : <ChevronUp size={11} />}
{(isHovered || isOpen) && <ChevronUp size={11} />}
</button>
</div>
</StatefulPopover>

View file

@ -52,6 +52,8 @@ export const Sidebar = memo(function Sidebar({
onRenameTask,
onRenameBranch,
onReorderProjects,
taskOrderByProject,
onReorderTasks,
onToggleSidebar,
}: {
projects: ProjectSection[];
@ -65,14 +67,89 @@ export const Sidebar = memo(function Sidebar({
onRenameTask: (id: string) => void;
onRenameBranch: (id: string) => void;
onReorderProjects: (fromIndex: number, toIndex: number) => void;
taskOrderByProject: Record<string, string[]>;
onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void;
onToggleSidebar?: () => void;
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
const contextMenu = useContextMenu();
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({});
const dragIndexRef = useRef<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [hoveredProjectId, setHoveredProjectId] = useState<string | null>(null);
// Mouse-based drag and drop state
type DragState =
| { type: "project"; fromIdx: number; overIdx: number | null }
| { type: "task"; projectId: string; fromIdx: number; overIdx: number | null }
| null;
const [drag, setDrag] = useState<DragState>(null);
const dragRef = useRef<DragState>(null);
const startYRef = useRef(0);
const didDragRef = useRef(false);
// Attach global mousemove/mouseup when dragging
useEffect(() => {
if (!drag) return;
const onMove = (e: MouseEvent) => {
// Detect which element is under the cursor using data attributes
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el) return;
const projectEl = (el as HTMLElement).closest?.("[data-project-idx]") as HTMLElement | null;
const taskEl = (el as HTMLElement).closest?.("[data-task-idx]") as HTMLElement | null;
if (drag.type === "project" && projectEl) {
const overIdx = Number(projectEl.dataset.projectIdx);
if (overIdx !== drag.overIdx) {
setDrag({ ...drag, overIdx });
dragRef.current = { ...drag, overIdx };
}
} else if (drag.type === "task" && taskEl) {
const overProjectId = taskEl.dataset.taskProjectId ?? "";
const overIdx = Number(taskEl.dataset.taskIdx);
if (overProjectId === drag.projectId && overIdx !== drag.overIdx) {
setDrag({ ...drag, overIdx });
dragRef.current = { ...drag, overIdx };
}
}
// Mark that we actually moved (to distinguish from clicks)
if (Math.abs(e.clientY - startYRef.current) > 4) {
didDragRef.current = true;
}
};
const onUp = () => {
const d = dragRef.current;
if (d && didDragRef.current && d.overIdx !== null && d.fromIdx !== d.overIdx) {
if (d.type === "project") {
onReorderProjects(d.fromIdx, d.overIdx);
} else {
onReorderTasks(d.projectId, d.fromIdx, d.overIdx);
}
}
dragRef.current = null;
didDragRef.current = false;
setDrag(null);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
}, [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]);
return (
<SPanel>
@ -155,123 +232,185 @@ export const Sidebar = memo(function Sidebar({
<PanelLeft size={14} />
</div>
) : null}
<div
role="button"
tabIndex={0}
aria-disabled={newTaskRepos.length === 0}
onClick={() => {
if (newTaskRepos.length === 0) {
return;
}
onCreate();
}}
onKeyDown={(event) => {
if (newTaskRepos.length === 0) {
return;
}
if (event.key === "Enter" || event.key === " ") onCreate();
}}
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 ref={createMenuRef} className={css({ position: "relative", flexShrink: 0 })}>
<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();
} else {
setCreateMenuOpen((prev) => !prev);
}
}}
onKeyDown={(event) => {
if (newTaskRepos.length === 0) return;
if (event.key === "Enter" || event.key === " ") {
if (newTaskRepos.length === 1) {
onSelectNewTaskRepo(newTaskRepos[0]!.id);
onCreate();
} else {
setCreateMenuOpen((prev) => !prev);
}
}
}}
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>
{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>
<div className={css({ padding: "0 8px 8px", display: "flex", flexDirection: "column", gap: "6px" })}>
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.04em" }}>
Repo
</LabelXSmall>
<select
value={selectedNewTaskRepoId}
disabled={newTaskRepos.length === 0}
onChange={(event) => {
onSelectNewTaskRepo(event.currentTarget.value);
}}
className={css({
width: "100%",
borderRadius: "8px",
border: `1px solid ${t.borderDefault}`,
backgroundColor: t.interactiveHover,
color: t.textPrimary,
fontSize: "12px",
padding: "8px 10px",
outline: "none",
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
})}
>
{newTaskRepos.length === 0 ? <option value="">No repos available</option> : null}
{newTaskRepos.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.label}
</option>
))}
</select>
</div>
<ScrollBody>
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
{projects.map((project, projectIndex) => {
const isCollapsed = collapsedProjects[project.id] === true;
const isDragOver = dragOverIndex === projectIndex && dragIndexRef.current !== projectIndex;
const isProjectDropTarget = drag?.type === "project" && drag.overIdx === projectIndex && drag.fromIdx !== projectIndex;
const isBeingDragged = drag?.type === "project" && drag.fromIdx === projectIndex && didDragRef.current;
const orderedTaskIds = taskOrderByProject[project.id];
const orderedTasks = orderedTaskIds
? (() => {
const byId = new Map(project.tasks.map((t) => [t.id, t]));
const sorted = orderedTaskIds.map((id) => byId.get(id)).filter(Boolean) as typeof project.tasks;
for (const t of project.tasks) {
if (!orderedTaskIds.includes(t.id)) sorted.push(t);
}
return sorted;
})()
: project.tasks;
return (
<div
key={project.id}
draggable
onDragStart={(event) => {
dragIndexRef.current = projectIndex;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(projectIndex));
}}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDragOverIndex(projectIndex);
}}
onDragLeave={() => {
setDragOverIndex((current) => (current === projectIndex ? null : current));
}}
onDrop={(event) => {
event.preventDefault();
const fromIndex = dragIndexRef.current;
if (fromIndex != null && fromIndex !== projectIndex) {
onReorderProjects(fromIndex, projectIndex);
}
dragIndexRef.current = null;
setDragOverIndex(null);
}}
onDragEnd={() => {
dragIndexRef.current = null;
setDragOverIndex(null);
}}
data-project-idx={projectIndex}
className={css({
display: "flex",
flexDirection: "column",
gap: "4px",
borderTop: isDragOver ? `2px solid ${t.accent}` : "2px solid transparent",
transition: "border-color 150ms ease",
position: "relative",
opacity: isBeingDragged ? 0.4 : 1,
transition: "opacity 150ms ease",
"::before": {
content: '""',
position: "absolute",
top: "-2px",
left: 0,
right: 0,
height: "2px",
backgroundColor: isProjectDropTarget ? t.textPrimary : "transparent",
transition: "background-color 100ms ease",
},
})}
>
<div
onClick={() =>
setCollapsedProjects((current) => ({
...current,
[project.id]: !current[project.id],
}))
}
onMouseEnter={() => setHoveredProjectId(project.id)}
onMouseLeave={() => setHoveredProjectId((cur) => (cur === project.id ? null : cur))}
onMouseDown={(event) => {
if (event.button !== 0) return;
startYRef.current = event.clientY;
didDragRef.current = false;
setHoveredProjectId(null);
const state: DragState = { type: "project", fromIdx: projectIndex, overIdx: null };
dragRef.current = state;
setDrag(state);
}}
onClick={() => {
if (!didDragRef.current) {
setCollapsedProjects((current) => ({
...current,
[project.id]: !current[project.id],
}));
}
}}
data-project-header
className={css({
display: "flex",
@ -281,7 +420,6 @@ export const Sidebar = memo(function Sidebar({
gap: "8px",
cursor: "grab",
userSelect: "none",
":hover": { opacity: 0.8 },
})}
>
<div className={css({ display: "flex", alignItems: "center", gap: "4px", overflow: "hidden" })}>
@ -323,11 +461,43 @@ export const Sidebar = memo(function Sidebar({
{project.label}
</LabelSmall>
</div>
{isCollapsed ? <LabelXSmall color={t.textTertiary}>{formatRelativeAge(project.updatedAtMs)}</LabelXSmall> : null}
<div className={css({ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
{isCollapsed ? <LabelXSmall color={t.textTertiary}>{formatRelativeAge(project.updatedAtMs)}</LabelXSmall> : null}
<button
onClick={(e) => {
e.stopPropagation();
setHoveredProjectId(null);
onSelectNewTaskRepo(project.id);
onCreate();
}}
onMouseDown={(e) => e.stopPropagation()}
className={css({
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: "26px",
height: "26px",
borderRadius: "6px",
border: "none",
background: "none",
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",
pointerEvents: hoveredProjectId === project.id ? "auto" : "none",
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
})}
title={`New task in ${project.label}`}
>
<Plus size={12} color={t.textTertiary} />
</button>
</div>
</div>
{!isCollapsed &&
project.tasks.map((task) => {
orderedTasks.map((task, taskIndex) => {
const isActive = task.id === activeId;
const isDim = task.status === "archived";
const isRunning = task.tabs.some((tab) => tab.status === "running");
@ -336,11 +506,30 @@ export const Sidebar = memo(function Sidebar({
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0);
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
const isTaskDropTarget = drag?.type === "task" && drag.projectId === project.id && drag.overIdx === taskIndex && drag.fromIdx !== taskIndex;
const isTaskBeingDragged = drag?.type === "task" && drag.projectId === project.id && drag.fromIdx === taskIndex && didDragRef.current;
return (
<div
key={task.id}
onClick={() => onSelect(task.id)}
data-task-idx={taskIndex}
data-task-project-id={project.id}
onMouseDown={(event) => {
if (event.button !== 0) return;
// Only start task drag if not already in a project drag
if (dragRef.current) return;
event.stopPropagation();
startYRef.current = event.clientY;
didDragRef.current = false;
const state: DragState = { type: "task", projectId: project.id, fromIdx: taskIndex, overIdx: null };
dragRef.current = state;
setDrag(state);
}}
onClick={() => {
if (!didDragRef.current) {
onSelect(task.id);
}
}}
onContextMenu={(event) =>
contextMenu.open(event, [
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
@ -351,10 +540,21 @@ export const Sidebar = memo(function Sidebar({
className={css({
padding: "8px 12px",
borderRadius: "8px",
border: "1px solid transparent",
position: "relative",
backgroundColor: isActive ? t.interactiveHover : "transparent",
opacity: isTaskBeingDragged ? 0.4 : 1,
cursor: "pointer",
transition: "all 200ms ease",
transition: "all 150ms ease",
"::before": {
content: '""',
position: "absolute",
top: "-2px",
left: 0,
right: 0,
height: "2px",
backgroundColor: isTaskDropTarget ? t.textPrimary : "transparent",
transition: "background-color 100ms ease",
},
":hover": {
backgroundColor: t.interactiveHover,
},
@ -410,9 +610,52 @@ export const Sidebar = memo(function Sidebar({
</div>
);
})}
{/* Bottom drop zone for dragging to end of task list */}
{!isCollapsed && (
<div
data-task-idx={orderedTasks.length}
data-task-project-id={project.id}
className={css({
minHeight: "4px",
position: "relative",
"::before": {
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "2px",
backgroundColor:
drag?.type === "task" && drag.projectId === project.id && drag.overIdx === orderedTasks.length && drag.fromIdx !== orderedTasks.length
? t.textPrimary
: "transparent",
transition: "background-color 100ms ease",
},
})}
/>
)}
</div>
);
})}
{/* Bottom drop zone for dragging project to end of list */}
<div
data-project-idx={projects.length}
className={css({
minHeight: "4px",
position: "relative",
"::before": {
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "2px",
backgroundColor:
drag?.type === "project" && drag.overIdx === projects.length && drag.fromIdx !== projects.length ? t.textPrimary : "transparent",
transition: "background-color 100ms ease",
},
})}
/>
</div>
</ScrollBody>
<SidebarFooter />
@ -450,7 +693,6 @@ function SidebarFooter() {
const [workspaceFlyoutOpen, setWorkspaceFlyoutOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const flyoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const workspaceTriggerRef = useRef<HTMLDivElement>(null);
const flyoutRef = useRef<HTMLDivElement>(null);
const [flyoutPos, setFlyoutPos] = useState<{ top: number; left: number } | null>(null);
@ -469,7 +711,6 @@ function SidebarFooter() {
const inContainer = containerRef.current?.contains(target);
const inFlyout = flyoutRef.current?.contains(target);
if (!inContainer && !inFlyout) {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
setOpen(false);
setWorkspaceFlyoutOpen(false);
}
@ -557,21 +798,7 @@ function SidebarFooter() {
});
return (
<div
ref={containerRef}
onMouseEnter={() => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = setTimeout(() => setOpen(true), 300);
}}
onMouseLeave={() => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = setTimeout(() => {
setOpen(false);
setWorkspaceFlyoutOpen(false);
}, 200);
}}
className={css({ position: "relative", flexShrink: 0 })}
>
<div ref={containerRef} className={css({ position: "relative", flexShrink: 0 })}>
{open ? (
<div
className={css({
@ -638,14 +865,9 @@ function SidebarFooter() {
})}
onMouseEnter={() => {
openFlyout();
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
}}
onMouseLeave={() => {
closeFlyout();
hoverTimerRef.current = setTimeout(() => {
setOpen(false);
setWorkspaceFlyoutOpen(false);
}, 200);
}}
>
<div className={popoverStyle}>
@ -726,7 +948,6 @@ function SidebarFooter() {
<button
type="button"
onClick={() => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
setOpen((prev) => {
if (prev) setWorkspaceFlyoutOpen(false);
return !prev;

View file

@ -1,7 +1,7 @@
import { memo } from "react";
import { useStyletron } from "baseui";
import { LabelSmall } from "baseui/typography";
import { Clock, MailOpen, PanelLeft, PanelRight } from "lucide-react";
import { Clock, PanelLeft, PanelRight } from "lucide-react";
import { useFoundryTokens } from "../../app/theme";
import { PanelHeaderBar } from "./ui";
@ -23,6 +23,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
onSidebarPeekEnd,
rightSidebarCollapsed,
onToggleRightSidebar,
onNavigateToUsage,
}: {
task: Task;
activeTab: AgentTab | null | undefined;
@ -39,6 +40,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
onSidebarPeekEnd?: () => void;
rightSidebarCollapsed?: boolean;
onToggleRightSidebar?: () => void;
onNavigateToUsage?: () => void;
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
@ -161,52 +163,32 @@ export const TranscriptHeader = memo(function TranscriptHeader({
) : null}
<div className={css({ flex: 1 })} />
<div
role="button"
tabIndex={0}
onClick={onNavigateToUsage}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onNavigateToUsage?.();
}}
className={css({
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "3px 10px",
padding: "4px 12px",
borderRadius: "6px",
backgroundColor: t.interactiveHover,
border: `1px solid ${t.borderSubtle}`,
backgroundColor: "transparent",
fontSize: "11px",
fontWeight: 500,
lineHeight: 1,
color: t.textSecondary,
color: t.textTertiary,
whiteSpace: "nowrap",
cursor: "pointer",
transition: "background 200ms ease, color 200ms ease",
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
})}
>
<Clock size={11} style={{ flexShrink: 0 }} />
<span>847 min used</span>
<span>{task.minutesUsed ?? 0} min used</span>
</div>
{activeTab ? (
<button
onClick={() => onSetActiveTabUnread(!activeTab.unread)}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "4px 10px",
borderRadius: "6px",
fontSize: "11px",
fontWeight: 500,
lineHeight: 1,
color: t.textSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
})}
>
<MailOpen size={12} style={{ flexShrink: 0 }} />{" "}
<span className={css({ "@media screen and (max-width: 768px)": { display: "none" } })}>{activeTab.unread ? "Mark read" : "Mark unread"}</span>
</button>
) : null}
{rightSidebarCollapsed && onToggleRightSidebar ? (
<div
className={css({