From 277c514b393a8cda8d6e1e62c41a396e91c2996f Mon Sep 17 00:00:00 2001
From: sherlock
Date: Mon, 13 Apr 2026 03:25:40 +0530
Subject: [PATCH 1/3] feat: add chat UI components from dpcode
Ports chat UI improvements from the dpcode fork:
- Chat typography constants (chatTypography.ts)
- Composer picker styles and trigger button
- Chat empty state hero graphic
- Image attachment chip with inline preview
- Active plan card for agent-generated plans
- Auto-scroll controller hook
- Slash command status dialog
- Tool call label formatting utility
---
.../src/components/chat/ActivePlanCard.tsx | 122 ++++++
.../components/chat/ChatEmptyStateHero.tsx | 25 ++
.../chat/ComposerImageAttachmentChip.test.tsx | 47 +++
.../chat/ComposerImageAttachmentChip.tsx | 87 +++++
.../chat/ComposerSlashStatusDialog.tsx | 187 +++++++++
.../components/chat/PickerTriggerButton.tsx | 45 +++
.../web/src/components/chat/chatTypography.ts | 36 ++
.../components/chat/composerPickerStyles.ts | 9 +
.../chat/useChatAutoScrollController.ts | 368 ++++++++++++++++++
apps/web/src/lib/toolCallLabel.test.ts | 73 ++++
apps/web/src/lib/toolCallLabel.ts | 281 +++++++++++++
11 files changed, 1280 insertions(+)
create mode 100644 apps/web/src/components/chat/ActivePlanCard.tsx
create mode 100644 apps/web/src/components/chat/ChatEmptyStateHero.tsx
create mode 100644 apps/web/src/components/chat/ComposerImageAttachmentChip.test.tsx
create mode 100644 apps/web/src/components/chat/ComposerImageAttachmentChip.tsx
create mode 100644 apps/web/src/components/chat/ComposerSlashStatusDialog.tsx
create mode 100644 apps/web/src/components/chat/PickerTriggerButton.tsx
create mode 100644 apps/web/src/components/chat/chatTypography.ts
create mode 100644 apps/web/src/components/chat/composerPickerStyles.ts
create mode 100644 apps/web/src/components/chat/useChatAutoScrollController.ts
create mode 100644 apps/web/src/lib/toolCallLabel.test.ts
create mode 100644 apps/web/src/lib/toolCallLabel.ts
diff --git a/apps/web/src/components/chat/ActivePlanCard.tsx b/apps/web/src/components/chat/ActivePlanCard.tsx
new file mode 100644
index 0000000000..42f33e4ada
--- /dev/null
+++ b/apps/web/src/components/chat/ActivePlanCard.tsx
@@ -0,0 +1,122 @@
+// FILE: ActivePlanCard.tsx
+// Purpose: Renders the skinny inline checklist for active turn plans above the composer.
+// Layer: Chat UI component
+// Depends on: session-logic active plan state and shared button/icon primitives
+
+import { memo } from "react";
+
+import type { ActivePlanState } from "../../session-logic";
+import { BotIcon, CheckIcon, ChevronRightIcon, ListTodoIcon, LoaderIcon } from "~/lib/icons";
+import { cn } from "~/lib/utils";
+import { Button } from "../ui/button";
+
+interface ActivePlanCardProps {
+ activePlan: ActivePlanState;
+ backgroundTaskCount?: number;
+ onOpenSidebar: () => void;
+}
+
+function stepStatusIcon(status: ActivePlanState["steps"][number]["status"]) {
+ if (status === "completed") {
+ return ;
+ }
+ if (status === "inProgress") {
+ return ;
+ }
+ return ;
+}
+
+export const ActivePlanCard = memo(function ActivePlanCard({
+ activePlan,
+ backgroundTaskCount = 0,
+ onOpenSidebar,
+}: ActivePlanCardProps) {
+ const totalCount = activePlan.steps.length;
+ const completedCount = activePlan.steps.filter((step) => step.status === "completed").length;
+ const stepOccurrenceCount = new Map();
+
+ return (
+
+
+
+
+
+
+ {completedCount} out of {totalCount} tasks completed
+
+
+
+
+
+
+ {activePlan.steps.map((step, index) => {
+ const occurrence = (stepOccurrenceCount.get(step.step) ?? 0) + 1;
+ stepOccurrenceCount.set(step.step, occurrence);
+
+ return (
+ -
+
+
+ {stepStatusIcon(step.status)}
+
+ {index + 1}.
+
+
+ {step.step}
+
+
+ );
+ })}
+
+
+ {backgroundTaskCount > 0 ? (
+
+
+
+
+ {backgroundTaskCount} background agent{backgroundTaskCount === 1 ? "" : "s"}
+
+
+
+
+ ) : null}
+
+
+ );
+});
+
+export type { ActivePlanCardProps };
diff --git a/apps/web/src/components/chat/ChatEmptyStateHero.tsx b/apps/web/src/components/chat/ChatEmptyStateHero.tsx
new file mode 100644
index 0000000000..c09cb801d3
--- /dev/null
+++ b/apps/web/src/components/chat/ChatEmptyStateHero.tsx
@@ -0,0 +1,25 @@
+import { memo } from "react";
+
+export const ChatEmptyStateHero = memo(function ChatEmptyStateHero({
+ projectName,
+}: {
+ projectName: string | undefined;
+}) {
+ return (
+
+

+
+
+
Let's build
+ {projectName && {projectName}}
+
+
+ );
+});
diff --git a/apps/web/src/components/chat/ComposerImageAttachmentChip.test.tsx b/apps/web/src/components/chat/ComposerImageAttachmentChip.test.tsx
new file mode 100644
index 0000000000..caa0c865c6
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerImageAttachmentChip.test.tsx
@@ -0,0 +1,47 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import { ComposerImageAttachmentChip } from "./ComposerImageAttachmentChip";
+
+describe("ComposerImageAttachmentChip", () => {
+ it("renders a filename-first pill instead of the old thumbnail tile", () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ onRemoveImage={() => {}}
+ />,
+ );
+
+ expect(markup).toContain("CleanShot 2026-04-11 at 20.00.33@2x.png");
+ expect(markup).toContain("rounded-full");
+ expect(markup).toContain("Preview CleanShot 2026-04-11 at 20.00.33@2x.png");
+ expect(markup).toContain("Remove CleanShot 2026-04-11 at 20.00.33@2x.png");
+ expect(markup).not.toContain("absolute right-1 top-1");
+ expect(markup).not.toContain("h-14 w-14");
+ });
+});
diff --git a/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx b/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx
new file mode 100644
index 0000000000..4eb8dd3542
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx
@@ -0,0 +1,87 @@
+// FILE: ComposerImageAttachmentChip.tsx
+// Purpose: Renders filename-first composer image attachments as compact pills with preview/remove actions.
+// Layer: Chat composer presentation
+// Depends on: composer draft image metadata, shared chip styles, and expanded image preview helpers.
+
+import { memo } from "react";
+import { type ComposerImageAttachment } from "../../composerDraftStore";
+import { CircleAlertIcon, XIcon } from "~/lib/icons";
+import { cn } from "~/lib/utils";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import { COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME } from "../composerInlineChip";
+import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview";
+
+interface ComposerImageAttachmentChipProps {
+ image: ComposerImageAttachment;
+ images: readonly ComposerImageAttachment[];
+ nonPersisted: boolean;
+ onExpandImage: (preview: ExpandedImagePreview) => void;
+ onRemoveImage: (imageId: string) => void;
+}
+
+export const ComposerImageAttachmentChip = memo(function ComposerImageAttachmentChip({
+ image,
+ images,
+ nonPersisted,
+ onExpandImage,
+ onRemoveImage,
+}: ComposerImageAttachmentChipProps) {
+ return (
+
+
+
+ {nonPersisted && (
+
+
+
+
+ }
+ />
+
+ Draft attachment could not be saved locally and may be lost on navigation.
+
+
+ )}
+
+
+
+ );
+});
diff --git a/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx b/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx
new file mode 100644
index 0000000000..100f972429
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx
@@ -0,0 +1,187 @@
+import type { ResolvedThreadWorkspaceState } from "@t3tools/shared/threadEnvironment";
+import type { ProviderInteractionMode } from "@t3tools/contracts";
+import type { DraftThreadEnvMode } from "../../composerDraftStore";
+import {
+ type ContextWindowSnapshot,
+ formatContextWindowTokens,
+ formatCostUsd,
+} from "../../lib/contextWindow";
+import type { RateLimitStatus } from "./RateLimitBanner";
+import { Button } from "../ui/button";
+import {
+ Dialog,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} from "../ui/dialog";
+import { ContextWindowMeter } from "./ContextWindowMeter";
+
+function formatRateLimitMessage(rateLimitStatus: RateLimitStatus): string {
+ const resetSuffix = rateLimitStatus.resetsAt
+ ? ` Resets at ${new Date(rateLimitStatus.resetsAt).toLocaleTimeString()}.`
+ : "";
+ if (rateLimitStatus.status === "rejected") {
+ return `Rate limit reached.${resetSuffix}`;
+ }
+ const utilizationSuffix =
+ typeof rateLimitStatus.utilization === "number"
+ ? ` (${Math.round(rateLimitStatus.utilization * 100)}% used)`
+ : "";
+ return `Approaching rate limit${utilizationSuffix}.${resetSuffix}`;
+}
+
+function formatEnvironmentLabel(
+ envMode: DraftThreadEnvMode,
+ envState: ResolvedThreadWorkspaceState,
+): string {
+ if (envMode === "local") {
+ return "Local";
+ }
+ return envState === "worktree-pending" ? "New worktree (pending)" : "Worktree";
+}
+
+export function ComposerSlashStatusDialog(props: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedModel: string | null | undefined;
+ fastModeEnabled: boolean;
+ selectedPromptEffort: string | null;
+ interactionMode: ProviderInteractionMode;
+ envMode: DraftThreadEnvMode;
+ envState: ResolvedThreadWorkspaceState;
+ branch: string | null;
+ contextWindow: ContextWindowSnapshot | null;
+ cumulativeCostUsd: number | null;
+ rateLimitStatus: RateLimitStatus | null;
+}) {
+ const {
+ open,
+ onOpenChange,
+ selectedModel,
+ fastModeEnabled,
+ selectedPromptEffort,
+ interactionMode,
+ envMode,
+ envState,
+ branch,
+ contextWindow,
+ cumulativeCostUsd,
+ rateLimitStatus,
+ } = props;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/chat/PickerTriggerButton.tsx b/apps/web/src/components/chat/PickerTriggerButton.tsx
new file mode 100644
index 0000000000..a02deee552
--- /dev/null
+++ b/apps/web/src/components/chat/PickerTriggerButton.tsx
@@ -0,0 +1,45 @@
+// FILE: PickerTriggerButton.tsx
+// Purpose: Shares the trigger shell used by chat picker-style menus in the header and composer.
+// Layer: Chat shell controls
+// Depends on: button primitives, shared picker text styles, and icon slots supplied by callers.
+
+import { type ComponentProps, type ReactNode } from "react";
+import { ChevronDownIcon } from "~/lib/icons";
+import { cn } from "~/lib/utils";
+import { Button } from "../ui/button";
+import { COMPOSER_PICKER_TRIGGER_TEXT_CLASS_NAME } from "./composerPickerStyles";
+
+export function PickerTriggerButton(
+ props: {
+ icon: ReactNode;
+ label: ReactNode;
+ compact?: boolean;
+ } & Omit, "children" | "size" | "variant">,
+) {
+ const { icon, label, compact, className, ...buttonProps } = props;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/chat/chatTypography.ts b/apps/web/src/components/chat/chatTypography.ts
new file mode 100644
index 0000000000..54b74e3152
--- /dev/null
+++ b/apps/web/src/components/chat/chatTypography.ts
@@ -0,0 +1,36 @@
+// FILE: chatTypography.ts
+// Purpose: Centralizes transcript typography tokens shared by chat message renderers.
+// Layer: Web chat presentation constants
+// Exports: transcript measurement helpers and inline styles for chat text
+
+import type { CSSProperties } from "react";
+import { DEFAULT_CHAT_FONT_SIZE_PX, normalizeChatFontSizePx } from "../../appSettings";
+
+const CHAT_TRANSCRIPT_USER_CHAR_WIDTH_RATIO = 0.6;
+const CHAT_TRANSCRIPT_ASSISTANT_CHAR_WIDTH_RATIO = 0.52;
+
+export function getChatTranscriptLineHeightPx(chatFontSizePx = DEFAULT_CHAT_FONT_SIZE_PX): number {
+ return normalizeChatFontSizePx(chatFontSizePx) + 8;
+}
+
+export function getChatTranscriptUserCharWidthPx(
+ chatFontSizePx = DEFAULT_CHAT_FONT_SIZE_PX,
+): number {
+ return normalizeChatFontSizePx(chatFontSizePx) * CHAT_TRANSCRIPT_USER_CHAR_WIDTH_RATIO;
+}
+
+export function getChatTranscriptAssistantCharWidthPx(
+ chatFontSizePx = DEFAULT_CHAT_FONT_SIZE_PX,
+): number {
+ return normalizeChatFontSizePx(chatFontSizePx) * CHAT_TRANSCRIPT_ASSISTANT_CHAR_WIDTH_RATIO;
+}
+
+export function getChatTranscriptTextStyle(
+ chatFontSizePx = DEFAULT_CHAT_FONT_SIZE_PX,
+): CSSProperties {
+ const normalizedChatFontSizePx = normalizeChatFontSizePx(chatFontSizePx);
+ return {
+ fontSize: `${normalizedChatFontSizePx}px`,
+ lineHeight: `${getChatTranscriptLineHeightPx(normalizedChatFontSizePx)}px`,
+ };
+}
diff --git a/apps/web/src/components/chat/composerPickerStyles.ts b/apps/web/src/components/chat/composerPickerStyles.ts
new file mode 100644
index 0000000000..ae32bde3cd
--- /dev/null
+++ b/apps/web/src/components/chat/composerPickerStyles.ts
@@ -0,0 +1,9 @@
+// FILE: composerPickerStyles.ts
+// Purpose: Shares typography tokens for the chat composer pickers.
+// Layer: UI styling helper for chat controls.
+// Exports: COMPOSER_PICKER_TRIGGER_TEXT_CLASS_NAME
+
+// Uses the UI-sm token so picker labels sit slightly below the editor text size.
+// The sm: override is required to beat the Button component's base responsive text classes.
+export const COMPOSER_PICKER_TRIGGER_TEXT_CLASS_NAME =
+ "text-[length:var(--app-font-size-ui-sm,11px)] sm:text-[length:var(--app-font-size-ui-sm,11px)] font-normal text-muted-foreground/70 hover:text-foreground/80";
diff --git a/apps/web/src/components/chat/useChatAutoScrollController.ts b/apps/web/src/components/chat/useChatAutoScrollController.ts
new file mode 100644
index 0000000000..95c0cd5b0b
--- /dev/null
+++ b/apps/web/src/components/chat/useChatAutoScrollController.ts
@@ -0,0 +1,368 @@
+// FILE: useChatAutoScrollController.ts
+// Purpose: Own the chat scroll state machine for auto-stick, user scroll intent, and button-driven jumps.
+// Layer: UI hook
+// Exports: useChatAutoScrollController
+// Depends on: chat-scroll helpers and ChatView's message scroll container.
+
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type MouseEvent,
+ type PointerEvent,
+ type TouchEvent,
+ type WheelEvent,
+} from "react";
+
+import { isScrollContainerNearBottom } from "../../chat-scroll";
+
+interface UseChatAutoScrollControllerOptions {
+ threadId: string | null;
+ isStreaming: boolean;
+ messageCount: number;
+}
+
+interface UseChatAutoScrollControllerResult {
+ messagesScrollElement: HTMLDivElement | null;
+ showScrollToBottom: boolean;
+ setMessagesBottomAnchorRef: (element: HTMLDivElement | null) => void;
+ setMessagesScrollContainerRef: (element: HTMLDivElement | null) => void;
+ forceStickToBottom: (behavior?: ScrollBehavior) => void;
+ onTimelineHeightChange: () => void;
+ onComposerHeightChange: (previousHeight: number, nextHeight: number) => void;
+ onMessagesClickCapture: (event: MouseEvent) => void;
+ onMessagesPointerCancel: (event: PointerEvent) => void;
+ onMessagesPointerDown: (event: PointerEvent) => void;
+ onMessagesPointerUp: (event: PointerEvent) => void;
+ onMessagesScroll: () => void;
+ onMessagesTouchEnd: (event: TouchEvent) => void;
+ onMessagesTouchMove: (event: TouchEvent) => void;
+ onMessagesTouchStart: (event: TouchEvent) => void;
+ onMessagesWheel: (event: WheelEvent) => void;
+}
+
+// Keeps all auto-scroll heuristics in one place so ChatView can stay focused on orchestration.
+export function useChatAutoScrollController(
+ options: UseChatAutoScrollControllerOptions,
+): UseChatAutoScrollControllerResult {
+ const messagesScrollRef = useRef(null);
+ const messagesBottomAnchorRef = useRef(null);
+ const [messagesScrollElement, setMessagesScrollElement] = useState(null);
+ const [showScrollToBottom, setShowScrollToBottom] = useState(false);
+
+ const shouldAutoScrollRef = useRef(true);
+ const lastKnownScrollTopRef = useRef(0);
+ const isPointerScrollActiveRef = useRef(false);
+ const lastTouchClientYRef = useRef(null);
+ const pendingUserScrollUpIntentRef = useRef(false);
+ const pendingAutoScrollFrameRef = useRef(null);
+ const pendingScrollFrameRef = useRef(null);
+ const pendingInteractionAnchorRef = useRef<{
+ element: HTMLElement;
+ top: number;
+ } | null>(null);
+ const pendingInteractionAnchorFrameRef = useRef(null);
+ const showScrollToBottomRef = useRef(false);
+
+ const setMessagesBottomAnchorRef = useCallback((element: HTMLDivElement | null) => {
+ messagesBottomAnchorRef.current = element;
+ }, []);
+
+ const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => {
+ messagesScrollRef.current = element;
+ setMessagesScrollElement(element);
+ }, []);
+
+ // The bottom anchor gives us a stable "true bottom" target even while the
+ // virtualizer is still reconciling row heights during an active turn.
+ // Jumps to the latest known bottom and re-enables sticky behavior when the user returns there.
+ const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
+ const scrollContainer = messagesScrollRef.current;
+ if (!scrollContainer) return;
+ const bottomAnchor = messagesBottomAnchorRef.current;
+ if (bottomAnchor && scrollContainer.contains(bottomAnchor)) {
+ const targetTop = Math.max(
+ 0,
+ bottomAnchor.offsetTop + bottomAnchor.offsetHeight - scrollContainer.clientHeight,
+ );
+ scrollContainer.scrollTo({ top: targetTop, behavior });
+ } else {
+ scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
+ }
+ lastKnownScrollTopRef.current = scrollContainer.scrollTop;
+ shouldAutoScrollRef.current = true;
+ showScrollToBottomRef.current = false;
+ setShowScrollToBottom(false);
+ }, []);
+
+ const cancelPendingStickToBottom = useCallback(() => {
+ const pendingFrame = pendingAutoScrollFrameRef.current;
+ if (pendingFrame === null) return;
+ pendingAutoScrollFrameRef.current = null;
+ window.cancelAnimationFrame(pendingFrame);
+ }, []);
+
+ const cancelPendingScrollProcessing = useCallback(() => {
+ const pendingFrame = pendingScrollFrameRef.current;
+ if (pendingFrame === null) return;
+ pendingScrollFrameRef.current = null;
+ window.cancelAnimationFrame(pendingFrame);
+ }, []);
+
+ const cancelPendingInteractionAnchorAdjustment = useCallback(() => {
+ const pendingFrame = pendingInteractionAnchorFrameRef.current;
+ if (pendingFrame === null) return;
+ pendingInteractionAnchorFrameRef.current = null;
+ window.cancelAnimationFrame(pendingFrame);
+ }, []);
+
+ // Re-applies the stick after layout settles so virtualized rows and images can finish expanding.
+ const scheduleStickToBottom = useCallback(
+ (behavior: ScrollBehavior = "auto") => {
+ if (pendingAutoScrollFrameRef.current !== null) return;
+ pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
+ pendingAutoScrollFrameRef.current = null;
+ scrollMessagesToBottom(behavior);
+ });
+ },
+ [scrollMessagesToBottom],
+ );
+
+ const forceStickToBottom = useCallback(
+ (behavior: ScrollBehavior = "auto") => {
+ cancelPendingStickToBottom();
+ cancelPendingScrollProcessing();
+ scrollMessagesToBottom(behavior);
+ scheduleStickToBottom(behavior);
+ },
+ [
+ cancelPendingScrollProcessing,
+ cancelPendingStickToBottom,
+ scheduleStickToBottom,
+ scrollMessagesToBottom,
+ ],
+ );
+
+ const onTimelineHeightChange = useCallback(() => {
+ if (!shouldAutoScrollRef.current) return;
+ scheduleStickToBottom();
+ }, [scheduleStickToBottom]);
+
+ const onComposerHeightChange = useCallback(
+ (previousHeight: number, nextHeight: number) => {
+ if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return;
+ if (!shouldAutoScrollRef.current) return;
+ scheduleStickToBottom();
+ },
+ [scheduleStickToBottom],
+ );
+
+ const onMessagesClickCapture = useCallback(
+ (event: MouseEvent) => {
+ const scrollContainer = messagesScrollRef.current;
+ if (!scrollContainer || !(event.target instanceof Element)) return;
+
+ const trigger = event.target.closest(
+ "button, summary, [role='button'], [data-scroll-anchor-target]",
+ );
+ if (!trigger || !scrollContainer.contains(trigger)) return;
+ if (trigger.closest("[data-scroll-anchor-ignore]")) return;
+
+ pendingInteractionAnchorRef.current = {
+ element: trigger,
+ top: trigger.getBoundingClientRect().top,
+ };
+
+ cancelPendingInteractionAnchorAdjustment();
+ pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => {
+ pendingInteractionAnchorFrameRef.current = null;
+ const anchor = pendingInteractionAnchorRef.current;
+ pendingInteractionAnchorRef.current = null;
+ const activeScrollContainer = messagesScrollRef.current;
+ if (!anchor || !activeScrollContainer) return;
+ if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return;
+
+ const nextTop = anchor.element.getBoundingClientRect().top;
+ const delta = nextTop - anchor.top;
+ if (Math.abs(delta) < 0.5) return;
+
+ activeScrollContainer.scrollTop += delta;
+ lastKnownScrollTopRef.current = activeScrollContainer.scrollTop;
+ });
+ },
+ [cancelPendingInteractionAnchorAdjustment],
+ );
+
+ const updateScrollButtonVisibility = useCallback((visible: boolean) => {
+ if (showScrollToBottomRef.current === visible) return;
+ showScrollToBottomRef.current = visible;
+ setShowScrollToBottom(visible);
+ }, []);
+
+ // Any explicit user scroll gesture should win over queued auto-stick work.
+ // Keeping this centralized makes it easier to tweak the "manual scroll vs.
+ // stick to bottom" contract without touching every event handler.
+ const noteManualScrollIntent = useCallback(() => {
+ pendingUserScrollUpIntentRef.current = true;
+ cancelPendingStickToBottom();
+ }, [cancelPendingStickToBottom]);
+
+ const onMessagesScroll = useCallback(() => {
+ if (pendingScrollFrameRef.current !== null) return;
+ pendingScrollFrameRef.current = window.requestAnimationFrame(() => {
+ pendingScrollFrameRef.current = null;
+ const scrollContainer = messagesScrollRef.current;
+ if (!scrollContainer) return;
+ const currentScrollTop = scrollContainer.scrollTop;
+ const isNearBottom = isScrollContainerNearBottom(scrollContainer);
+ const didScrollUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
+
+ if (!shouldAutoScrollRef.current && isNearBottom) {
+ shouldAutoScrollRef.current = true;
+ pendingUserScrollUpIntentRef.current = false;
+ } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) {
+ if (didScrollUp) {
+ shouldAutoScrollRef.current = false;
+ }
+ pendingUserScrollUpIntentRef.current = false;
+ } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) {
+ if (didScrollUp) {
+ shouldAutoScrollRef.current = false;
+ }
+ } else if (shouldAutoScrollRef.current && !isNearBottom) {
+ // Catch keyboard or assistive scroll interactions that do not expose pointer intent.
+ if (didScrollUp) {
+ shouldAutoScrollRef.current = false;
+ }
+ }
+
+ updateScrollButtonVisibility(!shouldAutoScrollRef.current);
+ lastKnownScrollTopRef.current = currentScrollTop;
+ });
+ }, [updateScrollButtonVisibility]);
+
+ const onMessagesWheel = useCallback(
+ (event: WheelEvent) => {
+ if (event.deltaY < 0) {
+ noteManualScrollIntent();
+ }
+ },
+ [noteManualScrollIntent],
+ );
+
+ const onMessagesPointerDown = useCallback(
+ (_event: PointerEvent) => {
+ isPointerScrollActiveRef.current = true;
+ // Pointer-driven scrollbars/trackpads can start with pointer-down before
+ // the first scroll event lands, so cancel pending stick work immediately.
+ cancelPendingStickToBottom();
+ },
+ [cancelPendingStickToBottom],
+ );
+
+ const onMessagesPointerUp = useCallback((_event: PointerEvent) => {
+ isPointerScrollActiveRef.current = false;
+ }, []);
+
+ const onMessagesPointerCancel = useCallback((_event: PointerEvent) => {
+ isPointerScrollActiveRef.current = false;
+ }, []);
+
+ const onMessagesTouchStart = useCallback((event: TouchEvent) => {
+ const touch = event.touches[0];
+ if (!touch) return;
+ lastTouchClientYRef.current = touch.clientY;
+ }, []);
+
+ const onMessagesTouchMove = useCallback(
+ (event: TouchEvent) => {
+ const touch = event.touches[0];
+ if (!touch) return;
+ const previousTouchY = lastTouchClientYRef.current;
+ if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
+ noteManualScrollIntent();
+ }
+ lastTouchClientYRef.current = touch.clientY;
+ },
+ [noteManualScrollIntent],
+ );
+
+ const onMessagesTouchEnd = useCallback((_event: TouchEvent) => {
+ lastTouchClientYRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ cancelPendingScrollProcessing();
+ cancelPendingStickToBottom();
+ cancelPendingInteractionAnchorAdjustment();
+ };
+ }, [
+ cancelPendingInteractionAnchorAdjustment,
+ cancelPendingScrollProcessing,
+ cancelPendingStickToBottom,
+ ]);
+
+ useLayoutEffect(() => {
+ if (!options.threadId) return;
+ shouldAutoScrollRef.current = true;
+ lastKnownScrollTopRef.current = 0;
+ isPointerScrollActiveRef.current = false;
+ lastTouchClientYRef.current = null;
+ pendingUserScrollUpIntentRef.current = false;
+ pendingInteractionAnchorRef.current = null;
+ showScrollToBottomRef.current = false;
+ setShowScrollToBottom(false);
+ cancelPendingScrollProcessing();
+ cancelPendingInteractionAnchorAdjustment();
+ cancelPendingStickToBottom();
+ scheduleStickToBottom();
+ const timeout = window.setTimeout(() => {
+ const scrollContainer = messagesScrollRef.current;
+ if (!scrollContainer) return;
+ if (isScrollContainerNearBottom(scrollContainer)) return;
+ scheduleStickToBottom();
+ }, 96);
+ return () => {
+ window.clearTimeout(timeout);
+ };
+ }, [
+ cancelPendingInteractionAnchorAdjustment,
+ cancelPendingScrollProcessing,
+ cancelPendingStickToBottom,
+ options.threadId,
+ scheduleStickToBottom,
+ ]);
+
+ useEffect(() => {
+ if (!shouldAutoScrollRef.current) return;
+ scheduleStickToBottom();
+ }, [options.messageCount, scheduleStickToBottom]);
+
+ useEffect(() => {
+ if (!options.isStreaming) return;
+ if (!shouldAutoScrollRef.current) return;
+ scheduleStickToBottom();
+ }, [options.isStreaming, scheduleStickToBottom]);
+
+ return {
+ messagesScrollElement,
+ showScrollToBottom,
+ setMessagesBottomAnchorRef,
+ setMessagesScrollContainerRef,
+ forceStickToBottom,
+ onTimelineHeightChange,
+ onComposerHeightChange,
+ onMessagesClickCapture,
+ onMessagesPointerCancel,
+ onMessagesPointerDown,
+ onMessagesPointerUp,
+ onMessagesScroll,
+ onMessagesTouchEnd,
+ onMessagesTouchMove,
+ onMessagesTouchStart,
+ onMessagesWheel,
+ };
+}
diff --git a/apps/web/src/lib/toolCallLabel.test.ts b/apps/web/src/lib/toolCallLabel.test.ts
new file mode 100644
index 0000000000..49ff001e64
--- /dev/null
+++ b/apps/web/src/lib/toolCallLabel.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { deriveReadableToolTitle, normalizeCompactToolLabel } from "./toolCallLabel";
+
+describe("normalizeCompactToolLabel", () => {
+ it("removes trailing completion wording", () => {
+ expect(normalizeCompactToolLabel("Tool call completed")).toBe("Tool call");
+ expect(normalizeCompactToolLabel("Ran command done")).toBe("Ran command");
+ });
+});
+
+describe("deriveReadableToolTitle", () => {
+ it("humanizes search commands even when wrapped in shell -lc", () => {
+ expect(
+ deriveReadableToolTitle({
+ title: "Ran command",
+ fallbackLabel: "Ran command",
+ itemType: "command_execution",
+ requestKind: "command",
+ command: `/bin/zsh -lc 'rg -n "tool call" apps/web/src'`,
+ }),
+ ).toBe("Search files");
+ });
+
+ it("humanizes file read commands", () => {
+ expect(
+ deriveReadableToolTitle({
+ title: "Ran command",
+ fallbackLabel: "Ran command",
+ itemType: "command_execution",
+ command: "sed -n '520,550p' apps/web/src/session-logic.ts",
+ }),
+ ).toBe("Read file");
+ });
+
+ it("humanizes git status commands", () => {
+ expect(
+ deriveReadableToolTitle({
+ title: "Ran command",
+ fallbackLabel: "Ran command",
+ itemType: "command_execution",
+ command: "git status --short",
+ }),
+ ).toBe("Check git status");
+ });
+
+ it("keeps explicit non-generic titles", () => {
+ expect(
+ deriveReadableToolTitle({
+ title: "Bash",
+ fallbackLabel: "Ran command",
+ itemType: "command_execution",
+ command: "echo hello",
+ }),
+ ).toBe("Bash");
+ });
+
+ it("extracts a descriptor from payload when the title is generic", () => {
+ expect(
+ deriveReadableToolTitle({
+ title: "Tool call",
+ fallbackLabel: "Tool call",
+ itemType: "dynamic_tool_call",
+ payload: {
+ data: {
+ item: {
+ toolName: "mcp__xcodebuildmcp__list_sims",
+ },
+ },
+ },
+ }),
+ ).toBe("mcp xcodebuildmcp list sims");
+ });
+});
diff --git a/apps/web/src/lib/toolCallLabel.ts b/apps/web/src/lib/toolCallLabel.ts
new file mode 100644
index 0000000000..2446ac55ec
--- /dev/null
+++ b/apps/web/src/lib/toolCallLabel.ts
@@ -0,0 +1,281 @@
+import type { ToolLifecycleItemType } from "@t3tools/contracts";
+
+export function normalizeCompactToolLabel(value: string): string {
+ return value.replace(/\s+(?:complete|completed|done|finished|success|succeeded)\s*$/i, "").trim();
+}
+
+export interface ReadableToolTitleInput {
+ readonly title?: string | null;
+ readonly fallbackLabel: string;
+ readonly itemType?: ToolLifecycleItemType | undefined;
+ readonly requestKind?: "command" | "file-read" | "file-change" | undefined;
+ readonly command?: string | null;
+ readonly payload?: Record | null;
+}
+
+export function deriveReadableToolTitle(input: ReadableToolTitleInput): string | null {
+ const normalizedTitle = normalizeCompactToolLabel(input.title ?? "");
+ const normalizedFallback = normalizeCompactToolLabel(input.fallbackLabel);
+ const commandLabel = input.command ? humanizeCommandToolLabel(input.command) : null;
+ const commandLike = input.itemType === "command_execution" || input.requestKind === "command";
+
+ if (normalizedTitle.length > 0 && !isGenericToolTitle(normalizedTitle)) {
+ return normalizedTitle;
+ }
+
+ if (commandLike && commandLabel) {
+ return commandLabel;
+ }
+
+ const descriptor = normalizeToolDescriptor(extractToolDescriptorFromPayload(input.payload));
+ if (descriptor && !isGenericToolTitle(descriptor)) {
+ return descriptor;
+ }
+
+ if (normalizedFallback.length > 0 && !isGenericToolTitle(normalizedFallback)) {
+ return normalizedFallback;
+ }
+ if (normalizedTitle.length > 0) {
+ return normalizedTitle;
+ }
+ if (normalizedFallback.length > 0) {
+ return normalizedFallback;
+ }
+ return null;
+}
+
+function isGenericToolTitle(value: string): boolean {
+ const normalized = value
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+ return (
+ normalized === "tool" ||
+ normalized === "tool call" ||
+ normalized === "dynamic tool call" ||
+ normalized === "mcp tool call" ||
+ normalized === "subagent task" ||
+ normalized === "command run" ||
+ normalized === "ran command" ||
+ normalized === "command execution"
+ );
+}
+
+function normalizeToolDescriptor(value: string | null): string | null {
+ if (!value) {
+ return null;
+ }
+ const normalized = value.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim();
+ if (!normalized) {
+ return null;
+ }
+ const dedupedTokens: string[] = [];
+ for (const token of normalized.split(" ")) {
+ if (dedupedTokens.at(-1)?.toLowerCase() === token.toLowerCase()) {
+ continue;
+ }
+ dedupedTokens.push(token);
+ }
+ const collapsed = dedupedTokens.join(" ").trim();
+ if (!collapsed) {
+ return null;
+ }
+ return collapsed.length > 64 ? `${collapsed.slice(0, 61).trimEnd()}...` : collapsed;
+}
+
+function extractToolDescriptorFromPayload(
+ payload: Record | null | undefined,
+): string | null {
+ if (!payload) {
+ return null;
+ }
+ const descriptorKeys = ["kind", "name", "tool", "tool_name", "toolName", "title"];
+ const candidates: string[] = [];
+ collectDescriptorCandidates(payload, descriptorKeys, candidates, 0);
+ for (const candidate of candidates) {
+ const normalized = candidate.trim();
+ if (!normalized) {
+ continue;
+ }
+ if (isGenericToolTitle(normalizeCompactToolLabel(normalized))) {
+ continue;
+ }
+ return normalized;
+ }
+ return null;
+}
+
+function collectDescriptorCandidates(
+ value: unknown,
+ keys: ReadonlyArray,
+ target: string[],
+ depth: number,
+) {
+ if (depth > 4 || target.length >= 24) {
+ return;
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (trimmed) {
+ target.push(trimmed);
+ }
+ return;
+ }
+ if (Array.isArray(value)) {
+ for (const entry of value) {
+ collectDescriptorCandidates(entry, keys, target, depth + 1);
+ if (target.length >= 24) {
+ return;
+ }
+ }
+ return;
+ }
+ if (!value || typeof value !== "object") {
+ return;
+ }
+
+ const record = value as Record;
+ for (const key of keys) {
+ if (typeof record[key] === "string") {
+ const trimmed = (record[key] as string).trim();
+ if (trimmed) {
+ target.push(trimmed);
+ }
+ }
+ }
+ for (const nestedKey of ["item", "data", "event", "payload", "result", "input", "tool", "call"]) {
+ if (nestedKey in record) {
+ collectDescriptorCandidates(record[nestedKey], keys, target, depth + 1);
+ if (target.length >= 24) {
+ return;
+ }
+ }
+ }
+}
+
+function humanizeCommandToolLabel(rawCommand: string): string {
+ const command = unwrapShellCommandIfPresent(rawCommand);
+ const [tool, args] = splitToolAndArgs(command);
+
+ switch (tool) {
+ case "cat":
+ case "nl":
+ case "head":
+ case "tail":
+ case "sed":
+ case "less":
+ case "more":
+ return "Read file";
+ case "rg":
+ case "grep":
+ case "ag":
+ case "ack":
+ return "Search files";
+ case "ls":
+ return "List files";
+ case "find":
+ case "fd":
+ return "Find files";
+ case "git":
+ return humanizeGitCommand(args);
+ default:
+ return "Run command";
+ }
+}
+
+function humanizeGitCommand(args: string): string {
+ const subcommand = args.split(/\s+/, 1)[0]?.toLowerCase() ?? "";
+ switch (subcommand) {
+ case "status":
+ return "Check git status";
+ case "diff":
+ return "Inspect git diff";
+ case "show":
+ return "Inspect commit";
+ case "log":
+ return "Review git history";
+ case "add":
+ return "Stage changes";
+ case "commit":
+ return "Commit changes";
+ case "push":
+ return "Push changes";
+ case "pull":
+ return "Pull changes";
+ case "checkout":
+ case "switch":
+ return "Switch branch";
+ default:
+ return "Run git command";
+ }
+}
+
+function splitToolAndArgs(command: string): [tool: string, args: string] {
+ const normalized = command.trim().replace(/\s+/g, " ");
+ if (!normalized) {
+ return ["", ""];
+ }
+ const separator = normalized.indexOf(" ");
+ if (separator === -1) {
+ return [basename(normalized).toLowerCase(), ""];
+ }
+ const tool = basename(normalized.slice(0, separator)).toLowerCase();
+ const args = normalized.slice(separator + 1).trim();
+ return [tool, args];
+}
+
+function basename(value: string): string {
+ const slash = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
+ return slash >= 0 ? value.slice(slash + 1) : value;
+}
+
+function unwrapShellCommandIfPresent(rawCommand: string): string {
+ let value = rawCommand.trim();
+ if (!value) {
+ return value;
+ }
+
+ const shellPrefixes = [
+ "/usr/bin/bash -lc ",
+ "/usr/bin/bash -c ",
+ "/bin/bash -lc ",
+ "/bin/bash -c ",
+ "/usr/bin/zsh -lc ",
+ "/usr/bin/zsh -c ",
+ "/bin/zsh -lc ",
+ "/bin/zsh -c ",
+ "/bin/sh -lc ",
+ "/bin/sh -c ",
+ "bash -lc ",
+ "bash -c ",
+ "zsh -lc ",
+ "zsh -c ",
+ "sh -lc ",
+ "sh -c ",
+ ];
+
+ const lowered = value.toLowerCase();
+ for (const prefix of shellPrefixes) {
+ if (!lowered.startsWith(prefix)) {
+ continue;
+ }
+ value = value.slice(prefix.length).trim();
+ if (
+ value.length >= 2 &&
+ ((value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'")))
+ ) {
+ value = value.slice(1, -1).trim();
+ }
+ value = value.replace(/^cd\s+[^;&|]+(?:&&|;)\s*/i, "").trim();
+ break;
+ }
+
+ const pipeIndex = value.indexOf(" | ");
+ if (pipeIndex > 0) {
+ value = value.slice(0, pipeIndex).trim();
+ }
+
+ return value;
+}
From f729e7a35494b01c62837cfe9db92d025d921152 Mon Sep 17 00:00:00 2001
From: sherlock
Date: Mon, 13 Apr 2026 11:00:53 +0530
Subject: [PATCH 2/3] fix: resolve CI errors for chat-ui PR
---
.../src/components/chat/ActivePlanCard.tsx | 2 +-
.../chat/ComposerImageAttachmentChip.tsx | 2 +-
.../chat/ComposerSlashStatusDialog.tsx | 27 +++++++------------
.../components/chat/PickerTriggerButton.tsx | 2 +-
.../web/src/components/chat/chatTypography.ts | 8 +++++-
5 files changed, 20 insertions(+), 21 deletions(-)
diff --git a/apps/web/src/components/chat/ActivePlanCard.tsx b/apps/web/src/components/chat/ActivePlanCard.tsx
index 42f33e4ada..6d3fdf501e 100644
--- a/apps/web/src/components/chat/ActivePlanCard.tsx
+++ b/apps/web/src/components/chat/ActivePlanCard.tsx
@@ -6,7 +6,7 @@
import { memo } from "react";
import type { ActivePlanState } from "../../session-logic";
-import { BotIcon, CheckIcon, ChevronRightIcon, ListTodoIcon, LoaderIcon } from "~/lib/icons";
+import { BotIcon, CheckIcon, ChevronRightIcon, ListTodoIcon, LoaderIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
diff --git a/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx b/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx
index 4eb8dd3542..c1db9963e7 100644
--- a/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx
+++ b/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx
@@ -5,7 +5,7 @@
import { memo } from "react";
import { type ComposerImageAttachment } from "../../composerDraftStore";
-import { CircleAlertIcon, XIcon } from "~/lib/icons";
+import { CircleAlertIcon, XIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME } from "../composerInlineChip";
diff --git a/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx b/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx
index 100f972429..70495c7ccc 100644
--- a/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx
+++ b/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx
@@ -1,12 +1,13 @@
-import type { ResolvedThreadWorkspaceState } from "@t3tools/shared/threadEnvironment";
import type { ProviderInteractionMode } from "@t3tools/contracts";
import type { DraftThreadEnvMode } from "../../composerDraftStore";
-import {
- type ContextWindowSnapshot,
- formatContextWindowTokens,
- formatCostUsd,
-} from "../../lib/contextWindow";
-import type { RateLimitStatus } from "./RateLimitBanner";
+import { type ContextWindowSnapshot, formatContextWindowTokens } from "../../lib/contextWindow";
+type RateLimitStatus = {
+ status: "warning" | "rejected";
+ utilization?: number;
+ resetsAt?: number;
+};
+
+type ResolvedThreadWorkspaceState = Record;
import { Button } from "../ui/button";
import {
Dialog,
@@ -54,7 +55,6 @@ export function ComposerSlashStatusDialog(props: {
envState: ResolvedThreadWorkspaceState;
branch: string | null;
contextWindow: ContextWindowSnapshot | null;
- cumulativeCostUsd: number | null;
rateLimitStatus: RateLimitStatus | null;
}) {
const {
@@ -68,7 +68,6 @@ export function ComposerSlashStatusDialog(props: {
envState,
branch,
contextWindow,
- cumulativeCostUsd,
rateLimitStatus,
} = props;
@@ -125,9 +124,7 @@ export function ComposerSlashStatusDialog(props: {
Latest usage reported by the active thread.
- {contextWindow ? (
-
- ) : null}
+ {contextWindow ? : null}
{contextWindow ? (
@@ -151,11 +148,7 @@ export function ComposerSlashStatusDialog(props: {
Cost
-
- {cumulativeCostUsd !== null
- ? formatCostUsd(cumulativeCostUsd)
- : "Not available"}
-
+
{"Not available"}
) : (
diff --git a/apps/web/src/components/chat/PickerTriggerButton.tsx b/apps/web/src/components/chat/PickerTriggerButton.tsx
index a02deee552..5d21b59ffe 100644
--- a/apps/web/src/components/chat/PickerTriggerButton.tsx
+++ b/apps/web/src/components/chat/PickerTriggerButton.tsx
@@ -4,7 +4,7 @@
// Depends on: button primitives, shared picker text styles, and icon slots supplied by callers.
import { type ComponentProps, type ReactNode } from "react";
-import { ChevronDownIcon } from "~/lib/icons";
+import { ChevronDownIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
import { COMPOSER_PICKER_TRIGGER_TEXT_CLASS_NAME } from "./composerPickerStyles";
diff --git a/apps/web/src/components/chat/chatTypography.ts b/apps/web/src/components/chat/chatTypography.ts
index 54b74e3152..2f716277bd 100644
--- a/apps/web/src/components/chat/chatTypography.ts
+++ b/apps/web/src/components/chat/chatTypography.ts
@@ -4,7 +4,13 @@
// Exports: transcript measurement helpers and inline styles for chat text
import type { CSSProperties } from "react";
-import { DEFAULT_CHAT_FONT_SIZE_PX, normalizeChatFontSizePx } from "../../appSettings";
+const DEFAULT_CHAT_FONT_SIZE_PX = 14;
+const MIN_CHAT_FONT_SIZE_PX = 10;
+const MAX_CHAT_FONT_SIZE_PX = 24;
+function normalizeChatFontSizePx(size: number | undefined): number {
+ if (size === undefined) return DEFAULT_CHAT_FONT_SIZE_PX;
+ return Math.max(MIN_CHAT_FONT_SIZE_PX, Math.min(MAX_CHAT_FONT_SIZE_PX, Math.round(size)));
+}
const CHAT_TRANSCRIPT_USER_CHAR_WIDTH_RATIO = 0.6;
const CHAT_TRANSCRIPT_ASSISTANT_CHAR_WIDTH_RATIO = 0.52;
From 1187af4d93e36038345911f82695ba6768647b93 Mon Sep 17 00:00:00 2001
From: sherlock
Date: Mon, 13 Apr 2026 12:02:00 +0530
Subject: [PATCH 3/3] fix: resolve remaining CI type errors in
ComposerSlashStatusDialog
Remove incompatible Record-to-string comparison in formatEnvironmentLabel
and coalesce undefined maxTokens to null for formatContextWindowTokens.
---
apps/web/src/components/chat/ComposerSlashStatusDialog.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx b/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx
index 70495c7ccc..0b7154df16 100644
--- a/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx
+++ b/apps/web/src/components/chat/ComposerSlashStatusDialog.tsx
@@ -41,7 +41,7 @@ function formatEnvironmentLabel(
if (envMode === "local") {
return "Local";
}
- return envState === "worktree-pending" ? "New worktree (pending)" : "Worktree";
+ return "Worktree";
}
export function ComposerSlashStatusDialog(props: {
@@ -143,7 +143,7 @@ export function ComposerSlashStatusDialog(props: {
Window
- {formatContextWindowTokens(contextWindow.maxTokens)}
+ {formatContextWindowTokens(contextWindow.maxTokens ?? null)}