diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 31632ef26cf..8a713fd9026 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -164,14 +164,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { - {/* The submit target leads the run-context strip: this is the one - surface where "which project am I about to send this to" must be - answered before the first keystroke, especially on drafts. - Mirrors the sidebar cards' title bar — favicon + mono name. */} - - - - {activeProject.title} - - - {isMobile ? ( > = { - approval: "bg-amber-500 dark:bg-amber-400", - input: "bg-amber-500 dark:bg-amber-400", - working: "bg-sky-500 animate-status-pulse dark:bg-sky-400", - failed: "bg-red-500", -}; - // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; -const STATUS_WORD_BY_STATUS: Partial< - Record -> = { - approval: { label: "Needs approval", className: "text-amber-600 dark:text-amber-400" }, - input: { label: "Awaiting input", className: "text-amber-600 dark:text-amber-400" }, - working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, - failed: { label: "Failed", className: "text-red-600 dark:text-red-400" }, -}; - -// The working timer re-renders once per second only for rows that show it. -function useTickWhile(active: boolean): number { - const [, setTick] = useState(0); - useEffect(() => { - if (!active) return; - const id = window.setInterval(() => setTick((value) => value + 1), 1_000); - return () => window.clearInterval(id); - }, [active]); - return active ? Date.now() : 0; +function compactSidebarTimeLabel(label: string): string { + if (label === "just now") return "now"; + return label.endsWith(" ago") ? label.slice(0, -4) : label; } -function threadTimeLabel(thread: SidebarThreadSummary, status: SidebarV2Status): string { - if (status === "working" && thread.latestTurn?.startedAt) { - return formatElapsedDurationLabel(thread.latestTurn.startedAt); - } - if (status === "approval" || status === "input") { - // Approval/input activities bump shell.updatedAt in the projection - // pipeline. The shell has no dedicated request timestamp, so this is the - // closest accurate wait-start signal shared by the label and ordering. - return `waiting ${formatElapsedDurationLabel(thread.updatedAt)}`; - } +function threadTimeLabel(thread: SidebarThreadSummary): string { const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; - return formatRelativeTimeLabel(timestamp); + return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } const SidebarV2Row = memo(function SidebarV2Row(props: { @@ -152,10 +126,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; - // Draws a hairline above the first settled row after a group's card - // block, so active work and the history tail read as separate zones - // inside each project section. - showQuietDivider?: boolean; + // Adds a little breathing room where active work transitions into history. + showSettledGap?: boolean; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -202,10 +174,44 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); + // Same semantics as v1 (never-visited counts as read): flipping the beta + // flag must not light up every historical thread as unread. + const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); - useTickWhile( - variant === "card" && (status === "working" || status === "approval" || status === "input"), - ); + const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected; + const topStatus = + status === "working" + ? { + label: "Working", + icon: "working" as const, + className: + "animate-sidebar-working-text font-semibold text-blue-600 motion-reduce:animate-none dark:text-blue-400", + } + : status === "approval" + ? { + label: "Approval", + icon: null, + className: "font-semibold text-amber-700 dark:text-amber-300", + } + : status === "input" + ? { + label: "Input", + icon: null, + className: "font-semibold text-amber-700 dark:text-amber-300", + } + : status === "failed" + ? { + label: "Failed", + icon: null, + className: "font-semibold text-red-700 dark:text-red-300", + } + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "font-semibold text-emerald-700 dark:text-emerald-300", + } + : null; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -228,10 +234,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const driverKind = props.providerEntryByInstanceId.get(modelInstanceId)?.driverKind ?? null; - // Same semantics as v1 (never-visited counts as read): flipping the beta - // flag must not light up every historical thread as unread. - const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); - const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; @@ -316,12 +318,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ); const rowClassName = cn( - "group/v2-row relative w-full cursor-pointer select-none text-left", + "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left", props.isActive - ? "bg-foreground/10 dark:bg-white/[0.12]" + ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]" : isSelected - ? "bg-primary/15 dark:bg-primary/20" - : "hover:bg-foreground/5 dark:hover:bg-white/[0.06]", + ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]" + : "hover:bg-accent/65", ); const title = isRenaming ? ( @@ -335,17 +337,22 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onBlur={handleRenameBlur} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} - className="min-w-0 flex-1 border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-foreground" + className="min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-foreground" /> ) : ( #{pr.number} @@ -377,16 +391,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { return (
  • - {props.showQuietDivider ? ( -
    - - Settled - - -
    - ) : null}
    {title} - {prBadge} - - - {props.jumpLabel ?? - formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt)} + + + {props.jumpLabel ? ( + {props.jumpLabel} + ) : ( + (prBadge ?? ( + + {compactSidebarTimeLabel( + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), + )} + + )) + )} {!props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -433,7 +450,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > @@ -444,124 +461,112 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ); } - const statusEdge = CARD_EDGE_BY_STATUS[status]; - const statusWord = STATUS_WORD_BY_STATUS[status]; const diff = latestTurnDiff(thread); - const workingTimer = - status === "working" && thread.latestTurn?.startedAt - ? formatElapsedDurationLabel(thread.latestTurn.startedAt) - : null; return (
  • - {statusEdge ? ( - - ) : null} - {/* Title bar: the card is a tiny window whose chrome carries project - identity — favicon + name up top, separated by a hairline, so - "which project" reads before the thread content does. */} -
    - - {props.projectTitle ? ( - - {props.projectTitle} - - ) : null} - - - {props.jumpLabel ?? workingTimer ?? threadTimeLabel(thread, status)} - - {props.settlementSupported ? ( - - ) : null} - -
    -
    -
    - {title} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} -
    -
    - {statusWord ? ( +
    +
    + + {props.projectTitle ? ( - {statusWord.label} - - ) : null} - {status === "failed" && thread.session?.lastError ? ( - - {thread.session.lastError} + {props.projectTitle} ) : ( - <> - {thread.branch ? ( - - {thread.branch} + + )} + + + {props.jumpLabel ? ( + props.jumpLabel + ) : topStatus ? ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {topStatus.label} - ) : null} - {prBadge} - + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported ? ( + + ) : null} + +
    +
    {title}
    +
    + {thread.branch ? ( + + {thread.branch} + + ) : ( + )} - + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + {driverKind ? ( } + render={} > (null); + const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false); + const updateProjectScrollFade = useCallback(() => { + const scroller = projectScrollerRef.current; + if (!scroller) return; + setCanScrollProjectsRight( + scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, + ); + }, []); + useEffect(() => { + const scroller = projectScrollerRef.current; + if (!scroller) return; + + updateProjectScrollFade(); + const resizeObserver = new ResizeObserver(updateProjectScrollFade); + resizeObserver.observe(scroller); + return () => resizeObserver.disconnect(); + }, [projects, updateProjectScrollFade]); return ( <> - - - + + + } @@ -1380,88 +1405,99 @@ export default function SidebarV2() { ) : null} - + - - New thread - {newThreadShortcutLabel ? ( - - {newThreadShortcutLabel} - - ) : null} + {projects.length > 0 ? ( - -
    - {projects.length > 1 ? ( - - ) : null} - {projects.map((project) => { - const scopeKey = `${project.environmentId}:${project.id}`; - const isScoped = projectScopeKey === scopeKey; - return ( + +
    +
    + {projects.length > 1 ? ( - ); - })} - - { + const scopeKey = `${project.environmentId}:${project.id}`; + const isScoped = projectScopeKey === scopeKey; + return ( + ); + })} +
    +
    + + + } + > + + + Add project + +
    ) : null} @@ -1481,10 +1517,10 @@ export default function SidebarV2() { !settledThreadKeys.has( scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), ); - const showQuietDivider = !isCard && previousWasCard; + const showSettledGap = !isCard && previousWasCard; return ( Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more @@ -1544,7 +1580,7 @@ export default function SidebarV2() {