Fix session/tool-question UX regressions and stabilize streaming activity rendering (#451)

* fix: stale model variants in chat controls in draft session

* fix: attention indicator for project tabs

* fix: use part index and type for consistent IDs

* fix question card navigation for unanswered questions
This commit is contained in:
Bohdan Triapitsyn
2026-02-20 01:47:33 +02:00
committed by GitHub
parent 50a66bb6f1
commit 22255cf2c0
7 changed files with 117 additions and 80 deletions
@@ -541,11 +541,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const inputModalityIcons = getModalityIcons(currentMetadata, 'input'); const inputModalityIcons = getModalityIcons(currentMetadata, 'input');
const outputModalityIcons = getModalityIcons(currentMetadata, 'output'); const outputModalityIcons = getModalityIcons(currentMetadata, 'output');
// Providers/models can reload (directory switch/config sync) without changing // Compute from current model each render to avoid stale variants
// currentProviderId/currentModelId; include providers to avoid stale variants. // in draft/session transitions.
const availableVariants = React.useMemo(() => { const availableVariants = getCurrentModelVariants();
return getCurrentModelVariants();
}, [getCurrentModelVariants]);
const hasVariants = availableVariants.length > 0; const hasVariants = availableVariants.length > 0;
const costRows = [ const costRows = [
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { RiCheckLine, RiCloseLine, RiEditLine, RiListCheck3, RiQuestionLine } from '@remixicon/react'; import { RiArrowRightSLine, RiCheckLine, RiCloseLine, RiEditLine, RiListCheck3, RiQuestionLine } from '@remixicon/react';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -76,25 +76,43 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
const selectedForActive = selectedOptions[activeIndex] ?? []; const selectedForActive = selectedOptions[activeIndex] ?? [];
const isCustomActive = Boolean(customMode[activeIndex]); const isCustomActive = Boolean(customMode[activeIndex]);
const requiredSatisfied = React.useMemo(() => { const unansweredIndexes = React.useMemo(() => {
if (questions.length === 0) return false; const pending: number[] = [];
for (let index = 0; index < questions.length; index += 1) { for (let index = 0; index < questions.length; index += 1) {
const isCustom = Boolean(customMode[index]); const isCustom = Boolean(customMode[index]);
if (isCustom) { if (isCustom) {
const value = (customText[index] ?? '').trim(); const value = (customText[index] ?? '').trim();
if (!value) return false; if (!value) pending.push(index);
continue; continue;
} }
const answers = selectedOptions[index] ?? []; const answers = selectedOptions[index] ?? [];
if (answers.length === 0) { if (answers.length === 0) {
return false; pending.push(index);
}
}
return pending;
}, [customMode, customText, questions.length, selectedOptions]);
const requiredSatisfied = React.useMemo(() => {
if (questions.length === 0) return false;
return unansweredIndexes.length === 0;
}, [questions.length, unansweredIndexes.length]);
const handleNextUnanswered = React.useCallback(() => {
if (questions.length === 0 || unansweredIndexes.length === 0) return;
const start = isSummaryTab ? -1 : activeIndex;
for (let offset = 1; offset <= questions.length; offset += 1) {
const candidate = (start + offset + questions.length) % questions.length;
if (unansweredIndexes.includes(candidate)) {
setActiveTab(String(candidate));
return;
} }
} }
return true; setActiveTab(String(unansweredIndexes[0]));
}, [customMode, customText, questions.length, selectedOptions]); }, [activeIndex, isSummaryTab, questions.length, unansweredIndexes]);
const buildAnswersPayload = React.useCallback((): string[][] => { const buildAnswersPayload = React.useCallback((): string[][] => {
const answers: string[][] = []; const answers: string[][] = [];
@@ -197,6 +215,8 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = activeTab === tab.value; const isActive = activeTab === tab.value;
const isSummary = tab.value === SUMMARY_TAB; const isSummary = tab.value === SUMMARY_TAB;
const tabIndex = isSummary ? -1 : Number(tab.value);
const isAnswered = !isSummary && Number.isFinite(tabIndex) && !unansweredIndexes.includes(tabIndex);
return ( return (
<button <button
key={tab.value} key={tab.value}
@@ -205,8 +225,12 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
className={cn( className={cn(
'px-2 py-0.5 typography-meta font-medium rounded transition-colors flex items-center gap-1', 'px-2 py-0.5 typography-meta font-medium rounded transition-colors flex items-center gap-1',
isActive isActive
? 'bg-interactive-selection/40 text-foreground' ? 'bg-interactive-selection/40 text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-interactive-hover/20' : isSummary
? 'text-muted-foreground hover:text-foreground hover:bg-interactive-hover/20'
: isAnswered
? 'text-muted-foreground/60 hover:text-muted-foreground hover:bg-interactive-hover/20'
: 'text-foreground/85 hover:text-foreground hover:bg-interactive-hover/20'
)} )}
> >
{isSummary ? <RiListCheck3 className="h-3 w-3" /> : null} {isSummary ? <RiListCheck3 className="h-3 w-3" /> : null}
@@ -362,16 +386,16 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="px-2 pb-1.5 pt-1 flex items-center gap-1.5 border-t border-border/20"> <div className="px-2 pb-1.5 pt-1 flex items-center gap-1.5 border-t border-border/20">
<button <button
type="button" type="button"
onClick={handleConfirm} onClick={requiredSatisfied ? handleConfirm : handleNextUnanswered}
disabled={isResponding || !requiredSatisfied} disabled={isResponding}
className={cn( className={cn(
'flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-colors', 'flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-colors',
'bg-[rgb(var(--status-success)/0.1)] text-[var(--status-success)] hover:bg-[rgb(var(--status-success)/0.2)]', 'bg-[rgb(var(--status-success)/0.1)] text-[var(--status-success)] hover:bg-[rgb(var(--status-success)/0.2)]',
'disabled:opacity-50 disabled:cursor-not-allowed' 'disabled:opacity-50 disabled:cursor-not-allowed'
)} )}
> >
<RiCheckLine className="h-3 w-3" /> {requiredSatisfied ? <RiCheckLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
Confirm {requiredSatisfied ? 'Submit' : 'Next'}
</button> </button>
<button <button
@@ -332,7 +332,6 @@ const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean)
} }
const activityParts: TurnActivityPart[] = []; const activityParts: TurnActivityPart[] = [];
let syntheticIdCounter = 0;
turn.assistantMessages.forEach((msg) => { turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id; const messageId = msg.info.id;
@@ -341,10 +340,8 @@ const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean)
// All earlier text messages are justification // All earlier text messages are justification
const isFinalSummaryMessage = messageId === lastTextMessageId; const isFinalSummaryMessage = messageId === lastTextMessageId;
msg.parts.forEach((part) => { msg.parts.forEach((part, partIndex) => {
const baseId = (typeof part.id === 'string' && part.id.trim().length > 0) const baseId = `${messageId}-part-${partIndex}-${part.type}`;
? part.id
: `${messageId}-activity-${syntheticIdCounter++}`;
if (part.type === 'tool') { if (part.type === 'tool') {
const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state; const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
@@ -418,13 +415,11 @@ const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean)
turn.assistantMessages.forEach((msg) => { turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id; const messageId = msg.info.id;
msg.parts.forEach((part) => { msg.parts.forEach((part, partIndex) => {
if (part.type === 'tool') { if (part.type === 'tool') {
const toolName = (part as { tool?: unknown }).tool; const toolName = (part as { tool?: unknown }).tool;
if (isActivityStandaloneTool(toolName)) { if (isActivityStandaloneTool(toolName)) {
const toolPartId = typeof part.id === 'string' && part.id.trim().length > 0 const toolPartId = `${messageId}-part-${partIndex}-${part.type}`;
? part.id
: `${messageId}-task-${taskOrder.length + 1}`;
if (!taskMessageById.has(toolPartId)) { if (!taskMessageById.has(toolPartId)) {
taskMessageById.set(toolPartId, messageId); taskMessageById.set(toolPartId, messageId);
@@ -201,7 +201,6 @@ const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean)
} }
const activityParts: TurnActivityPart[] = []; const activityParts: TurnActivityPart[] = [];
let syntheticIdCounter = 0;
turn.assistantMessages.forEach((msg) => { turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id; const messageId = msg.info.id;
@@ -210,11 +209,8 @@ const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean)
// All earlier text messages are justification // All earlier text messages are justification
const isFinalSummaryMessage = messageId === lastTextMessageId; const isFinalSummaryMessage = messageId === lastTextMessageId;
msg.parts.forEach((part) => { msg.parts.forEach((part, partIndex) => {
const baseId = const baseId = `${messageId}-part-${partIndex}-${part.type}`;
(typeof part.id === 'string' && part.id.trim().length > 0)
? part.id
: `${messageId}-activity-${syntheticIdCounter++}`;
if (part.type === 'tool') { if (part.type === 'tool') {
const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state; const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
@@ -295,13 +291,11 @@ const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean)
turn.assistantMessages.forEach((msg) => { turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id; const messageId = msg.info.id;
msg.parts.forEach((part) => { msg.parts.forEach((part, partIndex) => {
if (part.type === 'tool') { if (part.type === 'tool') {
const toolName = (part as { tool?: unknown }).tool; const toolName = (part as { tool?: unknown }).tool;
if (isActivityStandaloneTool(toolName)) { if (isActivityStandaloneTool(toolName)) {
const toolPartId = typeof part.id === 'string' && part.id.trim().length > 0 const toolPartId = `${messageId}-part-${partIndex}-${part.type}`;
? part.id
: `${messageId}-task-${taskOrder.length + 1}`;
if (!taskMessageById.has(toolPartId)) { if (!taskMessageById.has(toolPartId)) {
taskMessageById.set(toolPartId, messageId); taskMessageById.set(toolPartId, messageId);
@@ -51,13 +51,11 @@ const getToolConnections = (
const toolParts = parts.filter((p) => p.kind === 'tool'); const toolParts = parts.filter((p) => p.kind === 'tool');
toolParts.forEach((activity, index) => { toolParts.forEach((activity, index) => {
const partId = activity.part.id; const partId = activity.id;
if (partId) { connections[partId] = {
connections[partId] = { hasPrev: index > 0,
hasPrev: index > 0, hasNext: index < toolParts.length - 1,
hasNext: index < toolParts.length - 1, };
};
}
}); });
return connections; return connections;
@@ -117,7 +115,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
const visibleInCollapsedIds = React.useMemo(() => { const visibleInCollapsedIds = React.useMemo(() => {
const ids = new Set<string>(); const ids = new Set<string>();
visibleCollapsedParts.forEach((p) => { visibleCollapsedParts.forEach((p) => {
if (p.part.id) ids.add(p.part.id); ids.add(p.id);
}); });
return ids; return ids;
}, [visibleCollapsedParts]); }, [visibleCollapsedParts]);
@@ -218,8 +216,8 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
</div> </div>
)} )}
{partsToRender.map((activity, index) => { {partsToRender.map((activity) => {
const partId = activity.part.id || `group-part-${index}`; const partId = activity.id;
const connection = connectionsToUse[partId]; const connection = connectionsToUse[partId];
const animationKey = `${partId}-exp${expansionKey}`; const animationKey = `${partId}-exp${expansionKey}`;
@@ -227,19 +225,19 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
// Determine if animation should be skipped: // Determine if animation should be skipped:
// 1. When expanding from collapsed: skip for items that were already visible // 1. When expanding from collapsed: skip for items that were already visible
// 2. When collapsed: skip for items already shown before (track in ref) // 2. When collapsed: skip for items already shown before (track in ref)
const wasVisibleInCollapsed = activity.part.id ? visibleInCollapsedIds.has(activity.part.id) : false; const wasVisibleInCollapsed = visibleInCollapsedIds.has(activity.id);
let skipAnimation = false; let skipAnimation = false;
if (justExpandedFromCollapsed && wasVisibleInCollapsed) { if (justExpandedFromCollapsed && wasVisibleInCollapsed) {
// Expanding: don't animate items that were already visible in collapsed state // Expanding: don't animate items that were already visible in collapsed state
skipAnimation = true; skipAnimation = true;
} else if (!isExpanded && activity.part.id) { } else if (!isExpanded) {
// Collapsed: animate only items that haven't been shown yet // Collapsed: animate only items that haven't been shown yet
if (shownInCollapsedRef.current.has(activity.part.id)) { if (shownInCollapsedRef.current.has(activity.id)) {
skipAnimation = true; skipAnimation = true;
} else { } else {
// Mark as shown for future renders // Mark as shown for future renders
shownInCollapsedRef.current.add(activity.part.id); shownInCollapsedRef.current.add(activity.id);
} }
} }
@@ -250,7 +248,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<ToolPart <ToolPart
part={activity.part as ToolPartType} part={activity.part as ToolPartType}
isExpanded={expandedTools.has(partId)} isExpanded={expandedTools.has(partId)}
onToggle={onToggleTool} onToggle={() => onToggleTool(partId)}
syntaxTheme={syntaxTheme} syntaxTheme={syntaxTheme}
isMobile={isMobile} isMobile={isMobile}
onContentChange={onContentChange} onContentChange={onContentChange}
+3 -2
View File
@@ -425,7 +425,8 @@ export const Header: React.FC = () => {
hasStreaming = true; hasStreaming = true;
} }
if (session.id !== currentSessionId && sessionAttentionStates.get(session.id)?.needsAttention === true) { const isCurrentVisibleSession = session.id === currentSessionId && project.id === activeProjectId;
if (!isCurrentVisibleSession && sessionAttentionStates.get(session.id)?.needsAttention === true) {
hasNeedsAttention = true; hasNeedsAttention = true;
} }
@@ -442,7 +443,7 @@ export const Header: React.FC = () => {
} }
return result; return result;
}, [availableWorktreesByProject, currentSessionId, getSessionsByDirectory, projects, sessionAttentionStates, sessionStatus, sessionsByDirectory, showProjectTabs]); }, [activeProjectId, availableWorktreesByProject, currentSessionId, getSessionsByDirectory, projects, sessionAttentionStates, sessionStatus, sessionsByDirectory, showProjectTabs]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (!showProjectTabs) return; if (!showProjectTabs) return;
+52 -25
View File
@@ -45,6 +45,7 @@ declare global {
const ENABLE_EMPTY_RESPONSE_DETECTION = false; const ENABLE_EMPTY_RESPONSE_DETECTION = false;
const TEXT_SHRINK_TOLERANCE = 50; const TEXT_SHRINK_TOLERANCE = 50;
const RESYNC_DEBOUNCE_MS = 750; const RESYNC_DEBOUNCE_MS = 750;
const QUESTION_RECONCILE_COOLDOWN_MS = 1500;
const textLengthCache = new WeakMap<Part[], number>(); const textLengthCache = new WeakMap<Part[], number>();
const computeTextLength = (parts: Part[] | undefined | null): number => { const computeTextLength = (parts: Part[] | undefined | null): number => {
@@ -153,6 +154,38 @@ export const useEventStream = () => {
return undefined; return undefined;
}, [activeSessionDirectory, fallbackDirectory]); }, [activeSessionDirectory, fallbackDirectory]);
const bootstrapPendingQuestions = React.useCallback(async () => {
try {
const projects = useProjectsStore.getState().projects;
const projectDirs = projects.map((project) => project.path);
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
const sessionDirs = currentSessions.map((session) => (session as { directory?: string | null }).directory);
const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs];
const pending = await opencodeClient.listPendingQuestions({ directories });
if (pending.length === 0) {
return;
}
for (const request of pending) {
addQuestion(request as unknown as QuestionRequest);
}
} catch {
// ignored
}
}, [addQuestion, effectiveDirectory]);
const lastQuestionRefreshAtRef = React.useRef(0);
const requestPendingQuestionsRefresh = React.useCallback((force = false) => {
const now = Date.now();
if (!force && now - lastQuestionRefreshAtRef.current < QUESTION_RECONCILE_COOLDOWN_MS) {
return;
}
lastQuestionRefreshAtRef.current = now;
void bootstrapPendingQuestions();
}, [bootstrapPendingQuestions]);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -171,36 +204,13 @@ export const useEventStream = () => {
} }
}; };
const bootstrapPendingQuestions = async () => {
try {
const projects = useProjectsStore.getState().projects;
const projectDirs = projects.map((project) => project.path);
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
const sessionDirs = currentSessions.map((session) => (session as { directory?: string | null }).directory);
const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs];
const pending = await opencodeClient.listPendingQuestions({ directories });
if (cancelled || pending.length === 0) {
return;
}
for (const request of pending) {
addQuestion(request as unknown as QuestionRequest);
}
} catch {
// ignored
}
};
void bootstrapPendingPermissions(); void bootstrapPendingPermissions();
void bootstrapPendingQuestions(); requestPendingQuestionsRefresh(true);
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [addPermission, addQuestion, effectiveDirectory]); }, [addPermission, requestPendingQuestionsRefresh]);
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => { const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
@@ -852,8 +862,15 @@ export const useEventStream = () => {
const partTime = (messagePart as { time?: { end?: unknown } }).time; const partTime = (messagePart as { time?: { end?: unknown } }).time;
const partHasEnded = typeof partTime?.end === 'number'; const partHasEnded = typeof partTime?.end === 'number';
const toolState = (messagePart as { state?: { status?: unknown } }).state?.status; const toolState = (messagePart as { state?: { status?: unknown } }).state?.status;
const toolName = typeof (messagePart as { tool?: unknown }).tool === 'string'
? (messagePart as { tool: string }).tool.toLowerCase()
: null;
const textContent = (messagePart as { text?: unknown }).text; const textContent = (messagePart as { text?: unknown }).text;
if (partType === 'tool' && toolName === 'question') {
requestPendingQuestionsRefresh();
}
const isStreamingPart = (() => { const isStreamingPart = (() => {
if (partType === 'tool') { if (partType === 'tool') {
return toolState === 'running' || toolState === 'pending'; return toolState === 'running' || toolState === 'pending';
@@ -1229,6 +1246,15 @@ export const useEventStream = () => {
if (!hasParts && !completedFromServer && !hasCompletedStatus && !eventHasStopFinish) break; if (!hasParts && !completedFromServer && !hasCompletedStatus && !eventHasStopFinish) break;
if ((messageExt as { role?: unknown }).role === 'assistant' && hasParts) { if ((messageExt as { role?: unknown }).role === 'assistant' && hasParts) {
const hasQuestionTool = partsArray.some((part) => (
part?.type === 'tool'
&& typeof (part as { tool?: unknown }).tool === 'string'
&& (part as { tool: string }).tool.toLowerCase() === 'question'
));
if (hasQuestionTool) {
requestPendingQuestionsRefresh();
}
const incomingLen = computeTextLength(partsArray); const incomingLen = computeTextLength(partsArray);
const wouldShrink = existingLen > 0 && incomingLen + TEXT_SHRINK_TOLERANCE < existingLen; const wouldShrink = existingLen > 0 && incomingLen + TEXT_SHRINK_TOLERANCE < existingLen;
@@ -1662,6 +1688,7 @@ export const useEventStream = () => {
applySessionMetadata, applySessionMetadata,
trackMessage, trackMessage,
reportMessage, reportMessage,
requestPendingQuestionsRefresh,
updateSession, updateSession,
removeSessionFromStore, removeSessionFromStore,