272 lines
9.2 KiB
TypeScript
272 lines
9.2 KiB
TypeScript
import React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Icon } from '@/components/icon/Icon';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import type { IconName } from '@/components/icon/icons';
|
|
|
|
/**
|
|
* Row/section vocabulary for the work-status panel.
|
|
*
|
|
* Every readout is a labelled row — icon, name, trailing value — so a glance
|
|
* answers "what is this number" without hovering. Sections carry a heading and
|
|
* are separated by a hairline; the panel itself stays chrome-less, since it is
|
|
* an object inside the chat rather than a docked pane.
|
|
*/
|
|
|
|
/**
|
|
* Sections are direct siblings inside the panel (fragments add no DOM nodes),
|
|
* so the separator is a first-child CSS rule. Passing "am I first?" down as a
|
|
* prop would mean every group tracking what the groups above it decided to
|
|
* render.
|
|
*/
|
|
const SECTION_CLASS = cn(
|
|
'flex flex-col',
|
|
'[&:not(:first-child)]:mt-3 [&:not(:first-child)]:border-t',
|
|
'[&:not(:first-child)]:border-[var(--interactive-border)] [&:not(:first-child)]:pt-3',
|
|
);
|
|
|
|
const HEADING_CLASS = 'text-xs font-normal text-muted-foreground';
|
|
|
|
export const WorkStatusSection: React.FC<{
|
|
title: string;
|
|
/** Aggregate for the whole section; belongs on the heading, not on a row. */
|
|
summary?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}> = ({ title, summary, children }) => (
|
|
<section className={SECTION_CLASS}>
|
|
<div className="mb-0.5 flex items-center gap-2 px-1">
|
|
<h3 className={cn(HEADING_CLASS, 'min-w-0 flex-1 truncate')}>{title}</h3>
|
|
{summary !== undefined && summary !== null ? (
|
|
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
|
|
) : null}
|
|
</div>
|
|
{children}
|
|
</section>
|
|
);
|
|
|
|
/**
|
|
* Section whose body folds away. The chevron swaps on expand exactly as the
|
|
* transcript's tool blocks do, so the two collapsibles read as the same
|
|
* control rather than two conventions in one window.
|
|
*
|
|
* Expanded state lives in the persisted UI store, not in component state: the
|
|
* panel unmounts whenever the context panel opens, and local state would
|
|
* silently discard the user's arrangement every time.
|
|
*/
|
|
export const WorkStatusCollapsibleSection: React.FC<{
|
|
/** Stable key for persisting expanded state. */
|
|
id: string;
|
|
title: string;
|
|
icon?: IconName;
|
|
/** For glyphs that live outside the sprite, such as the MCP mark. */
|
|
iconNode?: React.ReactNode;
|
|
iconColor?: string;
|
|
/** Shown on the header while collapsed and expanded alike. */
|
|
summary?: React.ReactNode;
|
|
/** An independent header action, such as refreshing this section's data. */
|
|
action?: React.ReactNode;
|
|
defaultExpanded?: boolean;
|
|
children: React.ReactNode;
|
|
}> = ({ id, title, icon, iconNode, iconColor, summary, action, defaultExpanded = false, children }) => {
|
|
const stored = useUIStore(
|
|
React.useCallback((state) => state.workStatusExpandedSections[id], [id]),
|
|
);
|
|
const setExpandedInStore = useUIStore((state) => state.setWorkStatusSectionExpanded);
|
|
const expanded = stored ?? defaultExpanded;
|
|
return (
|
|
<section className={SECTION_CLASS}>
|
|
<div className="mb-0.5 flex h-6 items-center gap-1">
|
|
<button
|
|
type="button"
|
|
aria-expanded={expanded}
|
|
onClick={() => setExpandedInStore(id, !expanded)}
|
|
className={cn(
|
|
'group/section flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 text-left',
|
|
// No hover fill anywhere in the panel: at this row density the blocks
|
|
// of colour read as selection, not as affordance. Interactivity shows
|
|
// through the text instead.
|
|
'transition-colors hover:text-foreground',
|
|
)}
|
|
>
|
|
{iconNode ?? (icon ? (
|
|
<Icon
|
|
name={icon}
|
|
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
|
|
style={iconColor ? { color: iconColor } : undefined}
|
|
/>
|
|
) : null)}
|
|
<span className={cn(HEADING_CLASS, 'min-w-0 truncate')}>{title}</span>
|
|
<Icon
|
|
name={expanded ? 'arrow-down-s' : 'arrow-right-s'}
|
|
className="size-3.5 shrink-0 text-muted-foreground"
|
|
/>
|
|
<span className="flex-1" />
|
|
{summary !== undefined && summary !== null ? (
|
|
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
|
|
) : null}
|
|
</button>
|
|
{action}
|
|
</div>
|
|
{expanded ? children : null}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
type RowProps = {
|
|
icon?: IconName;
|
|
iconColor?: string;
|
|
leading?: React.ReactNode;
|
|
label: React.ReactNode;
|
|
value?: React.ReactNode;
|
|
muted?: boolean;
|
|
/** Turns the row into a button; the caller decides what it opens. */
|
|
onClick?: () => void;
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
};
|
|
|
|
/**
|
|
* A single readout. `value` sits hard right; `label` truncates before it, so a
|
|
* long branch name never pushes its own ahead/behind counts out of view.
|
|
*/
|
|
export const WorkStatusRow: React.FC<RowProps> = ({
|
|
icon,
|
|
iconColor,
|
|
leading,
|
|
label,
|
|
value,
|
|
muted,
|
|
onClick,
|
|
ariaLabel,
|
|
className,
|
|
}) => {
|
|
const body = (
|
|
<>
|
|
{leading ?? (icon ? (
|
|
<Icon
|
|
name={icon}
|
|
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
|
|
style={iconColor ? { color: iconColor } : undefined}
|
|
/>
|
|
) : null)}
|
|
<span className={cn('min-w-0 flex-1 truncate text-[13px]', muted && 'text-muted-foreground')}>
|
|
{label}
|
|
</span>
|
|
{value !== undefined && value !== null ? (
|
|
<span className="flex shrink-0 items-center gap-1.5 text-[13px] tabular-nums">{value}</span>
|
|
) : null}
|
|
</>
|
|
);
|
|
|
|
const shared = cn('flex h-7 w-full items-center gap-2 rounded-md px-1 text-left', className);
|
|
|
|
if (!onClick) return <div className={shared}>{body}</div>;
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={ariaLabel}
|
|
className={cn(shared, 'transition-colors hover:text-foreground')}
|
|
>
|
|
{body}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
type WorkStatusTone = 'default' | 'muted' | 'success' | 'error' | 'warning' | 'info';
|
|
|
|
const TONE_COLOR: Record<Exclude<WorkStatusTone, 'default' | 'muted'>, string> = {
|
|
success: 'var(--status-success)',
|
|
error: 'var(--status-error)',
|
|
warning: 'var(--status-warning)',
|
|
info: 'var(--status-info)',
|
|
};
|
|
|
|
export const WorkStatusValue: React.FC<{
|
|
children: React.ReactNode;
|
|
tone?: WorkStatusTone;
|
|
}> = ({ children, tone = 'default' }) => (
|
|
<span
|
|
className={tone === 'muted' ? 'text-muted-foreground' : undefined}
|
|
style={tone === 'default' || tone === 'muted' ? undefined : { color: TONE_COLOR[tone] }}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
|
|
/**
|
|
* Trailing control shaped like the PR badge: a status that is also the thing
|
|
* you press. Used where the state itself is the affordance — an MCP server
|
|
* asking for sign-in, a goal waiting to be resumed.
|
|
*/
|
|
export const WorkStatusRowAction: React.FC<{
|
|
children: React.ReactNode;
|
|
onClick: () => void;
|
|
tone?: 'default' | 'warning' | 'error' | 'info';
|
|
disabled?: boolean;
|
|
ariaLabel?: string;
|
|
}> = ({ children, onClick, tone = 'default', disabled, ariaLabel }) => {
|
|
const color = tone === 'default' ? undefined : TONE_COLOR[tone];
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={ariaLabel}
|
|
disabled={disabled}
|
|
onClick={(event) => {
|
|
// The row underneath is often a button of its own with a different
|
|
// destination.
|
|
event.stopPropagation();
|
|
onClick();
|
|
}}
|
|
className={cn(
|
|
'shrink-0 rounded-full px-1.5 py-px text-[11px] font-medium leading-4 transition-opacity',
|
|
'hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
|
!color && 'bg-[var(--interactive-hover)] text-muted-foreground',
|
|
)}
|
|
style={color
|
|
? { color, backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)` }
|
|
: undefined}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
export const WorkStatusPill: React.FC<{
|
|
children: React.ReactNode;
|
|
color?: string;
|
|
background?: string;
|
|
}> = ({ children, color, background }) => (
|
|
<span
|
|
className={cn(
|
|
'rounded-full px-1.5 py-px text-[11px] font-medium leading-4',
|
|
!color && 'bg-[var(--interactive-hover)] text-muted-foreground',
|
|
)}
|
|
style={color ? { color, backgroundColor: background } : undefined}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
|
|
/** Full-width callout for states that block the branch (merge, rebase, …). */
|
|
export const WorkStatusCallout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
<div
|
|
className="mx-1 mb-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] font-medium"
|
|
style={{ backgroundColor: 'var(--status-warning-background)', color: 'var(--status-warning)' }}
|
|
>
|
|
<Icon name="alert" className="size-4 shrink-0" />
|
|
<span className="min-w-0 truncate">{children}</span>
|
|
</div>
|
|
);
|
|
|
|
/** Context-window fill, drawn under its row rather than inside it. */
|
|
export const WorkStatusMeter: React.FC<{ percent: number; color: string }> = ({ percent, color }) => (
|
|
<div className="mx-1 mb-1 h-1 overflow-hidden rounded-full bg-[var(--chat-divider)]">
|
|
<div
|
|
className="h-full rounded-full"
|
|
style={{ width: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: color }}
|
|
/>
|
|
</div>
|
|
);
|