qwen-code/packages/web-shell/client/components/ChatPane.tsx
Shaojin Wen 55b2886909
fix(web-shell): split-view pane fixes (remove "current" badge, clear composer on send) (#6454)
* fix(web-shell): remove meaningless "current" badge from split-view panes

In the split view every pane is an equal, independently interactive session (its own DaemonSessionProvider, SSE, transcript, and approvals), so tagging one pane as the workspace's "current" session carried no operational meaning. It only leaked a single-view concept into a peer-of-equals layout and reliably prompted "what is this?" questions from users. Panes are already identified by their titles.

Drop the isCurrent badge and its border highlight from ChatPane, and stop passing isCurrent from SplitView. The sidebar and session overview keep their "current" indicators, which are legitimate "you are here" navigation. currentSessionId is retained only to seed the initial pane.

* fix(web-shell): clear the split-view composer on send, not at turn end

A split-view pane kept the just-sent text sitting in its composer until the whole turn finished. handleSubmit committed the draft on the sendPrompt promise resolving, but that promise resolves via waitForAcceptedPromptCompletion (turn end), not at admission. Switch to the onAdmitted hook so the composer clears the moment the daemon accepts the prompt, matching the main view. A prompt rejected before admission still preserves the draft and surfaces the error.

* fix(web-shell): pass commitAccepted directly as onAdmitted; cover admit-then-fail

Review follow-up:
- commitAccepted is already `() => void`, so pass it directly as the onAdmitted option instead of wrapping it in a redundant `() => commitAccepted?.()` closure.
- Add a test for the turn failing after admission: the draft stays cleared (no second commit) and the error is still surfaced to onError.
2026-07-07 14:08:59 +00:00

223 lines
7.7 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useMemo } from 'react';
import {
useActions,
useConnection,
useStreamingState,
useTranscriptBlocks,
} from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../i18n';
import { useMessages } from '../hooks/useMessages';
import { extractPendingPermission } from '../adapters/transcriptAdapter';
import type { PromptImage } from '../adapters/promptTypes';
import type { ComposerSubmitCommit } from '../hooks/useComposerCore';
import { isAskUserPermission } from '../utils/askUserPermission';
import { MessageList } from './MessageList';
import { StreamingStatus } from './StreamingStatus';
import { ChatEditor } from './ChatEditor';
import { ToolApproval } from './messages/ToolApproval';
import { AskUserQuestion } from './messages/AskUserQuestion';
import styles from './ChatPane.module.css';
const EMPTY_COMMANDS: never[] = [];
const EMPTY_TOOLBAR: never[] = [];
export interface ChatPaneProps {
/** Header label; falls back to the session's own display name / id. */
title?: string;
onClose?: () => void;
onError?: (error: unknown, fallback: string) => void;
}
/**
* A self-contained interactive chat, scoped to whichever `DaemonSessionProvider`
* it is nested under. Rendering N of these (each under its own provider) inside
* one window is the split view: every pane has its own transcript, streaming
* state, approvals, and composer, and the browser scopes keyboard focus to the
* pane the user clicks into — so there is no cross-pane approval arbitration.
*/
export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
const { t } = useI18n();
const connection = useConnection();
const actions = useActions();
const messages = useMessages(t);
const blocks = useTranscriptBlocks();
const streamingState = useStreamingState();
const reportError = useCallback(
(error: unknown, fallback: string) => {
if (onError) onError(error, fallback);
else console.error(fallback, error);
},
[onError],
);
const pendingApproval = useMemo(
() => extractPendingPermission(blocks),
[blocks],
);
const isAskUser = isAskUserPermission(pendingApproval);
const pendingToolApproval =
pendingApproval && !isAskUser ? pendingApproval : null;
const pendingAskUserApproval =
pendingApproval && isAskUser ? pendingApproval : null;
const approvalActive =
pendingToolApproval !== null || pendingAskUserApproval !== null;
const isResponding = streamingState !== 'idle';
// Anchor the streaming timer to the turn's own start (the last user message's
// timestamp) rather than letting StreamingStatus fall back to "now" — so a
// pane opened mid-turn shows the real elapsed time, not a reset-to-zero clock.
const activeTurnStartedAt = useMemo(() => {
if (!isResponding) return undefined;
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role === 'user') return message.timestamp;
}
return undefined;
}, [messages, isResponding]);
const handleSubmit = useCallback(
(
text: string,
images?: PromptImage[],
commitAccepted?: ComposerSubmitCommit,
): boolean => {
const trimmed = text.trim();
if (!trimmed) return false;
// Keep the draft (return false) and clear it only once the daemon ADMITS
// the prompt. `onAdmitted` fires at acceptance; the sendPrompt promise
// itself resolves only when the whole (possibly long) turn finishes, so
// committing on resolution would strand the sent text in the composer for
// the entire response. If the prompt is rejected before admission
// (transcript still loading, session disconnected, or a turn already
// active) onAdmitted never fires, so the draft is preserved and the error
// is surfaced.
actions
.sendPrompt(trimmed, {
...(images && images.length ? { images } : {}),
onAdmitted: commitAccepted,
})
.catch((error: unknown) => reportError(error, 'Failed to send prompt'));
return false;
},
[actions, reportError],
);
const handleConfirm = useCallback(
(id: string, selectedOption: string, answers?: Record<string, string>) => {
actions
.submitPermission(id, selectedOption, answers)
.catch((error: unknown) =>
reportError(error, 'Failed to submit permission choice'),
);
},
[actions, reportError],
);
const handleCancel = useCallback(() => {
actions
.cancel()
.catch((error: unknown) =>
reportError(error, 'Failed to cancel request'),
);
}, [actions, reportError]);
const headerLabel =
title || connection.displayName || connection.sessionId?.slice(0, 8) || '';
return (
<section
className={styles.pane}
data-testid="chat-pane"
aria-label={headerLabel}
>
<header className={styles.header}>
<span className={styles.title} title={headerLabel}>
{headerLabel}
</span>
{onClose && (
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label={t('splitView.closePane')}
title={t('splitView.closePane')}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
d="M6 6l12 12M18 6L6 18"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
)}
</header>
{connection.error && (
<div className={styles.connectionError} role="alert">
<span className={styles.connectionErrorText}>
{t('splitView.paneConnectionError')}: {connection.error}
</span>
</div>
)}
<div className={styles.body}>
<MessageList
messages={messages}
pendingApproval={pendingToolApproval}
loadingTranscript={connection.loadingTranscript}
catchingUp={connection.catchingUp}
isResponding={isResponding}
workspaceCwd={connection.workspaceCwd || ''}
hideSessionTimeline
/>
</div>
<div className={styles.footer}>
{pendingToolApproval && (
<div className={styles.approval} data-testid="pane-approval">
<ToolApproval
request={pendingToolApproval}
onConfirm={handleConfirm}
variant="floating"
// Several panes can show approvals at once; global Enter/Escape
// shortcuts aren't focus-scoped, so keep pane approvals
// click-only to avoid confirming the wrong session's request.
keyboardActive={false}
/>
</div>
)}
{pendingAskUserApproval && (
<div className={styles.approval} data-testid="pane-approval">
<AskUserQuestion
request={pendingAskUserApproval}
onConfirm={handleConfirm}
variant="floating"
/>
</div>
)}
{/* Panes keep the composer status compact: spinner + elapsed time +
token count + cancel hint, but no rotating "witty" loading phrase. */}
<StreamingStatus startedAt={activeTurnStartedAt} showPhrase={false} />
<ChatEditor
onSubmit={handleSubmit}
onCancel={handleCancel}
isRunning={isResponding}
commands={EMPTY_COMMANDS}
visibleToolbarActions={EMPTY_TOOLBAR}
dialogOpen={approvalActive}
placeholderText={t('splitView.composerPlaceholder')}
/>
</div>
</section>
);
}