fix: question tool content disappears after refresh (#879) (#909)

* fix: show question content in ToolPart instead of 'Awaiting response...' after refresh

Previously, when the question tool was pending/running or completed
without parseable output, the ToolPart fell through to a generic
'Awaiting response...' message. After a page refresh or app restart,
this made questions appear empty even though the tool state still
contained the question input data.

Now the ToolPart reads question text, headers, and options from the
tool state's input field, ensuring question content persists across
refreshes regardless of QuestionCard store availability.

Fixes #879

* fix: restore QuestionCard after refresh and pause working status during active questions

Two fixes for question tool UX:

1. ChatContainer: sessionIsWorking now returns false when there are
   active questions (same as it already did for permissions). This
   prevents the status row from showing 'Asking question...' and
   instead shows the QuestionCard.

2. sync-context: resyncDirectoryAfterReconnect now re-fetches
   pending questions via listPendingQuestions(). Previously only
   sessions and messages were re-fetched on SSE reconnect, so
   questions asked during disconnection were lost, causing
   QuestionCard to disappear after page refresh.

Refs #879

* fix: hide assistant working status while questions are pending

The assistant status hook only special-cased pending permissions, so
question tools still surfaced 'Asking question...' after refresh even
when the UI was already waiting on a QuestionCard response.

Treat pending questions like other blocking requests by clearing the
working indicator until the user answers.

Refs #879

* fix: merge question/permission stores instead of full replace on bootstrap and reconnect

The root cause of QuestionCard disappearing after refresh was a race
condition between SSE events and HTTP bootstrap. Bootstrap and reconnect
both did full replacement of state.question, wiping SSE-delivered data
that arrived between the HTTP call initiation and response arrival.

Changes:
- bootstrap.ts: question and permission stores now use merge semantics.
  Only sessions present in the API response are overwritten. Sessions
  absent from the response are left untouched (they may hold SSE data).
- sync-context.tsx: reconnect question resync uses the same merge pattern.
  No longer clears question entries for sessions not in the API response.
- bootstrap.ts: sdk.question.list() now passes directory parameter to
  scope the query correctly.

This ensures SSE-delivered question data survives the bootstrap window,
while still allowing the API response to be authoritative for sessions
it covers.

Refs #879

* fix: prune stale pending requests after reconnect

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-04-14 20:02:07 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 01ca471776
commit 5584537da5
5 changed files with 148 additions and 36 deletions
@@ -326,7 +326,7 @@ export const ChatContainer: React.FC = () => {
return flattenBlockingRequests(questionsMap, scopedSessionIds);
}, [questionsMap, scopedSessionIds]);
const sessionIsWorking = React.useMemo(() => {
if (!currentSessionId || sessionPermissions.length > 0) {
if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.length > 0) {
return false;
}
@@ -345,7 +345,7 @@ export const ChatContainer: React.FC = () => {
&& lastMessage.role === 'assistant'
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
);
}, [activeStreamingPhase, currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type, streamingMessageId]);
}, [activeStreamingPhase, currentSessionId, sessionMessages, sessionPermissions.length, sessionQuestions.length, sessionStatusForCurrent.type, streamingMessageId]);
const activeRetryStatus = React.useMemo(() => {
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
return null;
@@ -1526,7 +1526,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
);
};
// Question tool: show parsed Q&A summary
// Question tool: show parsed Q&A summary or question content from input
if (part.tool === 'question') {
if (state.status === 'completed' && hasStringOutput) {
const parsedQA = parseQuestionOutput(outputString);
@@ -1560,6 +1560,35 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
);
}
// Show question content from input whenever available, whether the tool is
// pending/running or completed without parseable output. This ensures question
// text persists across refreshes even if the QuestionCard store data is lost.
const questionInput = input as { questions?: Array<{ question?: string; header?: string; options?: Array<{ label: string; description: string }>; multiple?: boolean }> } | undefined;
if (questionInput?.questions && Array.isArray(questionInput.questions) && questionInput.questions.length > 0) {
return renderScrollableBlock(
<div className="space-y-2">
{questionInput.questions.map((q, index) => (
<div key={index} className="space-y-0.5">
{q.header ? (
<div className="typography-micro text-muted-foreground">{q.header}</div>
) : null}
<div className="typography-meta text-foreground">{q.question}</div>
{Array.isArray(q.options) && q.options.length > 0 ? (
<div className="flex flex-wrap gap-1 mt-0.5">
{q.options.map((opt) => (
<span key={opt.label} className="typography-micro px-1.5 py-0.5 rounded bg-muted/30 border border-border/30 text-muted-foreground">
{opt.label}
</span>
))}
</div>
) : null}
</div>
))}
</div>,
{ maxHeightClass: 'max-h-[40vh]' }
);
}
return <div className="typography-meta text-muted-foreground">Awaiting response...</div>;
}
+19 -3
View File
@@ -3,7 +3,7 @@ import type { AssistantMessage, Message, Part, ReasoningPart, TextPart, ToolPart
import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectorySync, useSessionPermissions, useSessionStatus } from '@/sync/sync-context';
import { useDirectorySync, useSessionPermissions, useSessionQuestions, useSessionStatus } from '@/sync/sync-context';
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
import { useCurrentSessionActivity } from './useSessionActivity';
@@ -163,6 +163,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
);
const sessionPermissionRequests = useSessionPermissions(currentSessionId ?? '');
const sessionQuestionRequests = useSessionQuestions(currentSessionId ?? '');
const sessionAbortRecord = useSessionUIStore(
React.useCallback((state) => {
@@ -427,11 +428,26 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
}
const hasPendingPermission = sessionPermissionRequests.length > 0;
const hasPendingQuestion = sessionQuestionRequests.length > 0;
if (!hasPendingPermission) {
if (!hasPendingPermission && !hasPendingQuestion) {
return baseWorking;
}
if (hasPendingQuestion) {
return {
...baseWorking,
statusText: null,
isWorking: false,
hasWorkingContext: false,
hasActiveTools: false,
canAbort: false,
activePartType: undefined,
activeToolName: undefined,
retryInfo: null,
};
}
return {
...baseWorking,
statusText: 'waiting for permission',
@@ -439,7 +455,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
canAbort: false,
retryInfo: null,
};
}, [baseWorking, sessionPermissionRequests]);
}, [baseWorking, sessionPermissionRequests, sessionQuestionRequests]);
return {
forming,
+50 -30
View File
@@ -4,6 +4,14 @@ import type { GlobalState, State } from "./types"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
if (!items || items.length === 0) return ""
return items
.map((item) => item.id)
.sort(cmp)
.join("|")
}
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
return input.reduce<Record<string, T[]>>((acc, item) => {
if (!item?.id || !item.sessionID) return acc
@@ -129,44 +137,56 @@ export async function bootstrapDirectory(input: {
set({ vcs: x.data ?? current.vcs })
}),
),
retry(() =>
sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
const permission: Record<string, PermissionRequest[]> = {}
// Clear sessions no longer having permissions
const current = getState()
for (const sessionID of Object.keys(current.permission ?? {})) {
if (!grouped[sessionID]) permission[sessionID] = []
}
// Set grouped permissions sorted by id
for (const [sessionID, perms] of Object.entries(grouped)) {
permission[sessionID] = perms
.filter((p) => !!p?.id)
.sort((a, b) => cmp(a.id, b.id))
}
set({ permission })
}),
),
retry(() =>
sdk.question.list().then((x) => {
retry(async () => {
const before = getState()
const beforeSignatures = new Map(
Object.entries(before.question ?? {}).map(([sessionID, questions]) => [sessionID, requestSignature(questions)]),
)
const x = await sdk.question.list(directory ? { directory } : undefined)
const grouped = groupBySession(
(x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID),
)
const question: Record<string, QuestionRequest[]> = {}
const current = getState()
for (const sessionID of Object.keys(current.question ?? {})) {
if (!grouped[sessionID]) question[sessionID] = []
}
const merged = { ...current.question }
for (const [sessionID, questions] of Object.entries(grouped)) {
question[sessionID] = questions
merged[sessionID] = questions
.filter((q) => !!q?.id)
.sort((a, b) => cmp(a.id, b.id))
}
set({ question })
}),
),
for (const sessionID of beforeSignatures.keys()) {
if (grouped[sessionID]) continue
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
const currentSignature = requestSignature(current.question[sessionID])
if (currentSignature !== beforeSignature) continue
delete merged[sessionID]
}
set({ question: merged })
}),
retry(async () => {
const before = getState()
const beforeSignatures = new Map(
Object.entries(before.permission ?? {}).map(([sessionID, permissions]) => [sessionID, requestSignature(permissions)]),
)
const x = await sdk.permission.list(directory ? { directory } : undefined)
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm?.sessionID),
)
const current = getState()
const merged = { ...current.permission }
for (const [sessionID, perms] of Object.entries(grouped)) {
merged[sessionID] = perms
.filter((p) => !!p?.id)
.sort((a, b) => cmp(a.id, b.id))
}
for (const sessionID of beforeSignatures.keys()) {
if (grouped[sessionID]) continue
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
const currentSignature = requestSignature(current.permission[sessionID])
if (currentSignature !== beforeSignature) continue
delete merged[sessionID]
}
set({ permission: merged })
}),
])
const errors = results
+47
View File
@@ -98,6 +98,13 @@ let bootedAt = 0
const BOOT_DEBOUNCE_MS = 1500
const RECONNECT_MESSAGE_LIMIT = 200
const RECONNECT_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
if (!items || items.length === 0) return ""
return items
.map((item) => item.id)
.sort(cmp)
.join("|")
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -719,6 +726,46 @@ async function resyncDirectoryAfterReconnect(
setIndexedSessionMessages(routingIndex, sessionId, directory, nextMessages)
}))
// Re-fetch pending questions on reconnect — they may have been asked
// during the SSE disconnection window and will not arrive via SSE events.
// Overwrite sessions covered by API response, and clear reconnect candidates
// that remain unchanged during the request but are absent from the response.
// If SSE changed a session while the request was in-flight, keep that data.
try {
const before = store.getState()
const beforeSignatures = new Map(
candidateSessionIds.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]),
)
const pendingQuestions = await opencodeClient.listPendingQuestions({ directories: [directory] })
const grouped: Record<string, QuestionRequest[]> = {}
for (const q of pendingQuestions) {
if (!q?.id || !q.sessionID) continue
const list = grouped[q.sessionID]
if (list) list.push(q)
else grouped[q.sessionID] = [q]
}
// Sort each group by id for binary-search compatibility
for (const sessionId of Object.keys(grouped)) {
grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}
store.setState((state: DirectoryStore) => {
const merged = { ...state.question }
for (const [sessionId, questions] of Object.entries(grouped)) {
merged[sessionId] = questions
}
for (const sessionId of candidateSessionIds) {
if (grouped[sessionId]) continue
const beforeSignature = beforeSignatures.get(sessionId) ?? ""
const currentSignature = requestSignature(state.question[sessionId])
if (currentSignature !== beforeSignature) continue
delete merged[sessionId]
}
return { question: merged }
})
} catch {
// Non-fatal: question resync best-effort
}
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
}