feat(chat): work-status panel, and MCP auth and settings fixes (#2776)

Adds a work-status panel beside the transcript. Context fill, model and
cost, todos, running subagents and the permission requests blocking
them, branch and working-tree state, MCP servers, pinned messages and
context sources were scattered across the header, the composer and the
context panel — a blocked subagent was reported nowhere at all. The
panel reads them from live channels rather than persisted history, and
becomes an overlay where the chat is too narrow to seat a column.

It is on by default, including for existing installs. Because it now
carries these readouts, the desktop header and composer drop the ones it
duplicates: todo and changed-files chips, usage and MCP tabs. VS Code
and mobile keep theirs — neither hosts the panel.

Fixes MCP authorization, which was broken from the panel, invalidated by
a directory switch through a redirect URI that encoded the working
directory, and left the desktop app in the background because browsers
will not follow a custom-protocol link without a user gesture. The
settings page no longer asks the user to understand the MCP spec before
adding a server: one field takes the command or the link, with the kind
inferred and a visible override, and client-registration fields appear
only when a server actually asks for its own credentials.

Also: skills load from the panel instead of only when the composer's
slash autocomplete opens; the header button names the current instance
rather than falling through to the word "Instance" for relay hosts.

Three new optional UI settings keys, all migrated. No change to stored
MCP server configuration.
This commit is contained in:
Bohdan Triapitsyn
2026-08-09 19:30:25 +03:00
parent 493a618efc
commit f4743ea060
69 changed files with 5777 additions and 894 deletions
@@ -0,0 +1,266 @@
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;
defaultExpanded?: boolean;
children: React.ReactNode;
}> = ({ id, title, icon, iconNode, iconColor, summary, 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}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpandedInStore(id, !expanded)}
className={cn(
'group/section mb-0.5 flex h-6 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>
{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>
);