Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions apps/staged/src/lib/features/sessions/PlanCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<!--
PlanCard.svelte — the latest ACP plan, pinned above the chat transcript.

Renders a collapsible card summarizing the agent's current plan. The header
shows a caret, an overall status icon mirroring tool-call semantics, the
"Plan" title, and a muted completed/total progress summary. The body lists
each entry with a per-status icon.

Props:
entries — the plan entries (never empty; callers gate on latestPlan)
defaultExpanded — initial expansion; user toggles are respected afterwards
-->
<script lang="ts">
import { slide } from 'svelte/transition';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
import CircleCheck from '@lucide/svelte/icons/circle-check';
import CircleDot from '@lucide/svelte/icons/circle-dot';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Spinner from '../../shared/Spinner.svelte';
import type { PlanEntry } from './acpTranscript';

interface Props {
entries: PlanEntry[];
defaultExpanded: boolean;
}

let { entries, defaultExpanded }: Props = $props();

// Initialized once (capturing the initial value is intentional); new plan
// updates streaming in never override the user's explicit toggle.
// svelte-ignore state_referenced_locally
let expanded = $state(defaultExpanded);

let completedCount = $derived(entries.filter((entry) => entry.status === 'completed').length);
let overallStatus = $derived.by((): PlanEntry['status'] => {
if (entries.some((entry) => entry.status === 'failed')) return 'failed';
if (entries.some((entry) => entry.status === 'in_progress')) return 'in_progress';
if (entries.length > 0 && entries.every((entry) => entry.status === 'completed'))
return 'completed';
return 'pending';
});
</script>

