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 ( +
  1. +
    + + {stepStatusIcon(step.status)} + + {index + 1}. +
    +

    + {step.step} +

    +
  2. + ); + })} +
+ + {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 ( +
+ DP Code logo + +
+

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 ( + + + + Session Status + + Runtime controls and local thread state for the active composer. + + + +
+
+

Model

+

{selectedModel}

+
+
+

Fast Mode

+

{fastModeEnabled ? "On" : "Off"}

+
+
+

Reasoning

+

{selectedPromptEffort ?? "Default"}

+
+
+

Mode

+

+ {interactionMode === "plan" ? "Plan" : "Default"} +

+
+
+

+ Environment +

+

+ {formatEnvironmentLabel(envMode, envState)} +

+
+
+

Branch

+

{branch ?? "Unknown"}

+
+
+ +
+
+
+

+ Context Window +

+

+ Latest usage reported by the active thread. +

+
+ {contextWindow ? ( + + ) : null} +
+ {contextWindow ? ( +
+
+

Used

+

+ {formatContextWindowTokens(contextWindow.usedTokens)} +

+
+
+

Remaining

+

+ {formatContextWindowTokens(contextWindow.remainingTokens)} +

+
+
+

Window

+

+ {formatContextWindowTokens(contextWindow.maxTokens)} +

+
+
+

Cost

+

+ {cumulativeCostUsd !== null + ? formatCostUsd(cumulativeCostUsd) + : "Not available"} +

+
+
+ ) : ( +

+ Context usage has not been reported yet for this thread. +

+ )} +
+ +
+

Rate Limits

+ {rateLimitStatus ? ( +

{formatRateLimitMessage(rateLimitStatus)}

+ ) : ( +

+ No active rate-limit warning for this thread. +

+ )} +
+
+ + + +
+
+ ); +} 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)}