From f97773b9396a4f57effc657567eac0c53627ff36 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 22 Jul 2026 02:20:27 -0400 Subject: [PATCH 1/4] fix(web): simplify project chrome labels --- apps/web/src/components/BranchToolbar.tsx | 16 ---------------- apps/web/src/components/chat/ChatHeader.tsx | 9 ++++----- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 62dde6a90b7..0354c2e0cd7 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -25,7 +25,6 @@ import { import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; -import { ProjectFavicon } from "./ProjectFavicon"; import { Button } from "./ui/button"; import { Menu, @@ -250,21 +249,6 @@ export const BranchToolbar = memo(function BranchToolbar({ return (
- {/* 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 ? ( {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone - doesn't answer it. Mirrors the sidebar cards' title bar — - favicon + mono uppercase name. */} + doesn't answer it. */} {activeProjectName ? ( - + - + {activeProjectName} From f2b297e4c73a0ca6b278049be4aa27ad17de8344 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 22 Jul 2026 02:20:36 -0400 Subject: [PATCH 2/4] fix(web): round the chat composer shell --- apps/web/src/components/chat/ChatComposer.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d3038a96de3..94e4af3bba6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2127,7 +2127,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) >
{!isComposerCollapsedMobile && (activePendingApproval ? ( -
+
) : pendingUserInputs.length > 0 ? ( -
+
) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
+
) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
Date: Wed, 22 Jul 2026 02:20:44 -0400 Subject: [PATCH 3/4] fix(web): give the app sidebar its own surface palette --- apps/web/src/components/AppSidebarLayout.tsx | 12 ++++-------- apps/web/src/index.css | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) 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 }) { Date: Wed, 22 Jul 2026 02:20:57 -0400 Subject: [PATCH 4/4] fix(web): polish sidebar v2 cards and filters --- apps/web/src/components/Sidebar.logic.ts | 4 +- apps/web/src/components/SidebarV2.tsx | 510 ++++++++++++----------- apps/web/src/index.css | 37 +- 3 files changed, 310 insertions(+), 241 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 305b7e8a35e..b8ae19c3e5f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -418,10 +418,12 @@ export function resolveThreadRowClassName(input: { } // ── Sidebar v2 status model ───────────────────────────────────────── -// Four visual states, three colors: color is reserved for "act now" +// Five visual states, three colors: color is reserved for "act now" // (approval), "in motion" (working), and "broken" (failed). Ready is the // unlabeled resting state — the agent stopped and is waiting on the user, // whether it finished, asked a question, or proposed a plan. +// Unread completion is tracked separately: it describes whether a ready +// thread needs attention, not what the thread is currently doing. export type SidebarV2Status = "approval" | "input" | "working" | "failed" | "ready"; type SidebarV2StatusInput = Pick< diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index d20bcbe2376..daa594f5c46 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -8,7 +8,17 @@ import { scopedThreadKey, } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import { CheckIcon, CloudIcon, PlusIcon, SearchIcon, Undo2Icon } from "lucide-react"; +import { + CheckIcon, + CircleCheckIcon, + CircleDashedIcon, + CloudIcon, + FolderPlusIcon, + PlusIcon, + SearchIcon, + SquarePenIcon, + Undo2Icon, +} from "lucide-react"; import { memo, useCallback, @@ -61,7 +71,7 @@ import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; -import { formatElapsedDurationLabel, formatRelativeTimeLabel } from "../timestampFormat"; +import { formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { @@ -71,7 +81,6 @@ import { resolveAdjacentThreadId, resolveSidebarV2Status, sortThreadsForSidebarV2, - type SidebarV2Status, } from "./Sidebar.logic"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; @@ -93,54 +102,19 @@ import { import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -// Row heights are fixed per variant so the list only changes shape at -// lifecycle transitions (settle/unsettle), never from streaming updates. -// Cards are square, hard-edged blocks: a solid full-saturation edge strip -// carries the state color; the border stays neutral and high-contrast. -const CARD_EDGE_BY_STATUS: Partial> = { - 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() {