mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 14:01:09 +00:00
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:
parent
f09b9090bb
commit
fde8b481bd
20 changed files with 4164 additions and 1018 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue