Skip to content
Merged
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
28 changes: 16 additions & 12 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2022,6 +2022,19 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return;
}

const nativeEvt = e.nativeEvent as KeyboardEvent;
// IME-owned keys must stay with the input method. In particular, Escape
// closes the Chinese/Japanese/Korean candidate window and must not cancel
// the running BitFun session.
const isComposing =
isImeComposingRef.current
|| nativeEvt.isComposing
|| nativeEvt.keyCode === 229;

if (e.key === 'Escape' && isComposing) {
return;
}

if (slashCommandState.isActive) {
if (!(slashCommandState.kind === 'modes' && !canSwitchModes)) {
const items =
Expand Down Expand Up @@ -2195,18 +2208,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}
}

const nativeEvt = e.nativeEvent as KeyboardEvent;
// IME-safe Enter detection (see useImeEnterGuard for the rationale):
// - our own composition flag covers browsers where `isComposing` is flaky
// - `keyCode === 229` is the W3C "composition keyCode" still emitted by
// every evergreen browser while the IME owns the key, even after
// `isComposing` has flipped back to false. Replaces the previous
// 120ms time-window guard which would swallow legitimate fast Enters.
const isComposing =
isImeComposingRef.current
|| nativeEvt.isComposing
|| nativeEvt.keyCode === 229;

if (e.key === 'Enter' && !e.shiftKey) {
if (isComposing) {
return;
Expand Down Expand Up @@ -2368,6 +2369,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
target.isContentEditable ||
target.closest('[contenteditable="true"]') !== null;

const isImeOwnedKey = e.key === 'Escape' && (e.isComposing || e.keyCode === 229);
if (isImeOwnedKey) return;

if (e.key === 'Escape' && derivedState?.canCancel) {
if (isEditable) return;
e.preventDefault();
Expand Down
29 changes: 29 additions & 0 deletions src/web-ui/src/flow_chat/components/RichTextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,33 @@ describeWithJsdom('RichTextInput external sync', () => {
expect(editor.textContent).toBe('server rewrite');
expect(editor.firstChild).not.toBe(originalTextNode);
});

it('keeps Escape owned by IME composition', async () => {
const onKeyDown = vi.fn();

await act(async () => {
root.render(
<RichTextInput
value=""
onChange={() => {}}
onKeyDown={onKeyDown}
contexts={emptyContexts}
onRemoveContext={() => {}}
/>
);
});

const editor = container.querySelector('.rich-text-input');
expect(editor).toBeInstanceOf(HTMLDivElement);

await act(async () => {
editor!.dispatchEvent(new window.KeyboardEvent('keydown', {
key: 'Escape',
keyCode: 229,
bubbles: true,
}));
});

expect(onKeyDown).not.toHaveBeenCalled();
});
});
6 changes: 3 additions & 3 deletions src/web-ui/src/flow_chat/components/RichTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,8 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
}, [internalRef, onLargePaste, onMentionStateChange]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const nativeIsComposing = (e.nativeEvent as KeyboardEvent).isComposing;
const composing = nativeIsComposing || isComposingRef.current;
const nativeEvent = e.nativeEvent as KeyboardEvent;
const composing = nativeEvent.isComposing || isComposingRef.current || nativeEvent.keyCode === 229;

if (!composing && e.key === 'Backspace' && internalRef.current) {
const selection = window.getSelection();
Expand All @@ -580,7 +580,7 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
}
}

if (composing && e.key === 'Enter') {
if (composing && (e.key === 'Enter' || e.key === 'Escape')) {
return;
}

Expand Down
52 changes: 52 additions & 0 deletions src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,58 @@

// ==================== Explore region adjustments ====================

.flowchat-flow-item--tool-transition {
overflow: hidden;
will-change: opacity, transform, max-height;
}

.flowchat-flow-item--tool-active {
animation: flow-tool-active-enter 180ms ease-out both;
}

.flowchat-flow-item--tool-completed {
animation: flow-tool-completed-exit 1000ms ease-in-out both;
}

@keyframes flow-tool-active-enter {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

@keyframes flow-tool-completed-exit {
0% {
opacity: 1;
transform: translateY(0);
max-height: 96px;
margin-bottom: 0.35rem;
}
55% {
opacity: 0.72;
transform: translateY(-1px);
max-height: 96px;
margin-bottom: 0.35rem;
}
100% {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
margin-bottom: 0;
}
}

@media (prefers-reduced-motion: reduce) {
.flowchat-flow-item--tool-active,
.flowchat-flow-item--tool-completed {
animation: none;
}
}

/* Focus highlight for programmatic "jump to marker" navigation. */
.flowchat-flow-item.flowchat-flow-item--focused {
outline: 2px solid var(--border-accent);
Expand Down
46 changes: 41 additions & 5 deletions src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { FlowChatStore } from '../../store/FlowChatStore';
import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager';
import { ExportImageButton } from './ExportImageButton';
import { ForkSessionButton } from './ForkSessionButton';
import { buildModelRoundItemGroups } from './modelRoundItemGrouping';
import { buildModelRoundItemGroups, COMPLETED_TOOL_TRANSIENT_MS } from './modelRoundItemGrouping';
import { Tooltip } from '@/component-library';
import { createLogger } from '@/shared/utils/logger';
import { SmoothHeightCollapse } from './SmoothHeightCollapse';
Expand Down Expand Up @@ -130,6 +130,32 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
[round.items]
);

const latestCompletedToolEndTime = useMemo(() => {
return sortedItems.reduce((latest, item) => {
if (item.type !== 'tool' || item.status !== 'completed') return latest;
const endTime = (item as FlowToolItem).endTime;
return typeof endTime === 'number' ? Math.max(latest, endTime) : latest;
}, 0);
}, [sortedItems]);
const [transientNowMs, setTransientNowMs] = useState(() => Date.now());

useEffect(() => {
if (latestCompletedToolEndTime <= 0) return;

const remainingMs = latestCompletedToolEndTime + COMPLETED_TOOL_TRANSIENT_MS - Date.now();
if (remainingMs <= 0) {
setTransientNowMs(Date.now());
return;
}

setTransientNowMs(Date.now());
const timeoutId = window.setTimeout(() => {
setTransientNowMs(Date.now());
}, remainingMs);

return () => window.clearTimeout(timeoutId);
}, [latestCompletedToolEndTime]);

// Group items in two passes:
// 1) group subagent items
// 2) group normal items into explore/critical via anchor tool
Expand All @@ -139,8 +165,9 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
isStreaming: round.isStreaming,
disableExploreGrouping: round.renderHints?.disableExploreGrouping === true,
isCollapsibleTool,
nowMs: transientNowMs,
});
}, [round.isStreaming, round.renderHints?.disableExploreGrouping, sortedItems]);
}, [round.isStreaming, round.renderHints?.disableExploreGrouping, sortedItems, transientNowMs]);