<div class="plan-card">
<button
type="button"
class="plan-header"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>
<span class="plan-caret" class:plan-caret-expanded={expanded}>
<ChevronRight size={12} />
</span>
<span
class="plan-status-icon"
class:status-running={overallStatus === 'in_progress'}
class:status-danger={overallStatus === 'failed'}
class:status-success={overallStatus === 'completed'}
>
{#if overallStatus === 'in_progress'}
<Spinner size={11} />
{:else if overallStatus === 'failed'}
<CircleAlert size={11} />
{:else if overallStatus === 'completed'}
<CircleCheck size={11} />
{:else}
<CircleDot size={11} />
{/if}
</span>
<span class="plan-title">Plan</span>
<span class="plan-progress">{completedCount}/{entries.length}</span>
</button>
{#if expanded}
<div class="plan-entries" transition:slide={{ duration: 150 }}>
{#each entries as entry}
<div class="plan-entry">
<span
class="plan-entry-icon"
class:status-running={entry.status === 'in_progress'}
class:status-success={entry.status === 'completed'}
class:status-danger={entry.status === 'failed'}
>
{#if entry.status === 'in_progress'}
<Spinner size={11} />
{:else if entry.status === 'completed'}
<CircleCheck size={11} />
{:else if entry.status === 'failed'}
<CircleAlert size={11} />
{:else}
<CircleDot size={11} />
{/if}
</span>
<span class="plan-entry-text">{entry.content}</span>
</div>
{/each}
</div>
{/if}
</div>

<style>
.plan-card {
flex-shrink: 0;
Comment on lines +101 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the expanded plan's height

When an ACP plan contains many entries or long wrapped descriptions, this non-shrinking card has no maximum height or internal scrolling, while .session-chat-pane is an overflow-hidden flex column. The expanded plan can therefore consume the pane's entire height and leave the transcript and composer clipped in a zero-height scroll area; cap the card/body height and make the entries independently scrollable.

Useful? React with 👍 / 👎.

border-bottom: 1px solid var(--border-subtle);
background: var(--bg-secondary);
padding: 6px 16px;
font-size: var(--size-xs);
}

.plan-header {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
border: 0;
background: transparent;
color: var(--text-muted);
padding: 2px 0;
text-align: left;
font: inherit;
cursor: pointer;
}

.plan-header:hover .plan-title {
text-decoration: underline;
}

.plan-caret {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 8px;
height: 12px;
color: var(--text-faint);
}

.plan-caret :global(svg) {
transition: transform 0.15s ease;
}

.plan-caret-expanded :global(svg) {
transform: rotate(90deg);
}

.plan-title {
flex-shrink: 0;
font-weight: 500;
line-height: 1;
transform: translateY(-0.5px);
}

.plan-progress {
color: var(--text-faint);
}

.plan-status-icon,
.plan-entry-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
flex-shrink: 0;
color: var(--text-faint);
}

.status-running {
color: var(--ui-warning);
}

.status-success {
color: var(--ui-success, var(--ui-accent));
}

.status-danger {
color: var(--ui-danger);
}

.plan-entries {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
padding: 2px 0 4px;
max-height: min(240px, 40vh);
overflow-y: auto;
overscroll-behavior: contain;
}

.plan-entry {
display: flex;
gap: 6px;
align-items: flex-start;
color: var(--text-muted);
line-height: 1.35;
}

.plan-entry-icon {
/* Reserve the slot even for pending entries so text stays aligned. */
margin-top: 2px;
}

.plan-entry-text {
min-width: 0;
overflow-wrap: anywhere;
}
</style>
50 changes: 15 additions & 35 deletions apps/staged/src/lib/features/sessions/SessionChatPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,15 @@
groupRichToolsByVerb,
isToolMetadataSettled,
latestAvailableCommands,
latestPlan,
stabilizeAcpTranscriptGroups,
transcriptGroupKey,
type AcpCommand,
type AcpTranscriptEvent,
type AcpTranscriptGroup,
} from './acpTranscript';
import PlanCard from './PlanCard.svelte';
import { viewport } from '../../shared/viewport.svelte';
import {
displayRootKey,
normalizeDisplayRoots,
Expand Down Expand Up @@ -256,6 +259,8 @@
* last group?" without re-running whenever the grouped array identity
* changes — deriveds cut propagation when the value is equal. */
let lastGroupIndex = $derived(grouped.length - 1);
/** Latest ACP plan, pinned above the transcript rather than rendered in it. */
let plan = $derived.by(() => latestPlan(acpMetadataMessages));
/** Pikchr sources of successful render_pikchr tool calls, shown inline as diagrams. */
let pikchrToolSources = $derived(
grouped.flatMap((group) =>
Expand Down Expand Up @@ -1624,10 +1629,6 @@
}

function acpEventSummary(event: AcpTranscriptEvent): string {
if (event.kind === 'plan_update') {
const entries = arrayProp(event.content, 'entries');
return entries.length === 1 ? '1 step' : `${entries.length} steps`;
}
if (event.kind === 'available_commands_update') {
const commands = arrayProp(event.content, 'availableCommands');
return commands.length === 1 ? '1 command' : `${commands.length} commands`;
Expand All @@ -1639,10 +1640,6 @@
return compactJsonSummary(event.content);
}

function planEntries(content: unknown): Record<string, unknown>[] {
return arrayProp(content, 'entries');
}

function configOptions(content: unknown): Record<string, unknown>[] {
return Array.isArray(content)
? content.filter(
Expand Down Expand Up @@ -1740,6 +1737,15 @@
bind:this={modalElement}
class={`session-chat-pane ${compact ? 'compact' : ''} ${dragOver ? 'drag-over' : ''}`}
>
<!-- Latest plan, pinned above the scrollable transcript. Keyed on the
session so the expanded/collapsed toggle resets when this pane is
reused for a different session. -->
{#if plan}
{#key session?.id}
<PlanCard entries={plan} defaultExpanded={!viewport.isMobile && !compact} />
{/key}
{/if}

<!-- Messages area -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
Expand Down Expand Up @@ -1961,16 +1967,7 @@
</div>
{#if isExpanded}
<div class="acp-event-body" transition:slide={{ duration: SLIDE_DURATION }}>
{#if event.kind === 'plan_update'}
<div class="plan-list">
{#each planEntries(event.content) as entry}
<div class="plan-entry">
<span class="plan-status">{stringProp(entry, 'status')}</span>
<span>{stringProp(entry, 'content')}</span>
</div>
{/each}
</div>
{:else if event.kind === 'config_options_update'}
{#if event.kind === 'config_options_update'}
<div class="config-list">
{#each configOptions(event.content) as option}
<label class="config-option">
Expand Down Expand Up @@ -2567,30 +2564,13 @@
line-height: 1.5;
}

.plan-list,
.config-list,
.command-list {
display: flex;
flex-direction: column;
gap: 6px;
}

.plan-entry {
display: flex;
gap: 8px;
align-items: flex-start;
color: var(--text-muted);
line-height: 1.35;
}

.plan-status {
flex-shrink: 0;
min-width: 74px;
color: var(--text-faint);
font-size: calc(var(--size-xs) * 0.9);
text-transform: capitalize;
}

.config-option {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 220px);
Expand Down
Loading