Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export const globalSettingsSchema = z.object({
enhancementApiConfigId: z.string().optional(),
includeTaskHistoryInEnhance: z.boolean().optional(),
historyPreviewCollapsed: z.boolean().optional(),
reasoningBlockCollapsed: z.boolean().optional(),
profileThresholds: z.record(z.string(), z.number()).optional(),
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),
Expand Down
4 changes: 4 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1617,6 +1617,10 @@ export const webviewMessageHandler = async (
await updateGlobalState("historyPreviewCollapsed", message.bool ?? false)
// No need to call postStateToWebview here as the UI already updated optimistically
break
case "setReasoningBlockCollapsed":
await updateGlobalState("reasoningBlockCollapsed", message.bool ?? true)
// No need to call postStateToWebview here as the UI already updated optimistically
break
case "toggleApiConfigPin":
if (message.text) {
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export interface WebviewMessage {
| "focusPanelRequest"
| "profileThresholds"
| "setHistoryPreviewCollapsed"
| "setReasoningBlockCollapsed"
| "openExternal"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"
Expand Down
86 changes: 75 additions & 11 deletions webview-ui/src/components/chat/ReasoningBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React, { useEffect, useRef, useState } from "react"
import React, { useEffect, useRef, useState, useCallback } from "react"
import { useTranslation } from "react-i18next"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"

import MarkdownBlock from "../common/MarkdownBlock"
import { Lightbulb } from "lucide-react"
import { Lightbulb, ChevronDown, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

interface ReasoningBlockProps {
content: string
Expand All @@ -16,12 +19,45 @@ interface ReasoningBlockProps {
* Render reasoning with a heading and a simple timer.
* - Heading uses i18n key chat:reasoning.thinking
* - Timer runs while reasoning is active (no persistence)
* - Can be collapsed to show only last 2 lines of content
*/
export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage: This new component lacks unit tests. Consider adding tests for:

  • Collapse/expand functionality
  • Keyboard shortcut handling (Ctrl/Cmd+Shift+T)
  • State persistence via global settings
  • Timer functionality during streaming

const { t } = useTranslation()
const { reasoningBlockCollapsed, setReasoningBlockCollapsed } = useExtensionState()

// Initialize collapsed state based on global setting (default to collapsed)
const [isCollapsed, setIsCollapsed] = useState(reasoningBlockCollapsed !== false)

const startTimeRef = useRef<number>(Date.now())
const [elapsed, setElapsed] = useState<number>(0)
const contentRef = useRef<HTMLDivElement>(null)

// Update collapsed state when global setting changes
useEffect(() => {
setIsCollapsed(reasoningBlockCollapsed !== false)
}, [reasoningBlockCollapsed])

// Handle keyboard shortcut for toggling collapsed state
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Ctrl/Cmd + Shift + T to toggle reasoning blocks
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "T") {
e.preventDefault()
const newState = !isCollapsed
setIsCollapsed(newState)
// Update global setting
setReasoningBlockCollapsed(!newState)
// Persist to backend
vscode.postMessage({ type: "setReasoningBlockCollapsed", bool: !newState })
}
},
[isCollapsed, setReasoningBlockCollapsed],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential memory leak: The keyboard event listener is recreated on every render due to the isCollapsed dependency. Consider using a ref to track the current state:

const isCollapsedRef = useRef(isCollapsed);
isCollapsedRef.current = isCollapsed;

const handleKeyDown = useCallback((e: KeyboardEvent) => {
  if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'T') {
    e.preventDefault();
    const newState = !isCollapsedRef.current;
    setIsCollapsed(newState);
    setReasoningBlockCollapsed(!newState);
    vscode.postMessage({ type: 'setReasoningBlockCollapsed', bool: !newState });
  }
}, [setReasoningBlockCollapsed]);

)

useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])

// Simple timer that runs while streaming
useEffect(() => {
Expand All @@ -36,22 +72,50 @@ export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockP
const seconds = Math.floor(elapsed / 1000)
const secondsLabel = t("chat:reasoning.seconds", { count: seconds })

const handleToggle = () => {
setIsCollapsed(!isCollapsed)
}

return (
<div>
<div className="flex items-center justify-between mb-2.5 pr-2">
<div className="group">
<div
className="flex items-center justify-between mb-2.5 pr-2 cursor-pointer select-none"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessibility issue: This clickable header should have proper ARIA attributes for screen reader users:

<div
  className="flex items-center justify-between mb-2.5 pr-2 cursor-pointer select-none"
  onClick={handleToggle}
  role="button"
  aria-expanded={!isCollapsed}
  aria-label="Toggle reasoning block"
  tabIndex={0}
  onKeyDown={(e) => e.key === 'Enter' && handleToggle()}
>

onClick={handleToggle}>
<div className="flex items-center gap-2">
<Lightbulb className="w-4" />
<span className="font-bold text-vscode-foreground">{t("chat:reasoning.thinking")}</span>
{elapsed > 0 && (
<span className="text-sm text-vscode-descriptionForeground tabular-nums">{secondsLabel}</span>
)}
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
{isCollapsed ? <ChevronRight className="w-4" /> : <ChevronDown className="w-4" />}
</div>
{elapsed > 0 && (
<span className="text-sm text-vscode-descriptionForeground tabular-nums flex items-center gap-1">
{secondsLabel}
</span>
)}
</div>
{(content?.trim()?.length ?? 0) > 0 && (
<div className="border-l border-vscode-descriptionForeground/20 ml-2 pl-4 pb-1 text-vscode-descriptionForeground">
<MarkdownBlock markdown={content} />
<div
ref={contentRef}
className={cn(
"border-l border-vscode-descriptionForeground/20 ml-2 pl-4 pb-1 text-vscode-descriptionForeground",
isCollapsed && "relative overflow-hidden",
)}
style={
isCollapsed
? {
maxHeight: "3em", // Approximately 2 lines
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded styling values: Consider extracting these magic numbers to CSS variables or configuration:

  • maxHeight: "3em" could be var(--reasoning-block-collapsed-height)
  • Gradient percentages (0%, 30%) could be configurable

maskImage: "linear-gradient(to top, transparent 0%, black 30%)",
WebkitMaskImage: "linear-gradient(to top, transparent 0%, black 30%)",
}
: undefined
}>
{isCollapsed ? (
// When collapsed, render content in a container that shows bottom-aligned text
<div className="flex flex-col justify-end" style={{ minHeight: "3em" }}>
<MarkdownBlock markdown={content} />
</div>
) : (
<MarkdownBlock markdown={content} />
)}
</div>
)}
</div>
Expand Down
9 changes: 9 additions & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { convertTextMateToHljs } from "@src/utils/textMateToHljs"

export interface ExtensionStateContextType extends ExtensionState {
historyPreviewCollapsed?: boolean // Add the new state property
reasoningBlockCollapsed?: boolean // Add reasoning block collapsed state
didHydrateState: boolean
showWelcome: boolean
theme: any
Expand Down Expand Up @@ -142,6 +143,7 @@ export interface ExtensionStateContextType extends ExtensionState {
terminalCompressProgressBar?: boolean
setTerminalCompressProgressBar: (value: boolean) => void
setHistoryPreviewCollapsed: (value: boolean) => void
setReasoningBlockCollapsed: (value: boolean) => void
autoCondenseContext: boolean
setAutoCondenseContext: (value: boolean) => void
autoCondenseContextPercent: number
Expand Down Expand Up @@ -280,6 +282,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
global: {},
})
const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(true)
const [reasoningBlockCollapsed, setReasoningBlockCollapsed] = useState(true) // Default to collapsed

const setListApiConfigMeta = useCallback(
(value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
Expand Down Expand Up @@ -317,6 +320,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
if ((newState as any).includeTaskHistoryInEnhance !== undefined) {
setIncludeTaskHistoryInEnhance((newState as any).includeTaskHistoryInEnhance)
}
// Update reasoningBlockCollapsed if present in state message
if ((newState as any).reasoningBlockCollapsed !== undefined) {
setReasoningBlockCollapsed((newState as any).reasoningBlockCollapsed)
}
// Handle marketplace data if present in state message
if (newState.marketplaceItems !== undefined) {
setMarketplaceItems(newState.marketplaceItems)
Expand Down Expand Up @@ -413,6 +420,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode

const contextValue: ExtensionStateContextType = {
...state,
reasoningBlockCollapsed,
didHydrateState,
showWelcome,
theme,
Expand Down Expand Up @@ -528,6 +536,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
}),
setHistoryPreviewCollapsed: (value) =>
setState((prevState) => ({ ...prevState, historyPreviewCollapsed: value })),
setReasoningBlockCollapsed,
setHasOpenedModeSelector: (value) => setState((prevState) => ({ ...prevState, hasOpenedModeSelector: value })),
setAutoCondenseContext: (value) => setState((prevState) => ({ ...prevState, autoCondenseContext: value })),
setAutoCondenseContextPercent: (value) =>
Expand Down
Loading