const extractDialogTurnContent = useCallback(() => {
const flowChatStore = FlowChatStore.getInstance();
Expand Down Expand Up @@ -667,11 +694,19 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({ item, turnId, isLas
<ModelThinkingDisplay thinkingItem={item as FlowThinkingItem} isLastItem={isLastItem} />
);

case 'tool':
case 'tool': {
const toolItem = item as FlowToolItem;
const isCompletedTool = toolItem.status === 'completed';
const toolClassName = [
'flowchat-flow-item',
'flowchat-flow-item--tool-transition',
isCompletedTool ? 'flowchat-flow-item--tool-completed' : 'flowchat-flow-item--tool-active',
].join(' ');

return wrapContent(
<div className="flowchat-flow-item" data-flow-item-id={item.id} data-flow-item-type="tool">
<div className={toolClassName} data-flow-item-id={item.id} data-flow-item-type="tool">
<FlowToolCard
toolItem={item as FlowToolItem}
toolItem={toolItem}
onConfirm={async (toolId: string, updatedInput?: any, permissionOptionId?: string, approve?: boolean) => {
if (onToolConfirm) {
await onToolConfirm(toolId, updatedInput, permissionOptionId, approve);
Expand All @@ -698,6 +733,7 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({ item, turnId, isLas
/>
</div>
);
}

default:
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,30 @@ function makeTextItem(id: string): FlowTextItem {
};
}

function makeReadTool(id: string): FlowToolItem {
function makeReadTool(
id: string,
status: FlowToolItem['status'] = 'completed',
endTime?: number,
): FlowToolItem {
return {
id,
type: 'tool',
toolName: 'Read',
timestamp: 1001,
status: 'completed',
status,
toolCall: {
id,
input: { file_path: 'src/main.rs' },
},
toolResult: {
result: 'file contents',
success: true,
},
...(status === 'completed'
? {
toolResult: {
result: 'file contents',
success: true,
},
}
: {}),
...(endTime !== undefined ? { endTime } : {}),
};
}

Expand Down Expand Up @@ -105,4 +114,74 @@ describe('buildModelRoundItemGroups', () => {
},
]);
});

it('keeps an active collapsible tool outside the preceding explore group', () => {
const completedTool = makeReadTool('tool-1');
const runningTool = makeReadTool('tool-2', 'running');

const groups = buildModelRoundItemGroups({
items: [completedTool, runningTool],
isStreaming: true,
disableExploreGrouping: false,
isCollapsibleTool: toolName => toolName === 'Read',
});

expect(groups).toEqual([
{
type: 'explore',
items: [completedTool],
isLast: false,
},
{
type: 'critical',
item: runningTool,
},
]);
});

it('keeps a just-completed collapsible tool visible before merging it', () => {
const completedTool = makeReadTool('tool-1');
const justCompletedTool = makeReadTool('tool-2', 'completed', 10_000);

const groups = buildModelRoundItemGroups({
items: [completedTool, justCompletedTool],
isStreaming: true,
disableExploreGrouping: false,
isCollapsibleTool: toolName => toolName === 'Read',
nowMs: 10_200,
});

expect(groups).toEqual([
{
type: 'explore',
items: [completedTool],
isLast: false,
},
{
type: 'critical',
item: justCompletedTool,
},
]);
});

it('merges a completed collapsible tool after the transition window', () => {
const completedTool = makeReadTool('tool-1');
const settledTool = makeReadTool('tool-2', 'completed', 10_000);

const groups = buildModelRoundItemGroups({
items: [completedTool, settledTool],
isStreaming: true,
disableExploreGrouping: false,
isCollapsibleTool: toolName => toolName === 'Read',
nowMs: 11_001,
});

expect(groups).toEqual([
{
type: 'explore',
items: [completedTool, settledTool],
isLast: true,
},
]);
});
});
Loading
Loading