fix: distinguish waiting-on-user from streaming in ai sessions (#10396)

* feat: expand the active fork's family in the workspace menu

* fix: distinguish waiting-on-user from streaming in ai sessions

* fix: honor compact sizing in the waiting-for-input pill

* docs: condense waiting-state comments to the 4-line limit

* fix: detect a blocked tool card sitting behind queued ones

* fix: scan to the turn boundary for a blocked tool card
This commit is contained in:
Ruben Fiszel
2026-07-28 22:30:13 +02:00
committed by GitHub
parent cfb8ca4391
commit a17ccbdd0d
7 changed files with 202 additions and 114 deletions
@@ -14,7 +14,6 @@
Folder,
Hand,
HistoryIcon,
Hourglass,
MousePointer2,
Plus,
TextSelect,
@@ -25,7 +24,7 @@
import { fade } from 'svelte/transition'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { isActiveUserQuestion, type DisplayMessage } from './shared'
import { pendingUserAction, type DisplayMessage } from './shared'
import type { ContextElement } from './context'
import ChatQuickActions from './ChatQuickActions.svelte'
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
@@ -486,24 +485,15 @@
}
})
// "Waiting for user" detection — when the latest tool message is staged
// for confirmation or has an unanswered askUserQuestion, the AI loop is
// paused on the user, not on its own work. The typing-dots indicator
// implies the AI is busy, which is misleading; surface a text pill
// instead so users know to act on the tool above.
const waitingForUserAction = $derived.by(() => {
if (!aiChatManager.loading) return false
const last = messages[messages.length - 1]
if (!last || last.role !== 'tool') return false
if (last.needsConfirmation && last.isLoading) return true
if (isActiveUserQuestion(last)) return true
return false
})
// The typing-dots indicator implies the AI is busy, which is misleading while
// the loop is parked on the user; surface a text pill instead so users know to
// act on the tool above.
const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages))
// While the AI is waiting on an answer to an askUserQuestion, the only valid
// input is one of the choices (or the custom answer) in the question card —
// so disable the main chat input until the question is answered or canceled.
const hasActiveUserQuestion = $derived(isActiveUserQuestion(messages[messages.length - 1]))
const hasActiveUserQuestion = $derived(pendingUserAction(messages) === 'question')
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
@@ -682,28 +672,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
showFlowPendingActionControls ? 'bottom-14' : 'bottom-2'
)}
>
{#if waitingForUserAction}
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface/80 backdrop-blur text-2xs text-accent"
aria-label="Waiting for your input"
>
<Hourglass class="w-3 h-3 hourglass-flip" />
Waiting for your input
</span>
{:else}
<ChatTypingIndicator
loading={aiChatManager.loading}
label={aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
{/if}
<ChatTypingIndicator
loading={aiChatManager.loading}
paused={waitingForUserAction}
label={aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
</div>
{/if}
</div>
@@ -1067,26 +1048,3 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
</div>
</div>
<style>
/* Hourglass flips every 4s with long rests at each upright position.
`:global` because the class is applied to a child component's root
(Lucide SVG) and Svelte scoped CSS otherwise wouldn't match it. */
:global(.hourglass-flip) {
animation: hourglass-flip 4s cubic-bezier(0.65, 0, 0.35, 1) infinite;
transform-origin: center;
}
@keyframes hourglass-flip {
0%,
35% {
transform: rotate(0deg);
}
50%,
85% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -1,26 +1,37 @@
<script lang="ts">
import { Hourglass } from 'lucide-svelte'
let {
loading,
compact = false,
paused = false,
label
}: { loading: boolean; compact?: boolean; label?: string } = $props()
}: { loading: boolean; compact?: boolean; paused?: boolean; label?: string } = $props()
// Wall-clock for the typing-dots indicator. Starts on the rising edge of
// `loading`, ticks once a second, frozen on the last value when loading
// ends so callers reading the dots briefly after still see a coherent number.
let loadingStartedAt = $state<number | undefined>(undefined)
let loadingElapsedMs = $state(0)
// Starts on the rising edge of `loading`, frozen on its last value once loading
// ends so a caller reading it just after still sees a coherent number. `paused`
// suspends it and resumes where it stopped: time the user spends answering is
// theirs, and counting it makes a fast turn read as a slow one.
let elapsedMs = $state(0)
let accumulatedMs = 0
let wasLoading = false
$effect(() => {
if (!loading) {
loadingStartedAt = undefined
wasLoading = false
return
}
loadingStartedAt = Date.now()
loadingElapsedMs = 0
const interval = setInterval(() => {
if (loadingStartedAt) loadingElapsedMs = Date.now() - loadingStartedAt
}, 1000)
return () => clearInterval(interval)
if (!wasLoading) {
wasLoading = true
accumulatedMs = 0
elapsedMs = 0
}
if (paused) return
const startedAt = Date.now() - accumulatedMs
const interval = setInterval(() => (elapsedMs = Date.now() - startedAt), 1000)
return () => {
accumulatedMs = Date.now() - startedAt
clearInterval(interval)
}
})
function formatElapsed(ms: number): string {
@@ -35,30 +46,42 @@
}
</script>
<span
class={compact
? 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-surface/80 backdrop-blur'
: 'inline-flex items-center gap-2 px-2 py-1 rounded-md bg-surface/80 backdrop-blur'}
aria-label="AI is generating a response"
>
<span class={compact ? 'inline-flex items-end gap-0.5' : 'inline-flex items-end gap-1'}>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot'}
></span>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot chat-typing-dot-2'}
></span>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot chat-typing-dot-3'}
></span>
</span>
<span class={(compact ? 'text-[10px]' : 'text-2xs') + ' text-tertiary tabular-nums leading-none'}
>{label ? label + ' · ' : ''}{formatElapsed(loadingElapsedMs)}</span
{#if paused}
<span
class={(compact ? 'gap-1 px-1.5 py-0.5 text-[10px]' : 'gap-1.5 px-2 py-1 text-2xs') +
' inline-flex items-center rounded-md bg-surface/80 backdrop-blur text-accent'}
aria-label="Waiting for your input"
>
</span>
<Hourglass class={(compact ? 'w-2.5 h-2.5' : 'w-3 h-3') + ' hourglass-flip'} />
Waiting for your input
</span>
{:else}
<span
class={compact
? 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-surface/80 backdrop-blur'
: 'inline-flex items-center gap-2 px-2 py-1 rounded-md bg-surface/80 backdrop-blur'}
aria-label="AI is generating a response"
>
<span class={compact ? 'inline-flex items-end gap-0.5' : 'inline-flex items-end gap-1'}>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot'}
></span>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot chat-typing-dot-2'}
></span>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot chat-typing-dot-3'}
></span>
</span>
<span
class={(compact ? 'text-[10px]' : 'text-2xs') + ' text-tertiary tabular-nums leading-none'}
>{label ? label + ' · ' : ''}{formatElapsed(elapsedMs)}</span
>
</span>
{/if}
<style>
.chat-typing-dot {
@@ -80,4 +103,24 @@
opacity: 1;
}
}
/* Hourglass flips every 4s with long rests at each upright position. Global:
the class lands on a lucide component's own element. */
:global(.hourglass-flip) {
animation: hourglass-flip 4s cubic-bezier(0.65, 0, 0.35, 1) infinite;
transform-origin: center;
}
@keyframes hourglass-flip {
0%,
35% {
transform: rotate(0deg);
}
50%,
85% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -888,6 +888,55 @@ describe('isActiveUserQuestion', () => {
})
})
describe('pendingUserAction', () => {
const toolMessage = (overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage => ({
role: 'tool',
tool_call_id: 'call_p',
content: 'running',
isLoading: true,
...overrides
})
const question = toolMessage({ userQuestion: { question: 'Pick one', choices: ['a'] } })
it('distinguishes an unanswered question from a staged confirmation', async () => {
const { pendingUserAction } = await import('./shared')
expect(pendingUserAction([question])).toBe('question')
expect(pendingUserAction([toolMessage({ needsConfirmation: true })])).toBe('confirmation')
})
it('is undefined for a tool the AI is running on its own', async () => {
const { pendingUserAction } = await import('./shared')
expect(pendingUserAction([toolMessage()])).toBe(undefined)
expect(pendingUserAction([toolMessage({ needsConfirmation: true, isLoading: false })])).toBe(
undefined
)
})
// A multi-tool turn creates every card before running the calls one at a time,
// so the blocked card is not the last message.
it('finds a blocked card sitting behind queued ones', async () => {
const { pendingUserAction } = await import('./shared')
expect(pendingUserAction([question, toolMessage(), toolMessage()])).toBe('question')
expect(pendingUserAction([toolMessage({ needsConfirmation: true }), toolMessage()])).toBe(
'confirmation'
)
})
// Text emitted between two tool calls lands as an assistant card between them.
it('finds a blocked card behind an interleaved assistant card', async () => {
const { pendingUserAction } = await import('./shared')
const assistant: DisplayMessage = { role: 'assistant', content: 'and also…' }
expect(pendingUserAction([question, assistant, toolMessage()])).toBe('question')
})
it('stops at the previous turn rather than reviving its resolved cards', async () => {
const { pendingUserAction } = await import('./shared')
const userMessage: DisplayMessage = { role: 'user', index: 0, content: 'go on' }
expect(pendingUserAction([question, userMessage, toolMessage()])).toBe(undefined)
})
})
describe('pollJobCompletion detach', () => {
function makeCallbacks() {
return {
@@ -650,6 +650,26 @@ export function isActiveUserQuestion(message: DisplayMessage | undefined): boole
)
}
// The loop is parked on the user: an unanswered askUserQuestion, or a tool call
// staged for confirmation. The manager stays `loading` through both, so anything
// rendering progress must ask here first or it reports "the AI is working".
export type PendingUserAction = 'question' | 'confirmation'
// Scans back to the turn boundary, not just the last message: a turn's cards are
// created up front and run one at a time, and text between two tool calls pushes
// an assistant card between them, so the blocked card is rarely last. Only cards
// of a live turn can match — every resolution path clears `isLoading`.
export function pendingUserAction(messages: DisplayMessage[]): PendingUserAction | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role === 'user') break
if (message.role !== 'tool') continue
if (isActiveUserQuestion(message)) return 'question'
if (message.needsConfirmation && message.isLoading) return 'confirmation'
}
return undefined
}
// Fires after every tool call resolves, with the tool name. Lets a host (e.g.
// the sessions page) react to mutating tools — refreshing previews — without
// the tool layer knowing about the UI. Single slot; the consumer filters by name
@@ -1,11 +1,5 @@
<script lang="ts">
import {
AlertCircle,
AlertTriangle,
Building,
GitFork,
GitPullRequestClosed
} from 'lucide-svelte'
import { AlertTriangle, Building, CircleHelp, GitFork, GitPullRequestClosed } from 'lucide-svelte'
import type { SessionChatStatus } from './sessionRuntime.svelte'
// The fork icon is deliberately sync-state-agnostic (no ahead/behind/
@@ -21,16 +15,20 @@
idle: 'No chat activity',
streaming: 'Generating response…',
'awaiting-user': 'Waiting for your reply',
'awaiting-answer': 'Waiting for your answer',
'needs-confirmation': 'Needs your confirmation',
draft: 'Unsent draft',
error: 'Last message had an error'
}
// Live chat signals take precedence over the persistent kind/fork
// indicator: streaming, needs-confirmation, and error are time-critical
// and warrant briefly hijacking the icon slot.
// indicator: they are time-critical and warrant briefly hijacking the icon
// slot.
const liveOverride = $derived(
status === 'streaming' || status === 'needs-confirmation' || status === 'error'
status === 'streaming' ||
status === 'awaiting-answer' ||
status === 'needs-confirmation' ||
status === 'error'
)
const persistentTitle = $derived(
@@ -51,8 +49,10 @@
<span class="w-[3px] h-[3px] rounded-full bg-blue-500 typing-dot dot-2"></span>
<span class="w-[3px] h-[3px] rounded-full bg-blue-500 typing-dot dot-3"></span>
</span>
{:else if status === 'needs-confirmation'}
<AlertCircle class="w-3 h-3 text-amber-500" />
{:else if status === 'awaiting-answer' || status === 'needs-confirmation'}
<!-- Both mean "the run is blocked on you"; at 12px a second amber circle
glyph would be indistinguishable, so the tooltip carries which one. -->
<CircleHelp class="w-3 h-3 text-amber-500" />
{:else if status === 'error'}
<AlertTriangle class="w-3 h-3 text-red-500" />
{:else if isFork}
@@ -86,7 +86,7 @@ import type {
RawAppDomResult
} from '$lib/components/raw_apps/rawAppDom'
import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
// Per-kind load state for a session's editor target. Pure state container the
@@ -967,7 +967,10 @@ export function resetSessionPreviewTabs(sessionId: string, url: string): void {
export type SessionChatStatus =
| 'idle'
| 'streaming'
// The assistant's turn ended and the user has yet to reply — passive, unlike
// 'awaiting-answer'/'needs-confirmation', where a running loop is blocked.
| 'awaiting-user'
| 'awaiting-answer'
| 'needs-confirmation'
| 'draft'
| 'error'
@@ -1243,10 +1246,15 @@ setScreenshotHandler(async ({ sessionId: callerSessionId }) => {
export function getSessionChatStatus(runtime: SessionRuntime): SessionChatStatus {
const m = runtime.manager
const last = m.displayMessages[m.displayMessages.length - 1]
// A loop parked on the user still reports `loading`, so these must be tested
// before `streaming` — otherwise "answer me" renders as "the AI is typing"
// and a session that needs the user looks like one that doesn't.
const pending = pendingUserAction(m.displayMessages)
if (pending === 'question') return 'awaiting-answer'
if (pending === 'confirmation') return 'needs-confirmation'
if (m.loading) return 'streaming'
if (m.instructions.trim().length > 0) return 'draft'
const last = m.displayMessages[m.displayMessages.length - 1]
if (last?.role === 'tool' && last.needsConfirmation) return 'needs-confirmation'
if (last?.role === 'user' && last.error) return 'error'
if (last && (last.role === 'assistant' || last.role === 'tool')) return 'awaiting-user'
return 'idle'
@@ -179,6 +179,15 @@
return ambiguous
})
// Opening while a fork is active expands that fork's family, so the tick sits
// on the active fork's own row instead of on its collapsed root.
function seedExpandedFamilies() {
expandedFamilies.clear()
if (currentFamily && currentFamily.id !== $workspaceStore) {
expandedFamilies.add(currentFamily.id)
}
}
// The active workspace itself (fork included) — names the settings entry.
const activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
const canManageWorkspace = $derived(
@@ -193,13 +202,14 @@
<svelte:window onkeydowncapture={onExpandKeydown} />
<!-- Expansion is per-open: every open starts with all families collapsed,
including the active fork's. -->
<!-- Expansion is per-open: every open starts from the active workspace's family
alone, discarding whatever the previous open expanded. -->
<Menu
{createMenu}
usePointerDownOutside
placement="bottom-start"
bind:open={menuOpen}
on:open={seedExpandedFamilies}
on:close={() => expandedFamilies.clear()}
>
{#snippet triggr({ trigger })}