feat(ui): collapsible thinking blocks with merged per-turn view and user toggle (#1273)
* feat: add collapsible reasoning traces with animated labels
* feat(ui): redesign reasoning blocks with merged collapsible Thought view
- Replace per-part reasoning blocks with a single merged block per turn
(VSCode Copilot pattern), controlled by new `groupReasoningBlocks` store flag
- `ReasoningTimelineBlock` redesigned: chevron toggle, summary preview on
collapsed header, 'Thinking'/'Justification' label when expanded, BusyDots
while streaming, auto-scroll to bottom during live streaming
- Short texts (< 120 chars) render inline without a toggle
- Summary now strips markdown and truncates at a word boundary with ellipsis
- New `MergedReasoningPart` component merges all reasoning parts for a message
into one block at the position of the first reasoning part
- `defaultExpanded` prop lets callers override initial expand state
- Remove `.thinking-dot` CSS animation (replaced by BusyDots component)
- Fix reasoning markdown font-size: use `--text-markdown` instead of `--text-meta`
* refactor(ui): scope working phrases inside useAssistantStatus and simplify reasoning status
- Move WORKING_PHRASES array and getRandomWorkingPhrase() inside the hook
so they are no longer exported (were only consumed by ReasoningPart which
no longer needs them)
- Change the 'reasoning' activity status text from a random working phrase
to the deterministic string 'thinking' — matches the new UI label
* test(ui): expand ReasoningPart tests for new collapsible and summary behavior
- Update baseline test to use text long enough to trigger the collapsible
path (short texts now render inline) and assert on the correct aria markup
- Add test for 'Justification' label when pre-expanded via defaultExpanded
- Add test for 'Thinking' label for the thinking variant when expanded
- Add test verifying summary is a word-boundary-truncated excerpt ending with
an ellipsis character
* i18n: rename 'Reasoning Traces' to 'Thinking Blocks' and add thought key
- Rename settings label from 'Show Reasoning Traces' → 'Show Thinking Blocks'
across all supported locales (en, es, ko, pl, pt-BR, uk, zh-CN)
- Add `chat.reasoningTrace.thought` key to all locales (used by merged
reasoning block header in completed state)
* feat(ui): add collapsibleThinkingBlocks setting with full persistence wiring
- New boolean store field `collapsibleThinkingBlocks` (default true) with
`setCollapsibleThinkingBlocks` action; persisted to localStorage
- Threaded through DesktopSettings, SettingsPayload (API types), desktop
persistence (sanitize + apply), web appearance persistence, appearance
auto-save watcher, and server-side settings-helpers sanitize/format
- Server defaults to true when the field is absent in formatSettingsResponse
- MessageBody reads the flag: false → render reasoning as plain AssistantTextPart;
true → existing collapsible/merged block path
* feat(settings): expose Collapsible Reasoning Blocks toggle in visual settings
Add a checkbox under the 'Show Thinking Blocks' row (visible only when
showReasoningTraces is enabled) that toggles the collapsibleThinkingBlocks
preference. Follows the existing toggle pattern: div role=button, keyboard
handler for Enter/Space, Checkbox primitive, aria-pressed attribute.
* i18n: revert showReasoningTraces label rename and add collapsibleThinkingBlocks strings
- Revert 'Show Reasoning Traces' → 'Show Thinking Blocks' rename (the
collapsibleThinkingBlocks toggle is now a separate control, so the parent
label stays as 'Reasoning Traces' for clarity)
- Add `collapsibleThinkingBlocks` / `collapsibleThinkingBlocksAria` strings
across all seven supported locales (en, es, ko, pl, pt-BR, uk, zh-CN)
* test(server): add settings-helpers coverage for collapsibleThinkingBlocks
- Verify sanitizeSettingsUpdate accepts boolean true/false and rejects
non-boolean values (string, number)
- Verify formatSettingsResponse forwards the value correctly for both true
and false, and defaults to true when the field is absent
* fix(ui): respect defaultExpanded prop and remove dead alwaysShowActions from ReasoningTimelineBlock
The useEffect on [isStreaming] was firing on mount and immediately calling
setIsExpanded(false) (since isStreaming is false for completed blocks),
overriding any defaultExpanded={true} passed by callers. The fix uses a
prevIsStreamingRef so the effect only collapses the block on a true→false
transition and is a no-op on initial mount.
Also removes alwaysShowActions from ReasoningTimelineBlockProps — the new
header design always shows the chevron, making the prop obsolete. The prop
was already absent from the component destructuring (a dead type entry) and
was silently ignored at runtime. Removed it from ReasoningPartProps,
MergedReasoningPartProps, and the two call-sites in MessageBody as well.
* chore: remove unused reasoningpresentation module and test
* fix(ui): polish collapsible reasoning block UI
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
00c112077d
commit
e1977bbe63
@@ -4,7 +4,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import UserTextPart from './parts/UserTextPart';
|
||||
import ToolPart from './parts/ToolPart';
|
||||
import AssistantTextPart from './parts/AssistantTextPart';
|
||||
import ReasoningPart from './parts/ReasoningPart';
|
||||
import ReasoningPart, { MergedReasoningPart } from './parts/ReasoningPart';
|
||||
import { MessageFilesDisplay } from '../FileAttachment';
|
||||
import { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||
@@ -46,6 +46,7 @@ import { useSessions } from '@/sync/sync-context';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { extractLoopbackUrls } from '@/lib/url';
|
||||
|
||||
|
||||
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||
const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' };
|
||||
const INLINE_MESSAGE_ACTIONS_CLASS_NAME = 'mt-2 mb-1 flex items-center justify-start gap-1.5';
|
||||
@@ -1015,6 +1016,8 @@ const AssistantMessageBody = React.memo(({
|
||||
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
|
||||
const [isSavingPlan, setIsSavingPlan] = React.useState(false);
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks);
|
||||
const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks);
|
||||
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
|
||||
const isSortedRenderMode = chatRenderMode === 'sorted';
|
||||
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
|
||||
@@ -1506,7 +1509,16 @@ const AssistantMessageBody = React.memo(({
|
||||
// Flat rendering: iterate parts in natural order.
|
||||
// Group consecutive static tools (read, grep, glob, etc.) into compact rows.
|
||||
// Expandable tools (bash, edit, task) get individual rows.
|
||||
// Text and reasoning render inline at their natural position.
|
||||
// Text renders inline at its natural position.
|
||||
// Reasoning: all reasoning parts for this message are merged into ONE block
|
||||
// at the position of the first reasoning part (VSCode Copilot pattern).
|
||||
const flatReasoningParts = visibleParts.filter((p) => {
|
||||
if (p.type !== 'reasoning') return false;
|
||||
const a = activityByPart.get(p);
|
||||
return a?.kind !== 'reasoning';
|
||||
});
|
||||
let reasoningMergeRendered = false;
|
||||
|
||||
let i = 0;
|
||||
while (i < visibleParts.length) {
|
||||
const part = visibleParts[i];
|
||||
@@ -1553,17 +1565,8 @@ const AssistantMessageBody = React.memo(({
|
||||
continue;
|
||||
}
|
||||
if (showReasoningTraces) {
|
||||
if (isSortedRenderMode) {
|
||||
rendered.push(
|
||||
<ReasoningPart
|
||||
key={`reasoning-${messageId}-${i}`}
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
onContentChange={onContentChange}
|
||||
alwaysShowActions={alwaysShowMessageActions}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
if (!collapsibleThinkingBlocks) {
|
||||
// Non-collapsible mode: render thinking blocks as plain text inline.
|
||||
rendered.push(
|
||||
<AssistantTextPart
|
||||
key={`reasoning-${messageId}-${i}`}
|
||||
@@ -1575,6 +1578,29 @@ const AssistantMessageBody = React.memo(({
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
} else if (groupReasoningBlocks) {
|
||||
// Merged mode (VSCode pattern): one block for all reasoning parts.
|
||||
if (!reasoningMergeRendered) {
|
||||
reasoningMergeRendered = true;
|
||||
rendered.push(
|
||||
<MergedReasoningPart
|
||||
key={`reasoning-merged-${messageId}`}
|
||||
parts={flatReasoningParts}
|
||||
messageId={messageId}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Per-part mode: each reasoning block at its natural position.
|
||||
rendered.push(
|
||||
<ReasoningPart
|
||||
key={`reasoning-${messageId}-${i}`}
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
@@ -1661,6 +1687,8 @@ const AssistantMessageBody = React.memo(({
|
||||
animatedToolIdsLookup,
|
||||
animateActivityRows,
|
||||
chatRenderMode,
|
||||
collapsibleThinkingBlocks,
|
||||
groupReasoningBlocks,
|
||||
collapsedPreviewCount,
|
||||
expandedTools,
|
||||
isMobile,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||
|
||||
// A reasoning text whose summary (first 120 chars) fits in the header but
|
||||
// whose expanded body content should only appear when the disclosure is open.
|
||||
const LONG_REASONING =
|
||||
'First thought about the task at hand and how to approach it carefully.\n' +
|
||||
'This second line goes into much deeper detail about the internal reasoning ' +
|
||||
'process that should remain hidden in the collapsed header view.';
|
||||
|
||||
// A long text that should render the collapsible header with a label
|
||||
const LONG_JUSTIFICATION =
|
||||
'Sorting by activity first because the active session needs immediate attention.\n' +
|
||||
'Secondary sort by last updated timestamp ensures a stable deterministic ordering ' +
|
||||
'when multiple sessions have the same activity state.';
|
||||
|
||||
describe('ReasoningTimelineBlock', () => {
|
||||
test('renders reasoning traces behind an accessible collapsed disclosure by default', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<ReasoningTimelineBlock
|
||||
text={LONG_REASONING}
|
||||
variant="thinking"
|
||||
blockId="reasoning-test"
|
||||
showDuration={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Accessible toggle row is rendered
|
||||
expect(markup).toContain('role="button"');
|
||||
expect(markup).toContain('aria-expanded="false"');
|
||||
expect(markup).toContain('aria-label="Expand reasoning trace"');
|
||||
|
||||
// Summary preview (beginning of text) is visible in the header
|
||||
expect(markup).toContain('First thought');
|
||||
|
||||
// Expanded body (MarkdownRenderer) is NOT rendered while collapsed
|
||||
expect(markup).not.toContain('data-message-text-export-source');
|
||||
});
|
||||
|
||||
test('renders "Justification" label for justification variant when pre-expanded and not streaming', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<ReasoningTimelineBlock
|
||||
text={LONG_JUSTIFICATION}
|
||||
variant="justification"
|
||||
blockId="justification-test"
|
||||
showDuration={false}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Label shown in expanded header should be "Justification" not "Thinking"
|
||||
expect(markup).toContain('Justification');
|
||||
expect(markup).not.toContain('Thinking');
|
||||
});
|
||||
|
||||
test('renders "Thinking" label for thinking variant when pre-expanded and not streaming', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<ReasoningTimelineBlock
|
||||
text={LONG_REASONING}
|
||||
variant="thinking"
|
||||
blockId="thinking-test"
|
||||
showDuration={false}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Label shown in expanded header should be "Thinking"
|
||||
expect(markup).toContain('Thinking');
|
||||
});
|
||||
|
||||
test('header summary is a truncated excerpt from the beginning', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<ReasoningTimelineBlock
|
||||
text={LONG_REASONING}
|
||||
variant="thinking"
|
||||
blockId="reasoning-test"
|
||||
showDuration={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Deep body content beyond 120 chars should be cut from the summary span
|
||||
expect(markup).not.toContain('remain hidden in the collapsed header view');
|
||||
// The ellipsis character marks that the text was truncated
|
||||
expect(markup).toContain('…');
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { BusyDots } from './BusyDots';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
|
||||
@@ -14,14 +14,6 @@ type PartWithText = Part & { text?: string; content?: string; time?: { start?: n
|
||||
|
||||
export type ReasoningVariant = 'thinking' | 'justification';
|
||||
|
||||
const variantConfig: Record<
|
||||
ReasoningVariant,
|
||||
{ label: string; Icon: IconName }
|
||||
> = {
|
||||
thinking: { label: 'Thinking', Icon: 'brain-ai-3' },
|
||||
justification: { label: 'Justification', Icon: 'chat-ai-3' },
|
||||
};
|
||||
|
||||
const cleanReasoningText = (text: string): string => {
|
||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||
return '';
|
||||
@@ -35,39 +27,46 @@ const cleanReasoningText = (text: string): string => {
|
||||
.trim();
|
||||
};
|
||||
|
||||
const SUMMARY_MAX_CHARS = 80;
|
||||
const INLINE_THRESHOLD = 120;
|
||||
|
||||
/** Strip common markdown syntax so the header preview reads as plain text. */
|
||||
const stripMarkdown = (text: string): string =>
|
||||
text
|
||||
// Fenced code blocks → keep inner text on one line
|
||||
.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, inner: string) => inner.trim())
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
// Bold + italic (*** / __)
|
||||
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
|
||||
.replace(/_{1,3}([^_]+)_{1,3}/g, '$1')
|
||||
// Headings (# ## ###)
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
// Links [label](url) → label
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
// Blockquote markers
|
||||
.replace(/^>\s?/gm, '')
|
||||
// Horizontal rules
|
||||
.replace(/^[-*_]{3,}\s*$/gm, '')
|
||||
// Remaining leading/trailing punctuation from stripped markers
|
||||
.trim();
|
||||
|
||||
const getReasoningSummary = (text: string): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
const newlineIndex = trimmed.indexOf('\n');
|
||||
const periodIndex = trimmed.indexOf('.');
|
||||
// Strip markdown, then collapse all whitespace runs into single spaces.
|
||||
const flat = stripMarkdown(text).replace(/\s+/g, ' ').trim();
|
||||
|
||||
const cutoffCandidates = [
|
||||
newlineIndex >= 0 ? newlineIndex : Infinity,
|
||||
periodIndex >= 0 ? periodIndex : Infinity,
|
||||
];
|
||||
const cutoff = Math.min(...cutoffCandidates);
|
||||
|
||||
if (!Number.isFinite(cutoff)) {
|
||||
return trimmed;
|
||||
if (flat.length <= SUMMARY_MAX_CHARS) {
|
||||
return flat;
|
||||
}
|
||||
|
||||
return trimmed.substring(0, cutoff).trim();
|
||||
};
|
||||
|
||||
const formatDuration = (start: number, end?: number, now: number = Date.now()): string => {
|
||||
const duration = end ? end - start : now - start;
|
||||
const seconds = duration / 1000;
|
||||
const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
|
||||
return `${displaySeconds.toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
|
||||
const now = useDurationTickerNow(active, 250);
|
||||
|
||||
return <>{formatDuration(start, end, now)}</>;
|
||||
// Cut at a word boundary before the limit, then append ellipsis.
|
||||
const cut = flat.lastIndexOf(' ', SUMMARY_MAX_CHARS);
|
||||
const end = cut > 0 ? cut : SUMMARY_MAX_CHARS;
|
||||
return `${flat.substring(0, end).trimEnd()}…`;
|
||||
};
|
||||
|
||||
type ReasoningTimelineBlockProps = {
|
||||
@@ -79,7 +78,8 @@ type ReasoningTimelineBlockProps = {
|
||||
showDuration?: boolean;
|
||||
isStreaming?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
alwaysShowActions?: boolean;
|
||||
/** Override the initial expanded state. Defaults to `isStreaming`. */
|
||||
defaultExpanded?: boolean;
|
||||
};
|
||||
|
||||
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
@@ -87,18 +87,44 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
variant,
|
||||
onContentChange,
|
||||
blockId,
|
||||
time,
|
||||
showDuration = true,
|
||||
isStreaming = false,
|
||||
actions,
|
||||
alwaysShowActions = false,
|
||||
defaultExpanded,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(defaultExpanded ?? isStreaming);
|
||||
const contentId = React.useId();
|
||||
const scrollRef = React.useRef<HTMLElement>(null);
|
||||
// Track previous isStreaming so the effect only collapses on true→false
|
||||
// transitions and does NOT override defaultExpanded on initial mount.
|
||||
const prevIsStreamingRef = React.useRef(isStreaming);
|
||||
|
||||
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||
const { label, Icon: iconName } = variantConfig[variant];
|
||||
const timeStart = typeof time?.start === 'number' && Number.isFinite(time.start) ? time.start : undefined;
|
||||
const timeEnd = typeof time?.end === 'number' && Number.isFinite(time.end) ? time.end : undefined;
|
||||
const toggleAriaLabel = isExpanded
|
||||
? t('chat.reasoningTrace.collapseAria')
|
||||
: t('chat.reasoningTrace.expandAria');
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
onContentChange?.('structural');
|
||||
}, [onContentChange]);
|
||||
|
||||
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleToggle();
|
||||
}
|
||||
}, [handleToggle]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wasStreaming = prevIsStreamingRef.current;
|
||||
prevIsStreamingRef.current = isStreaming;
|
||||
// Auto-collapse only when streaming ends (true → false).
|
||||
// Do not fire on mount so that defaultExpanded is respected.
|
||||
if (wasStreaming && !isStreaming) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (text.trim().length === 0) {
|
||||
@@ -107,70 +133,136 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
onContentChange?.('structural');
|
||||
}, [onContentChange, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isStreaming && isExpanded && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [text, isStreaming, isExpanded]);
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isShort = !isStreaming && text.trim().length < INLINE_THRESHOLD;
|
||||
|
||||
// Short blocks: render content directly without a collapsible toggle.
|
||||
if (isShort) {
|
||||
return (
|
||||
<div className="my-1" data-reasoning-block-id={blockId} data-message-text-export-root="true">
|
||||
<div data-message-text-export-source="true">
|
||||
<MarkdownRenderer
|
||||
content={text}
|
||||
messageId={blockId}
|
||||
isAnimated={false}
|
||||
isStreaming={false}
|
||||
variant="reasoning"
|
||||
/>
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-1" data-reasoning-block-id={blockId} data-message-text-export-root="true">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
aria-label={toggleAriaLabel}
|
||||
className={cn(
|
||||
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
|
||||
'group/tool flex gap-1.5 pr-2 pl-px py-2 rounded-xl cursor-pointer items-center',
|
||||
)}
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div className="relative h-3.5 w-3.5 flex-shrink-0 cursor-pointer">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity',
|
||||
isExpanded && 'opacity-0',
|
||||
!isExpanded && (alwaysShowActions ? 'opacity-0' : 'group-hover/tool:opacity-0')
|
||||
!isExpanded && 'group-hover/tool:opacity-0',
|
||||
)}
|
||||
style={{ color: 'var(--tools-icon)' }}
|
||||
>
|
||||
<Icon name={iconName} className="h-3.5 w-3.5" />
|
||||
<Icon name="brain-ai-3" className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||
isExpanded && 'opacity-100',
|
||||
!isExpanded && (alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/tool:opacity-100')
|
||||
!isExpanded && 'opacity-0 group-hover/tool:opacity-100',
|
||||
)}
|
||||
style={{ color: 'var(--tools-icon)' }}
|
||||
>
|
||||
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
<span className="typography-meta font-medium">{label}</span>
|
||||
|
||||
{isStreaming ? (
|
||||
<span className="flex items-center gap-1 typography-meta font-medium" style={{ color: 'var(--tools-title)' }}>
|
||||
<span>{t('chat.reasoningTrace.reasoning')}</span>
|
||||
<BusyDots />
|
||||
</span>
|
||||
) : isExpanded ? (
|
||||
<span
|
||||
className="typography-meta font-medium"
|
||||
style={{ color: 'var(--tools-title)' }}
|
||||
>
|
||||
{t(variant === 'justification' ? 'chat.reasoningTrace.justification' : 'chat.reasoningTrace.thinking')}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="typography-meta font-medium"
|
||||
style={{ color: 'var(--tools-title)' }}
|
||||
>
|
||||
{t(variant === 'justification' ? 'chat.reasoningTrace.justification' : 'chat.reasoningTrace.thinking')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(summary || (showDuration && typeof timeStart === 'number')) ? (
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
||||
{summary ? <span className="flex-1 min-w-0 truncate">{summary}</span> : null}
|
||||
{showDuration && typeof timeStart === 'number' ? (
|
||||
<span className="relative flex-shrink-0 tabular-nums text-right">
|
||||
<span className="text-muted-foreground/80 transition-opacity duration-150">
|
||||
<LiveDuration
|
||||
start={timeStart}
|
||||
end={timeEnd}
|
||||
active={typeof timeEnd !== 'number'}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
|
||||
{!isStreaming && !isExpanded && summary ? (
|
||||
<span
|
||||
className="min-w-0 truncate typography-meta"
|
||||
style={{ color: 'var(--tools-description)', opacity: 0.8 }}
|
||||
title={summary}
|
||||
>
|
||||
{summary}
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content — left border matching ToolPart */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative pr-2 pb-2 pt-2 pl-4'
|
||||
)}
|
||||
id={contentId}
|
||||
className="relative ml-2 pl-3 pb-1 pt-0.5"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
|
||||
style={{ backgroundColor: 'var(--tools-border)' }}
|
||||
/>
|
||||
<ScrollableOverlay
|
||||
ref={scrollRef}
|
||||
as="div"
|
||||
outerClassName="max-h-80"
|
||||
className="p-0"
|
||||
useScrollShadow
|
||||
scrollShadowSize={36}
|
||||
userIntentOnly
|
||||
>
|
||||
<div data-message-text-export-source="true">
|
||||
<MarkdownRenderer
|
||||
@@ -199,14 +291,12 @@ type ReasoningPartProps = {
|
||||
part: Part;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
messageId: string;
|
||||
alwaysShowActions?: boolean;
|
||||
};
|
||||
|
||||
const ReasoningPart = React.memo(({
|
||||
part,
|
||||
onContentChange,
|
||||
messageId,
|
||||
alwaysShowActions = false,
|
||||
}: ReasoningPartProps) => {
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
const partWithText = part as PartWithText;
|
||||
@@ -233,9 +323,84 @@ const ReasoningPart = React.memo(({
|
||||
onContentChange={onContentChange}
|
||||
blockId={part.id || `${messageId}-reasoning`}
|
||||
time={time}
|
||||
showDuration={chatRenderMode !== 'sorted'}
|
||||
isStreaming={isStreaming}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type MergedReasoningPartProps = {
|
||||
parts: Part[];
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders ALL reasoning parts for a message as a single collapsible block,
|
||||
* merging their text and spanning their combined time range.
|
||||
* This matches the VSCode Copilot pattern of showing one "Thought" block per turn.
|
||||
*/
|
||||
export const MergedReasoningPart = React.memo(({
|
||||
parts,
|
||||
onContentChange,
|
||||
messageId,
|
||||
}: MergedReasoningPartProps) => {
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
|
||||
const mergedText = React.useMemo(() => {
|
||||
return parts
|
||||
.map((part) => {
|
||||
const p = part as PartWithText;
|
||||
return cleanReasoningText(p.text || p.content || '');
|
||||
})
|
||||
.filter((t) => t.length > 0)
|
||||
.join('\n\n');
|
||||
}, [parts]);
|
||||
|
||||
const mergedTime = React.useMemo(() => {
|
||||
let earliestStart: number | undefined;
|
||||
let latestEnd: number | undefined;
|
||||
|
||||
for (const part of parts) {
|
||||
const time = (part as PartWithText).time;
|
||||
if (typeof time?.start === 'number' && Number.isFinite(time.start)) {
|
||||
if (earliestStart === undefined || time.start < earliestStart) {
|
||||
earliestStart = time.start;
|
||||
}
|
||||
}
|
||||
if (typeof time?.end === 'number' && Number.isFinite(time.end)) {
|
||||
if (latestEnd === undefined || time.end > latestEnd) {
|
||||
latestEnd = time.end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined;
|
||||
}, [parts]);
|
||||
|
||||
const isStreaming = chatRenderMode === 'live' && parts.some(
|
||||
(part) => typeof (part as PartWithText).time?.end !== 'number',
|
||||
);
|
||||
|
||||
const throttledMergedText = useStreamingTextThrottle({
|
||||
text: mergedText,
|
||||
isStreaming,
|
||||
identityKey: `${messageId}:reasoning-merged`,
|
||||
});
|
||||
|
||||
const blockId = parts[0]?.id ?? `${messageId}-reasoning-merged`;
|
||||
|
||||
if (!throttledMergedText.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReasoningTimelineBlock
|
||||
text={throttledMergedText}
|
||||
variant="thinking"
|
||||
onContentChange={onContentChange}
|
||||
blockId={blockId}
|
||||
time={mergedTime}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -249,6 +249,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const directoryShowHidden = useDirectoryShowHidden();
|
||||
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
|
||||
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
||||
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
|
||||
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
|
||||
|
||||
const mermaidRenderingMode = useUIStore(state => state.mermaidRenderingMode);
|
||||
const setMermaidRenderingMode = useUIStore(state => state.setMermaidRenderingMode);
|
||||
@@ -1556,6 +1558,29 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('reasoning') && showReasoningTraces && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={collapsibleThinkingBlocks}
|
||||
onClick={() => setCollapsibleThinkingBlocks(!collapsibleThinkingBlocks)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setCollapsibleThinkingBlocks(!collapsibleThinkingBlocks);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={collapsibleThinkingBlocks}
|
||||
onChange={setCollapsibleThinkingBlocks}
|
||||
ariaLabel={t('settings.openchamber.visual.field.collapsibleThinkingBlocksAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.collapsibleThinkingBlocks')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('stickyUserHeader') && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
|
||||
@@ -16,6 +16,7 @@ type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
|
||||
preventOverscroll?: boolean;
|
||||
useScrollShadow?: boolean;
|
||||
scrollShadowSize?: number;
|
||||
userIntentOnly?: boolean;
|
||||
/** Forwarded to the inner element (e.g. textarea). */
|
||||
disabled?: boolean;
|
||||
};
|
||||
@@ -36,6 +37,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
preventOverscroll = false,
|
||||
useScrollShadow = false,
|
||||
scrollShadowSize,
|
||||
userIntentOnly = false,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const containerRef = React.useRef<HTMLElement | null>(null);
|
||||
@@ -91,6 +93,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
className={scrollbarClassName}
|
||||
disableHorizontal={disableHorizontal}
|
||||
observeMutations={observeMutations}
|
||||
userIntentOnly={userIntentOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -856,7 +856,7 @@ html:not(.dark) .chat-scroll {
|
||||
|
||||
/* Reasoning markdown renders at meta size, dimmed. */
|
||||
.markdown-content.markdown-reasoning {
|
||||
font-size: var(--text-meta);
|
||||
font-size: var(--text-markdown);
|
||||
color: var(--surface-muted-foreground);
|
||||
--markdown-heading1: inherit;
|
||||
--markdown-heading2: inherit;
|
||||
|
||||
@@ -580,6 +580,7 @@ export interface SettingsPayload {
|
||||
securityScopedBookmarks?: string[];
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
collapsibleThinkingBlocks?: boolean;
|
||||
showDeletionDialog?: boolean;
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
|
||||
type AppearanceSlice = {
|
||||
showReasoningTraces: boolean;
|
||||
collapsibleThinkingBlocks: boolean;
|
||||
showDeletionDialog: boolean;
|
||||
nativeNotificationsEnabled: boolean;
|
||||
notificationMode: 'always' | 'hidden-only';
|
||||
@@ -50,6 +51,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
|
||||
let previous: AppearanceSlice = {
|
||||
showReasoningTraces: useUIStore.getState().showReasoningTraces,
|
||||
collapsibleThinkingBlocks: useUIStore.getState().collapsibleThinkingBlocks,
|
||||
showDeletionDialog: useUIStore.getState().showDeletionDialog,
|
||||
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
|
||||
notificationMode: useUIStore.getState().notificationMode,
|
||||
@@ -101,6 +103,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
useUIStore.subscribe((state) => {
|
||||
const current: AppearanceSlice = {
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
|
||||
showDeletionDialog: state.showDeletionDialog,
|
||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||
notificationMode: state.notificationMode,
|
||||
@@ -134,6 +137,9 @@ export const startAppearanceAutoSave = (): void => {
|
||||
if (current.showReasoningTraces !== previous.showReasoningTraces) {
|
||||
diff.showReasoningTraces = current.showReasoningTraces;
|
||||
}
|
||||
if (current.collapsibleThinkingBlocks !== previous.collapsibleThinkingBlocks) {
|
||||
diff.collapsibleThinkingBlocks = current.collapsibleThinkingBlocks;
|
||||
}
|
||||
if (current.showDeletionDialog !== previous.showDeletionDialog) {
|
||||
diff.showDeletionDialog = current.showDeletionDialog;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
export interface AppearancePreferences {
|
||||
showReasoningTraces?: boolean;
|
||||
collapsibleThinkingBlocks?: boolean;
|
||||
}
|
||||
|
||||
type RawAppearancePayload = {
|
||||
showReasoningTraces?: unknown;
|
||||
collapsibleThinkingBlocks?: unknown;
|
||||
};
|
||||
|
||||
const sanitizePreferences = (payload?: RawAppearancePayload | null): AppearancePreferences | null => {
|
||||
@@ -19,6 +21,10 @@ const sanitizePreferences = (payload?: RawAppearancePayload | null): AppearanceP
|
||||
result.showReasoningTraces = payload.showReasoningTraces;
|
||||
}
|
||||
|
||||
if (typeof payload.collapsibleThinkingBlocks === 'boolean') {
|
||||
result.collapsibleThinkingBlocks = payload.collapsibleThinkingBlocks;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
};
|
||||
|
||||
@@ -30,6 +36,7 @@ const extractRawAppearance = (data: unknown): RawAppearancePayload | null => {
|
||||
const candidate = data as Record<string, unknown>;
|
||||
const payload: RawAppearancePayload = {
|
||||
showReasoningTraces: candidate.showReasoningTraces,
|
||||
collapsibleThinkingBlocks: candidate.collapsibleThinkingBlocks,
|
||||
};
|
||||
|
||||
return payload;
|
||||
@@ -54,6 +61,10 @@ export const applyAppearancePreferences = (preferences: AppearancePreferences):
|
||||
if (typeof preferences.showReasoningTraces === 'boolean') {
|
||||
store.setShowReasoningTraces(preferences.showReasoningTraces);
|
||||
}
|
||||
|
||||
if (typeof preferences.collapsibleThinkingBlocks === 'boolean') {
|
||||
store.setCollapsibleThinkingBlocks(preferences.collapsibleThinkingBlocks);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadAppearancePreferences = async (): Promise<AppearancePreferences | null> => {
|
||||
|
||||
@@ -58,6 +58,7 @@ export type DesktopSettings = {
|
||||
securityScopedBookmarks?: string[];
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
collapsibleThinkingBlocks?: boolean;
|
||||
showDeletionDialog?: boolean;
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
|
||||
@@ -1434,6 +1434,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.diffViewModeAria': 'Diff view mode: {option}',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': 'Show reasoning traces',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': 'Show Reasoning Traces',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Enable collapsible reasoning blocks',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocks': 'Enable Collapsible Reasoning Blocks',
|
||||
'settings.openchamber.visual.field.stickyUserHeaderAria': 'Sticky user header',
|
||||
'settings.openchamber.visual.field.stickyUserHeader': 'Sticky User Header',
|
||||
'settings.openchamber.visual.field.wideChatLayoutAria': 'Wide chat layout',
|
||||
|
||||
@@ -1686,6 +1686,12 @@ export const dict = {
|
||||
'chat.modelControls.modeValue.subagent': 'Subagent',
|
||||
'chat.modelControls.modeValue.all': 'All',
|
||||
'chat.modelControls.modeValue.none': '—',
|
||||
'chat.reasoningTrace.thinking': 'Thinking',
|
||||
'chat.reasoningTrace.reasoning': 'Reasoning',
|
||||
'chat.reasoningTrace.justification': 'Justification',
|
||||
'chat.reasoningTrace.expandAria': 'Expand reasoning trace',
|
||||
'chat.reasoningTrace.collapseAria': 'Collapse reasoning trace',
|
||||
'chat.reasoningTrace.thought': 'Thought',
|
||||
'chat.messageBody.subtask.title': 'Delegated task',
|
||||
'chat.messageBody.subtask.hidePrompt': 'Hide prompt',
|
||||
'chat.messageBody.subtask.showPrompt': 'Show prompt',
|
||||
|
||||
@@ -1434,6 +1434,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.diffViewModeAria": "Modo de vista de comparación: {option}",
|
||||
"settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de razonamiento",
|
||||
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar trazas de razonamiento",
|
||||
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar bloques de razonamiento colapsables",
|
||||
"settings.openchamber.visual.field.collapsibleThinkingBlocks": "Habilitar Bloques de Razonamiento Colapsables",
|
||||
"settings.openchamber.visual.field.stickyUserHeaderAria": "Encabezado del usuario fijo",
|
||||
"settings.openchamber.visual.field.stickyUserHeader": "Encabezado de usuario fijo",
|
||||
"settings.openchamber.visual.field.wideChatLayoutAria": "Diseño de chat ancho",
|
||||
|
||||
@@ -1652,6 +1652,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.modelControls.modeValue.subagent": "Subagente",
|
||||
"chat.modelControls.modeValue.all": "Todos",
|
||||
"chat.modelControls.modeValue.none": "—",
|
||||
"chat.reasoningTrace.thinking": "Pensando",
|
||||
"chat.reasoningTrace.reasoning": "Razonando",
|
||||
"chat.reasoningTrace.justification": "Justificación",
|
||||
"chat.reasoningTrace.expandAria": "Expandir traza de razonamiento",
|
||||
"chat.reasoningTrace.collapseAria": "Contraer traza de razonamiento",
|
||||
"chat.reasoningTrace.thought": "Pensó",
|
||||
"chat.messageBody.subtask.title": "Tarea delegada",
|
||||
"chat.messageBody.subtask.hidePrompt": "Ocultar prompt",
|
||||
"chat.messageBody.subtask.showPrompt": "Mostrar prompt",
|
||||
|
||||
@@ -1434,6 +1434,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.diffViewModeAria': 'Diff 보기 모드: {option}',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': 'Reasoning trace 표시',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': 'Reasoning Trace 표시',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '접을 수 있는 추론 블록 활성화',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocks': '접을 수 있는 추론 블록 활성화',
|
||||
'settings.openchamber.visual.field.stickyUserHeaderAria': '고정 사용자 헤더',
|
||||
'settings.openchamber.visual.field.stickyUserHeader': '고정 사용자 헤더',
|
||||
'settings.openchamber.visual.field.wideChatLayoutAria': '넓은 채팅 레이아웃',
|
||||
|
||||
@@ -1686,6 +1686,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.modelControls.modeValue.subagent': '서브 에이전트',
|
||||
'chat.modelControls.modeValue.all': '전체',
|
||||
'chat.modelControls.modeValue.none': '—',
|
||||
'chat.reasoningTrace.thinking': '추론',
|
||||
'chat.reasoningTrace.reasoning': '추론 중',
|
||||
'chat.reasoningTrace.justification': '근거',
|
||||
'chat.reasoningTrace.expandAria': '추론 트레이스 펼치기',
|
||||
'chat.reasoningTrace.collapseAria': '추론 트레이스 접기',
|
||||
'chat.reasoningTrace.thought': '추론 완료',
|
||||
'chat.messageBody.subtask.title': '위임된 작업',
|
||||
'chat.messageBody.subtask.hidePrompt': '숨기기 프롬프트',
|
||||
'chat.messageBody.subtask.showPrompt': '표시 프롬프트',
|
||||
|
||||
@@ -840,6 +840,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.showMobileStatusBarAria': 'Pokaż mobilny pasek statusu',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': 'Pokaż ślady rozumowania',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': 'Pokaż ślady rozumowania',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocks': 'Włącz zwijalne bloki rozumowania',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Włącz zwijalne bloki rozumowania',
|
||||
'settings.openchamber.visual.field.showSplitAssistantMessageActions': 'Akcje asystenta w treści',
|
||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'Akcje asystenta w treści',
|
||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip': 'Pokaż Kopiuj odpowiedź, Zapisz jako obraz i Odczytaj na głos w blokach tekstu asystenta, które pojawiają się przed późniejszymi wywołaniami narzędzi w tej samej odpowiedzi.',
|
||||
|
||||
@@ -889,6 +889,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.subtask.openSession': 'Otwórz sesję podzadania',
|
||||
'chat.messageBody.subtask.showPrompt': 'Pokaż prompt',
|
||||
'chat.messageBody.subtask.title': 'Zadanie delegowane',
|
||||
'chat.reasoningTrace.thinking': 'Myślenie',
|
||||
'chat.reasoningTrace.reasoning': 'Rozumowanie',
|
||||
'chat.reasoningTrace.justification': 'Uzasadnienie',
|
||||
'chat.reasoningTrace.expandAria': 'Rozwiń ślad rozumowania',
|
||||
'chat.reasoningTrace.collapseAria': 'Zwiń ślad rozumowania',
|
||||
'chat.reasoningTrace.thought': 'Przemyślał',
|
||||
'chat.mobileStatus.editProjects.empty': 'Brak projektów do edycji',
|
||||
'chat.mobileStatus.editProjects.footer': 'Przeciągnij elementy, aby zmienić kolejność, lub użyj strzałek do przesuwania. Dotknij „edytuj”, aby zmienić szczegóły.',
|
||||
'chat.mobileStatus.editProjects.title': 'Edytuj projekty',
|
||||
|
||||
@@ -1434,6 +1434,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.diffViewModeAria": "Modo de visualização de comparação: {option}",
|
||||
"settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de raciocínio",
|
||||
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar rastros de raciocínio",
|
||||
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar blocos de raciocínio recolhíveis",
|
||||
"settings.openchamber.visual.field.collapsibleThinkingBlocks": "Habilitar Blocos de Raciocínio Recolhíveis",
|
||||
"settings.openchamber.visual.field.stickyUserHeaderAria": "Cabeçalho do usuário fixo",
|
||||
"settings.openchamber.visual.field.stickyUserHeader": "Cabeçalho do usuário fixo",
|
||||
"settings.openchamber.visual.field.wideChatLayoutAria": "Layout de chat amplo",
|
||||
|
||||
@@ -1652,6 +1652,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.modelControls.modeValue.subagent": "Subagente",
|
||||
"chat.modelControls.modeValue.all": "Todos",
|
||||
"chat.modelControls.modeValue.none": "—",
|
||||
"chat.reasoningTrace.thinking": "Pensando",
|
||||
"chat.reasoningTrace.reasoning": "Raciocinando",
|
||||
"chat.reasoningTrace.justification": "Justificativa",
|
||||
"chat.reasoningTrace.expandAria": "Expandir rastro de raciocínio",
|
||||
"chat.reasoningTrace.collapseAria": "Recolher rastro de raciocínio",
|
||||
"chat.reasoningTrace.thought": "Pensou",
|
||||
"chat.messageBody.subtask.title": "Tarefa delegada",
|
||||
"chat.messageBody.subtask.hidePrompt": "Ocultar prompt",
|
||||
"chat.messageBody.subtask.showPrompt": "Mostrar prompt",
|
||||
|
||||
@@ -1434,6 +1434,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.diffViewModeAria": "Режим перегляду diff: {option}",
|
||||
"settings.openchamber.visual.field.showReasoningTracesAria": "Показати сліди міркувань",
|
||||
"settings.openchamber.visual.field.showReasoningTraces": "Показати сліди міркувань",
|
||||
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Увімкнути згортальні блоки міркувань",
|
||||
"settings.openchamber.visual.field.collapsibleThinkingBlocks": "Увімкнути згортальні блоки міркувань",
|
||||
"settings.openchamber.visual.field.stickyUserHeaderAria": "Закріплений заголовок користувача",
|
||||
"settings.openchamber.visual.field.stickyUserHeader": "Закріплений заголовок користувача",
|
||||
"settings.openchamber.visual.field.wideChatLayoutAria": "Широкий макет чату",
|
||||
|
||||
@@ -1652,6 +1652,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.modelControls.modeValue.subagent": "Субагент",
|
||||
"chat.modelControls.modeValue.all": "всі",
|
||||
"chat.modelControls.modeValue.none": "—",
|
||||
"chat.reasoningTrace.thinking": "Міркування",
|
||||
"chat.reasoningTrace.reasoning": "Міркую",
|
||||
"chat.reasoningTrace.justification": "Обґрунтування",
|
||||
"chat.reasoningTrace.expandAria": "Розгорнути слід міркування",
|
||||
"chat.reasoningTrace.collapseAria": "Згорнути слід міркування",
|
||||
"chat.reasoningTrace.thought": "Обдумав",
|
||||
"chat.messageBody.subtask.title": "Делеговане завдання",
|
||||
"chat.messageBody.subtask.hidePrompt": "Приховати промпт",
|
||||
"chat.messageBody.subtask.showPrompt": "Показати промпт",
|
||||
|
||||
@@ -1434,6 +1434,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.diffViewModeAria': 'Diff 视图模式:{option}',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': '显示推理轨迹',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': '显示推理轨迹',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '启用可折叠推理块',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocks': '启用可折叠推理块',
|
||||
'settings.openchamber.visual.field.stickyUserHeaderAria': '固定用户消息头',
|
||||
'settings.openchamber.visual.field.stickyUserHeader': '固定用户消息头',
|
||||
'settings.openchamber.visual.field.wideChatLayoutAria': '宽聊天布局',
|
||||
|
||||
@@ -1652,6 +1652,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.modelControls.modeValue.subagent': '子智能体',
|
||||
'chat.modelControls.modeValue.all': '全部',
|
||||
'chat.modelControls.modeValue.none': '—',
|
||||
'chat.reasoningTrace.thinking': '思考',
|
||||
'chat.reasoningTrace.reasoning': '推理',
|
||||
'chat.reasoningTrace.justification': '理由',
|
||||
'chat.reasoningTrace.expandAria': '展开推理轨迹',
|
||||
'chat.reasoningTrace.collapseAria': '折叠推理轨迹',
|
||||
'chat.reasoningTrace.thought': '已思考',
|
||||
'chat.messageBody.subtask.title': '委派任务',
|
||||
'chat.messageBody.subtask.hidePrompt': '隐藏提示',
|
||||
'chat.messageBody.subtask.showPrompt': '显示提示',
|
||||
|
||||
@@ -345,6 +345,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
|
||||
store.setShowReasoningTraces(settings.showReasoningTraces);
|
||||
}
|
||||
if (typeof settings.collapsibleThinkingBlocks === 'boolean' && settings.collapsibleThinkingBlocks !== store.collapsibleThinkingBlocks) {
|
||||
store.setCollapsibleThinkingBlocks(settings.collapsibleThinkingBlocks);
|
||||
}
|
||||
if (typeof settings.autoDeleteEnabled === 'boolean' && settings.autoDeleteEnabled !== store.autoDeleteEnabled) {
|
||||
store.setAutoDeleteEnabled(settings.autoDeleteEnabled);
|
||||
}
|
||||
@@ -639,6 +642,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
||||
}
|
||||
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
|
||||
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
|
||||
}
|
||||
if (typeof candidate.autoDeleteEnabled === 'boolean') {
|
||||
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
|
||||
}
|
||||
|
||||
@@ -524,6 +524,8 @@ interface UIStore {
|
||||
eventStreamStatus: EventStreamStatus;
|
||||
eventStreamHint: string | null;
|
||||
showReasoningTraces: boolean;
|
||||
collapsibleThinkingBlocks: boolean;
|
||||
groupReasoningBlocks: boolean;
|
||||
chatRenderMode: ChatRenderMode;
|
||||
activityRenderMode: ActivityRenderMode;
|
||||
showDeletionDialog: boolean;
|
||||
@@ -653,6 +655,7 @@ interface UIStore {
|
||||
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
|
||||
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
|
||||
setShowReasoningTraces: (value: boolean) => void;
|
||||
setCollapsibleThinkingBlocks: (value: boolean) => void;
|
||||
setChatRenderMode: (value: ChatRenderMode) => void;
|
||||
setActivityRenderMode: (value: ActivityRenderMode) => void;
|
||||
setShowDeletionDialog: (value: boolean) => void;
|
||||
@@ -783,6 +786,8 @@ export const useUIStore = create<UIStore>()(
|
||||
eventStreamStatus: 'idle',
|
||||
eventStreamHint: null,
|
||||
showReasoningTraces: true,
|
||||
collapsibleThinkingBlocks: true,
|
||||
groupReasoningBlocks: true,
|
||||
chatRenderMode: 'live',
|
||||
activityRenderMode: 'summary',
|
||||
showDeletionDialog: true,
|
||||
@@ -1420,6 +1425,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ showReasoningTraces: value });
|
||||
},
|
||||
|
||||
setCollapsibleThinkingBlocks: (value) => {
|
||||
set({ collapsibleThinkingBlocks: value });
|
||||
},
|
||||
|
||||
setChatRenderMode: (value) => {
|
||||
set({ chatRenderMode: value });
|
||||
},
|
||||
@@ -2047,6 +2056,7 @@ export const useUIStore = create<UIStore>()(
|
||||
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
|
||||
// Note: isSettingsDialogOpen intentionally NOT persisted
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
|
||||
chatRenderMode: state.chatRenderMode,
|
||||
activityRenderMode: state.activityRenderMode,
|
||||
showDeletionDialog: state.showDeletionDialog,
|
||||
|
||||
Reference in New Issue
Block a user