Skip to content
Merged
3 changes: 3 additions & 0 deletions src/lib/components/primitives/DesktopSessionList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@

function getStatusLabel(status: SessionStatus): string {
switch (status) {
case 'unknown':
return 'Status unavailable';
case 'thinking':
return 'Thinking';
case 'waiting':
Expand All @@ -399,6 +401,7 @@
case 'cancelling':
return 'running';
case 'waiting':
case 'unknown':
return 'warning';
case 'completed':
return 'success';
Expand Down
5 changes: 5 additions & 0 deletions src/lib/components/primitives/DesktopSessionList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ describe('DesktopSessionList recent sessions', () => {
});

describe('DesktopSessionList', () => {
it('does not label unknown execution as Idle', () => {
render(DesktopSessionList, { sessions: [{ ...sessions[0], status: 'unknown' }] });
expect(screen.getByLabelText('Status: Status unavailable')).toBeInTheDocument();
expect(screen.queryByLabelText('Status: Idle')).not.toBeInTheDocument();
});
it('delays loading chrome while keeping the pending state distinct from an empty catalog', async () => {
vi.useFakeTimers();
const { container } = render(DesktopSessionList, { loading: true });
Expand Down
72 changes: 46 additions & 26 deletions src/lib/components/session/SessionActivityBar.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { AlertTriangle, LoaderCircle, MessageCircleQuestion } from '@lucide/svelte';
import { Tooltip } from 'bits-ui';
import { getSessionToolPresentation } from '$lib/domain/session-tool-presentation';
import type { ActiveSessionViewModel } from '$lib/domain/types';

Expand All @@ -17,8 +18,9 @@
session.runState === 'streaming' ||
session.runState === 'tool-running'
);
const isBusy = $derived(session.undo.pendingOperation !== null || forkPending || agentBusy);
const isVisible = $derived(isBusy || session.runState === 'failed' || session.runState === 'waiting-input');
const canCancel = $derived(agentBusy && !session.undo.pendingOperation && !forkPending);
const isBusy = $derived(session.undo.pendingOperation !== null || forkPending || agentBusy || !!session.dependencyLabel);
const isVisible = $derived(isBusy || session.runState === 'failed' || session.runState === 'waiting-input' || session.runState === 'unknown');
const activeTool = $derived(session.toolCalls.find((tool) => tool.id === session.activeToolCallId) ?? null);
const activeToolPresentation = $derived(activeTool ? getSessionToolPresentation(activeTool) : null);

Expand All @@ -28,39 +30,57 @@
if (session.undo.pendingOperation === 'redo') return 'Restoring workspace changes…';
if (session.runState === 'failed') return session.lastError ? `Failed: ${session.lastError}` : 'Session failed';
if (session.runState === 'waiting-input') return 'Input needed';
if (session.runState === 'unknown') return session.activityLabel ?? 'Execution status unavailable';
if (session.dependencyLabel && !agentBusy) return `Own turn idle - ${session.dependencyLabel}`;
if (session.activityLabel?.startsWith('Cancelling') || session.activityLabel?.startsWith('Finishing')) return session.activityLabel;
if (session.runState === 'tool-running' && activeToolPresentation) {
return `${activeToolPresentation.label}${activeToolPresentation.preview ? ` · ${activeToolPresentation.preview}` : ''}`;
const count = session.toolCalls.filter((tool) => (tool.status === 'pending' || tool.status === 'in_progress') && (!session.runId || !tool.runId || tool.runId === session.runId)).length;
return `${activeToolPresentation.label}${activeToolPresentation.preview ? ` · ${activeToolPresentation.preview}` : ''}${count > 1 ? ` (+${count - 1} active)` : ''}`;
}
if (session.runState === 'tool-running') return session.activityLabel ?? 'Running tool…';
if (session.runState === 'streaming') return 'Responding…';
if (session.runState === 'thinking') return session.activityLabel ?? 'Thinking…';
if (session.runState === 'thinking') return `${session.activityLabel ?? 'Thinking...'}${session.dependencyLabel ? ` - ${session.dependencyLabel}` : ''}`;
if (session.runState === 'submitting') return 'Sending prompt…';
return '';
});
</script>

{#if isVisible}
<section
class={`session-activity-bar ${isBusy ? 'session-activity-bar-busy' : ''} ${session.runState === 'failed' ? 'session-activity-bar-failed' : ''} ${session.runState === 'waiting-input' ? 'session-activity-bar-attention' : ''}`}
role={session.runState === 'failed' ? 'alert' : 'status'}
aria-live={session.runState === 'failed' ? 'assertive' : 'polite'}
>
<span class="session-activity-indicator" aria-hidden="true">
{#if isBusy}
<LoaderCircle size={14} class="animate-spin" />
{:else if session.runState === 'waiting-input'}
<MessageCircleQuestion size={14} />
{:else}
<AlertTriangle size={14} />
<Tooltip.Provider delayDuration={250} skipDelayDuration={80}>
<Tooltip.Root disableHoverableContent disabled={!canCancel}>
<Tooltip.Trigger>
{#snippet child({ props })}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Focus exposes the informational tooltip without making the live region a button.) -->
<section
{...props}
class={`session-activity-bar ${isBusy ? 'session-activity-bar-busy' : ''} ${session.runState === 'failed' ? 'session-activity-bar-failed' : ''} ${session.runState === 'waiting-input' ? 'session-activity-bar-attention' : ''}`}
role={session.runState === 'failed' ? 'alert' : 'status'}
aria-live={session.runState === 'failed' ? 'assertive' : 'polite'}
tabindex={canCancel ? 0 : undefined}
>
<span class="session-activity-indicator" aria-hidden="true">
{#if isBusy}
<LoaderCircle size={14} class="animate-spin" />
{:else if session.runState === 'waiting-input'}
<MessageCircleQuestion size={14} />
{:else}
<AlertTriangle size={14} />
{/if}
</span>
<span class="session-activity-title">{primaryStatus}</span>
{#if session.runState === 'failed'}
<span class="session-activity-meta">Review the error, update the prompt, or refresh.</span>
{:else if session.runState === 'waiting-input'}
<span class="session-activity-meta">Respond below to continue.</span>
{/if}
</section>
{/snippet}
</Tooltip.Trigger>
{#if canCancel}
<Tooltip.Portal>
<Tooltip.Content class="app-tooltip-content" sideOffset={6}>Double Esc to cancel</Tooltip.Content>
</Tooltip.Portal>
{/if}
</span>
<span class="session-activity-title">{primaryStatus}</span>
{#if session.runState === 'failed'}
<span class="session-activity-meta">Review the error, update the prompt, or refresh.</span>
{:else if session.runState === 'waiting-input'}
<span class="session-activity-meta">Respond below to continue.</span>
{:else if !session.undo.pendingOperation && !forkPending}
<span class="session-activity-meta">Double Esc to cancel</span>
{/if}
</section>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
53 changes: 51 additions & 2 deletions src/lib/components/session/SessionActivityBar.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen } from '@testing-library/svelte';
import { cleanup, fireEvent, render, screen } from '@testing-library/svelte';
import { afterEach, describe, expect, it } from 'vitest';
import SessionActivityBar from './SessionActivityBar.svelte';
import { session } from './session-fixture';
Expand All @@ -18,7 +18,56 @@ describe('SessionActivityBar', () => {
render(SessionActivityBar, { session: session({ runState: 'thinking', activityLabel: 'Reviewing files…' }) });

expect(screen.getByRole('status')).toHaveTextContent('Reviewing files…');
expect(screen.getByRole('status')).toHaveTextContent('Double Esc to cancel');
expect(screen.getByRole('status')).not.toHaveTextContent('Double Esc to cancel');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});

it.each(['hover', 'focus'] as const)('shows the shared styled cancel tooltip on bar %s without a button or native title', async (intent) => {
render(SessionActivityBar, { session: session({ runState: 'tool-running' }) });
const bar = screen.getByRole('status');
expect(bar.tagName).toBe('SECTION');
expect(bar).toHaveAttribute('aria-live', 'polite');
expect(bar).toHaveAttribute('tabindex', '0');
expect(bar).not.toHaveAttribute('title');
expect(bar).not.toHaveTextContent('Double Esc to cancel');
expect(screen.queryByText('Double Esc to cancel')).not.toBeInTheDocument();
if (intent === 'hover') await fireEvent.pointerEnter(bar, { pointerType: 'mouse' });
else bar.focus();
const tooltip = await screen.findByText('Double Esc to cancel', { selector: '.app-tooltip-content' });
expect(tooltip).toHaveAttribute('data-tooltip-content');
expect(tooltip).toHaveClass('app-tooltip-content');
expect(bar).toHaveAttribute('aria-describedby', tooltip.id);
expect(bar.contains(tooltip)).toBe(false);
expect(tooltip.closest('[data-bits-floating-content-wrapper]')?.parentElement).toBe(document.body);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
const escapes: KeyboardEvent[] = [];
const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') escapes.push(event); };
window.addEventListener('keydown', onKeyDown);
try {
await fireEvent.keyDown(bar, { key: 'Escape' });
await fireEvent.keyDown(bar, { key: 'Escape' });
expect(escapes).toHaveLength(2);
// Tooltip dismissal prevents the first key's default but both keys still reach the app listener.
expect(escapes.map((event) => event.defaultPrevented)).toEqual([true, false]);
} finally {
window.removeEventListener('keydown', onKeyDown);
}
});

it.each(['dependency', 'undo', 'fork', 'unknown', 'failure', 'input'] as const)('suppresses cancel hint for %s while retaining guidance and live-region roles', async (mode) => {
const value = session({ runState: mode === 'unknown' ? 'unknown' : mode === 'failure' ? 'failed' : mode === 'input' ? 'waiting-input' : mode === 'dependency' ? 'completed' : 'thinking',
dependencyLabel: mode === 'dependency' ? 'Verifying delegate result' : null });
if (mode === 'undo') value.undo.pendingOperation = 'undo';
render(SessionActivityBar, { session: value, forkPending: mode === 'fork' });
const bar = screen.getByRole(mode === 'failure' ? 'alert' : 'status');
expect(bar).not.toHaveAttribute('title');
expect(bar).not.toHaveAttribute('tabindex', '0');
await fireEvent.pointerEnter(bar, { pointerType: 'mouse' });
await new Promise((resolve) => setTimeout(resolve, 300));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
expect(screen.queryByText('Double Esc to cancel')).not.toBeInTheDocument();
if (mode === 'failure') expect(bar).toHaveTextContent('Review the error, update the prompt, or refresh.');
if (mode === 'input') expect(bar).toHaveTextContent('Respond below to continue.');
});

it('leaves stopping to the composer stop button instead of an inline cancel control', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/components/session/SessionDelegatesBubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ describe('SessionDelegatesBubble', () => {
expect(screen.queryByRole('region', { name: 'Waiting messages' })).toBeNull();
expect(screen.getByRole('region', { name: 'Delegates in this turn' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '1 waiting message' })).toHaveTextContent(/^1$/);
expect(screen.getByRole('status')).toHaveTextContent('Double Esc to cancel');
expect(screen.getByRole('status')).not.toHaveTextContent('Double Esc to cancel');
expect(screen.getByRole('status')).toHaveAttribute('data-tooltip-trigger');
});

it('keeps both counts in the same activity row without making the composer own them', () => {
Expand Down
12 changes: 8 additions & 4 deletions src/lib/components/session/SessionHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,21 @@
if (forkPending) return { label: 'Creating fork', tone: 'running', busy: true };
if (session.undo.pendingOperation === 'undo') return { label: 'Undoing', tone: 'running', busy: true };
if (session.undo.pendingOperation === 'redo') return { label: 'Restoring', tone: 'running', busy: true };
if (session.runState === 'unknown' || (summaryStatus === 'unknown' && (!session.sessionId || ['idle', 'completed'].includes(session.runState)))) return { label: 'Status unavailable', tone: 'warning', busy: false };
if (session.runState === 'failed') return { label: 'Failed', tone: 'danger', busy: false };
if (session.runState === 'waiting-input' || summaryStatus === 'waiting') {
if (session.dependencyLabel && !['submitting', 'thinking', 'streaming', 'tool-running'].includes(session.runState)) {
return { label: `Own turn idle - ${session.dependencyLabel}`, tone: 'running', busy: true };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
if (session.runState === 'waiting-input' || (!session.sessionId && summaryStatus === 'waiting')) {
return { label: 'Input needed', tone: 'warning', busy: false };
}
if (
['submitting', 'thinking', 'streaming', 'tool-running'].includes(session.runState) ||
['thinking', 'cancelling'].includes(summaryStatus)
(!session.sessionId && ['thinking', 'cancelling'].includes(summaryStatus))
) {
return { label: summaryStatus === 'cancelling' ? 'Cancelling' : 'Working', tone: 'running', busy: true };
return { label: session.activityLabel?.startsWith('Cancelling') ? 'Cancelling' : session.activityLabel?.startsWith('Finishing') ? 'Finishing' : session.activityLabel?.startsWith('Waiting for delegates') ? 'Waiting for delegates' : 'Working', tone: 'running', busy: true };
}
if (session.runState === 'completed' || summaryStatus === 'completed') {
if (session.runState === 'completed' || (!session.sessionId && summaryStatus === 'completed')) {
return { label: 'Turn complete', tone: 'muted', busy: false };
}
return { label: 'Ready', tone: 'muted', busy: false };
Expand Down
12 changes: 12 additions & 0 deletions src/lib/components/session/SessionHeader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ const usageFixture = {
afterEach(cleanup);

describe('SessionHeader', () => {
it.each(['thinking', 'streaming', 'tool-running'] as const)('preserves known %s execution over a stale unknown summary', (runState) => {
render(SessionHeader, { session: session({ runState }), summaryStatus: 'unknown', title: 'Active', workspace: 'work', updatedAt: 'Now' });
expect(screen.getByLabelText('Status: Working')).toBeInTheDocument();
expect(screen.queryByLabelText('Status: Status unavailable')).not.toBeInTheDocument();
});

it('keeps observed failed execution visible while a dependency is live', () => {
render(SessionHeader, { session: session({ runState: 'failed', dependencyLabel: 'Verifying delegate result' }),
title: 'Failed', workspace: 'work', updatedAt: 'Now' });
expect(screen.getByLabelText('Status: Failed')).toBeInTheDocument();
});

it.each([false, true])('offers pin/unpin only inside the ellipsis menu (pinned=%s)', async (pinned) => {
const onSetPinned = vi.fn(async () => {});
const onSetLifecycle = vi.fn(async () => {});
Expand Down
3 changes: 2 additions & 1 deletion src/lib/components/session/SessionQueuedBubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ describe('SessionQueuedBubble', () => {
const activity = screen.getByRole('status');
expect(container.firstElementChild).toBe(bubble);
expect(bubble.nextElementSibling).toBe(activity);
expect(activity).toHaveTextContent('Double Esc to cancel');
expect(activity).not.toHaveTextContent('Double Esc to cancel');
expect(activity).toHaveAttribute('data-tooltip-trigger');

await fireEvent.click(bubble);
expect(screen.getByRole('region', { name: 'Waiting messages' }).parentElement).toBe(container);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/shell/RecentSessionRail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@
}

function getStatusLabel(status: SessionStatus, item: SessionRailItem): string {
const activity = status === 'thinking' ? 'Active' : status === 'waiting' ? 'Waiting' : status === 'cancelling' ? 'Cancelling' : item.requiresAttention ? null : 'Recent';
const activity = status === 'unknown' ? 'Status unavailable' : status === 'thinking' ? 'Active' : status === 'waiting' ? 'Waiting' : status === 'cancelling' ? 'Cancelling' : item.requiresAttention ? null : 'Recent';
if (item.requiresAttention) return activity ? `${activity}, action required` : 'Action required';
return activity ?? 'Recent';
}
Expand Down
5 changes: 5 additions & 0 deletions src/lib/components/shell/RecentSessionRail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ describe('RecentSessionRail pinned sessions', () => {
});

describe('RecentSessionRail navigation', () => {
it('keeps unknown execution explicit in existing rail status labels', () => {
render(RecentSessionRail, { current: 'Sessions', sessions: [{ ...activeSession, status: 'unknown' }] });
expect(screen.getByRole('link', { name: /^Waiting session, Status unavailable/ })).toBeInTheDocument();
expect(screen.getByText('work · Status unavailable')).toBeInTheDocument();
});
it('defaults to a labeled sidebar with grouped navigation and readable sessions', () => {
render(RecentSessionRail, { current: 'Sessions', sessions: [activeSession] });

Expand Down
Loading
Loading