mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
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:
@@ -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 })}
|
||||
|
||||
Reference in New Issue
Block a user