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.
252 lines
12 KiB
TypeScript
252 lines
12 KiB
TypeScript
import React from 'react';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { cn } from '@/lib/utils';
|
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility';
|
|
import { WorkStatusGoalRow } from './WorkStatusGoalRow';
|
|
import { WorkStatusPrimaryGroup } from './WorkStatusPrimaryGroup';
|
|
import { WorkStatusUsageSection } from './WorkStatusUsageSection';
|
|
import { WorkStatusSubagentsSection } from './WorkStatusSubagentsSection';
|
|
import { WorkStatusTasksSection } from './WorkStatusTasksSection';
|
|
import { WorkStatusMcpSection } from './WorkStatusMcpSection';
|
|
import { WorkStatusPinnedSection } from './WorkStatusPinnedSection';
|
|
import { WorkStatusContextSection } from './WorkStatusContextSection';
|
|
import { WorkStatusSectionsDialog } from './WorkStatusSectionsDialog';
|
|
import { isWorkStatusSectionVisible } from './sections';
|
|
import { WorkStatusPresenceProvider } from './presence';
|
|
import { Icon } from '@/components/icon/Icon';
|
|
|
|
type Props = {
|
|
/** Null on a new-session draft: repository readouts still apply. */
|
|
sessionId: string | null;
|
|
directory: string | null;
|
|
/** Whether the panel should currently occupy space. */
|
|
visible: boolean;
|
|
/**
|
|
* Floats over the transcript instead of sitting beside it, for when the chat
|
|
* is too narrow to give it a column of its own.
|
|
*/
|
|
overlay?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Matches the context panel's own width animation exactly.
|
|
*
|
|
* The two are siblings of the transcript, and opening the context panel hides
|
|
* this one. With an instant unmount the chat first jumped wider (this panel
|
|
* gone) and then eased narrower (the context panel expanding) — two opposite
|
|
* width changes in a row, which reads as a flutter. Collapsing on the same
|
|
* curve and duration makes the chat's width move once, in one direction.
|
|
*/
|
|
const PANEL_TRANSITION_MS = 200;
|
|
const PANEL_TRANSITION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
|
|
|
/**
|
|
* Work-status panel: a card inside the chat column reporting the state of the
|
|
* session, its branch and its subagents.
|
|
*
|
|
* Ordering is by durability, not by category. The first sections hold readouts
|
|
* that stay true for the whole session, then the state of the work in flight,
|
|
* then episodic material an agent may never produce. Each section renders
|
|
* nothing when it has nothing, so the panel collapses toward the top instead of
|
|
* reserving empty space.
|
|
*
|
|
* The card clips; the scroller lives inside it, so the same top/bottom scroll
|
|
* shadows the transcript uses stay within the rounded border instead of
|
|
* bleeding past it. The scrollbar itself is hidden — at this width it would
|
|
* eat a visible slice of every row's trailing value, and the shadows already
|
|
* say there is more to see.
|
|
*/
|
|
export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible, overlay = false }) => {
|
|
const { t } = useI18n();
|
|
const setScrollTop = useUIStore((state) => state.setWorkStatusScrollTop);
|
|
const setOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen);
|
|
const hiddenSections = useUIStore((state) => state.workStatusHiddenSections);
|
|
const [sectionsDialogOpen, setSectionsDialogOpen] = React.useState(false);
|
|
// Starts optimistic: sections report after their first commit, and rendering
|
|
// nothing on the way in would make the card flash out and back on arrival.
|
|
const [renderedSections, setRenderedSections] = React.useState(1);
|
|
const sectionVisible = React.useCallback(
|
|
(sectionId: Parameters<typeof isWorkStatusSectionVisible>[1]) =>
|
|
isWorkStatusSectionVisible(hiddenSections, sectionId),
|
|
[hiddenSections],
|
|
);
|
|
const frameRef = React.useRef<number | null>(null);
|
|
|
|
// Restoring the offset has to happen the moment the scroller attaches, and
|
|
// the panel unmounts whenever the context panel opens. Reading the stored
|
|
// value through a ref keeps this a mount-time restore rather than a
|
|
// subscription that would fight the user mid-scroll.
|
|
// Content is dropped only after the collapse finishes, so the card animates
|
|
// out with something in it rather than emptying first, and its subscriptions
|
|
// stop once it is truly gone.
|
|
const [contentMounted, setContentMounted] = React.useState(visible);
|
|
// Hidden, mid-collapse, or reporting nothing: in each case the card is not
|
|
// something the user can act on, so it should not be reachable.
|
|
const interactive = visible && renderedSections > 0;
|
|
React.useEffect(() => {
|
|
if (visible) {
|
|
setContentMounted(true);
|
|
return undefined;
|
|
}
|
|
const timer = window.setTimeout(() => setContentMounted(false), PANEL_TRANSITION_MS);
|
|
return () => window.clearTimeout(timer);
|
|
}, [visible]);
|
|
|
|
const restore = React.useCallback((node: HTMLElement | null) => {
|
|
if (!node) return;
|
|
const stored = useUIStore.getState().workStatusScrollTop;
|
|
if (stored > 0) node.scrollTop = stored;
|
|
}, []);
|
|
|
|
// Coalesced to one write per frame: scroll fires far faster than the store
|
|
// needs to hear about it.
|
|
const handleScroll = React.useCallback((event: React.UIEvent<HTMLElement>) => {
|
|
const { scrollTop } = event.currentTarget;
|
|
if (frameRef.current !== null) return;
|
|
frameRef.current = requestAnimationFrame(() => {
|
|
frameRef.current = null;
|
|
setScrollTop(scrollTop);
|
|
});
|
|
}, [setScrollTop]);
|
|
|
|
React.useEffect(() => () => {
|
|
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
|
}, []);
|
|
|
|
// The offset belongs to the panel a session produced, not to the panel in
|
|
// general: restoring one session's scroll into another's shorter panel lands
|
|
// somewhere arbitrary.
|
|
React.useEffect(() => {
|
|
setScrollTop(0);
|
|
}, [sessionId, setScrollTop]);
|
|
|
|
// Dismissed like any transient surface: a click elsewhere or Escape. It
|
|
// covers the transcript, so leaving it up would block the thing it reports on.
|
|
const overlayRef = React.useRef<HTMLElement | null>(null);
|
|
React.useEffect(() => {
|
|
// Only while it is actually up: a hidden overlay listening for clicks would
|
|
// swallow the very press that opens it.
|
|
if (!overlay || !visible) return undefined;
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (overlayRef.current?.contains(target)) return;
|
|
// The header toggle closes it on its own; letting this fire too would
|
|
// close and immediately reopen.
|
|
if (target?.closest('[data-work-status-toggle]')) return;
|
|
setOverlayOpen(false);
|
|
};
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setOverlayOpen(false);
|
|
};
|
|
document.addEventListener('pointerdown', onPointerDown, true);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [overlay, setOverlayOpen, visible]);
|
|
|
|
return (
|
|
<aside
|
|
ref={overlayRef}
|
|
aria-label={t('chat.workStatus.ariaLabel')}
|
|
aria-hidden={!interactive}
|
|
// The card stays mounted while hidden so it can animate its own collapse,
|
|
// and the sections button sits outside the content gate. Without `inert`
|
|
// Tab could land on an invisible control — and `aria-hidden` around a
|
|
// focusable descendant is an accessibility fault in its own right.
|
|
inert={!interactive}
|
|
className={cn(
|
|
// `self-start` keeps the card at content height instead of stretching
|
|
// to the row; `max-h` then caps it so a long panel scrolls rather than
|
|
// overflowing the chat.
|
|
// A left margin as well as a right one: flush against the transcript
|
|
// the card's own shadow had no room and was clipped down that edge.
|
|
'relative my-4 flex shrink-0 flex-col self-start overflow-hidden',
|
|
'max-h-[calc(100%-2rem)]',
|
|
interactive ? 'ml-2 mr-4' : 'ml-0 mr-0',
|
|
// Out of the flow entirely, anchored to the chat column's top-right so
|
|
// it reads as a dropdown from the header button. As a flex child it
|
|
// took part in the layout and pushed the transcript, which is the one
|
|
// thing an overlay must not do. Stronger shadow: it sits on content now.
|
|
overlay && [
|
|
'absolute right-3 top-3 z-30 mx-0 my-0',
|
|
'max-h-[calc(100%-1.5rem)]',
|
|
'shadow-[0_8px_28px_-8px_rgb(0_0_0_/_0.28)]',
|
|
// Beside the transcript the translucent fill reads as depth; on top
|
|
// of it, message bubbles showed straight through the rows. Frosting
|
|
// separates the two without going fully opaque.
|
|
'bg-[var(--surface-muted)]/80 backdrop-blur-md',
|
|
],
|
|
// An empty card is a border around a settings icon, which reads as a
|
|
// fault rather than as "nothing to report".
|
|
renderedSections === 0 && 'border-transparent bg-transparent shadow-none',
|
|
'motion-reduce:transition-none',
|
|
'rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-muted)]/40',
|
|
// A lighter version of the composer's lift: the same shape, but this
|
|
// card is taller, so the composer's spread reads as heavy here.
|
|
'shadow-[0_2px_8px_-3px_rgb(0_0_0_/_0.08)]',
|
|
)}
|
|
style={{
|
|
// The overlay keeps its width: it takes no space from the chat, so
|
|
// collapsing it would animate a dimension nothing depends on. It fades
|
|
// and lifts instead, like the dropdown it reads as.
|
|
width: overlay || interactive ? WORK_STATUS_PANEL_WIDTH : 0,
|
|
opacity: interactive ? 1 : 0,
|
|
transform: visible
|
|
? 'translateY(0) scale(1)'
|
|
: overlay
|
|
? 'translateY(-6px) scale(0.98)'
|
|
// Inline: leaves to the right and arrives from it, so the card
|
|
// reads as sliding out past the window edge.
|
|
: `translateX(${WORK_STATUS_PANEL_WIDTH / 4}px)`,
|
|
transformOrigin: 'top right',
|
|
transitionProperty: 'width, opacity, transform, margin',
|
|
transitionDuration: `${PANEL_TRANSITION_MS}ms`,
|
|
transitionTimingFunction: PANEL_TRANSITION_EASING,
|
|
pointerEvents: interactive ? undefined : 'none',
|
|
}}
|
|
>
|
|
{/* Overlaid rather than placed in flow: the panel has no header of its
|
|
own, and giving it one would cost a row of height on every session. */}
|
|
<button
|
|
type="button"
|
|
aria-label={t('chat.workStatus.sections.open')}
|
|
onClick={() => setSectionsDialogOpen(true)}
|
|
className="absolute right-2 top-1.5 z-10 rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<Icon name="equalizer-2" className="size-4" />
|
|
</button>
|
|
|
|
{contentMounted ? (
|
|
<WorkStatusPresenceProvider onChange={setRenderedSections}>
|
|
<ScrollShadow
|
|
ref={restore}
|
|
onScroll={handleScroll}
|
|
size={24}
|
|
className="oc-hide-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-2"
|
|
>
|
|
<WorkStatusPrimaryGroup
|
|
sessionId={sessionId}
|
|
directory={directory}
|
|
showSession={sectionVisible('session')}
|
|
showRepository={sectionVisible('repository')}
|
|
goalRow={<WorkStatusGoalRow sessionId={sessionId} directory={directory} />}
|
|
/>
|
|
{sectionVisible('usage') ? <WorkStatusUsageSection /> : null}
|
|
{sectionVisible('subagents') ? <WorkStatusSubagentsSection sessionId={sessionId} directory={directory} /> : null}
|
|
{sectionVisible('tasks') ? <WorkStatusTasksSection sessionId={sessionId} directory={directory} /> : null}
|
|
{sectionVisible('mcp') ? <WorkStatusMcpSection directory={directory} /> : null}
|
|
{sectionVisible('pinned') ? <WorkStatusPinnedSection sessionId={sessionId} directory={directory} /> : null}
|
|
{sectionVisible('contextSources') ? <WorkStatusContextSection sessionId={sessionId} directory={directory} /> : null}
|
|
</ScrollShadow>
|
|
</WorkStatusPresenceProvider>
|
|
) : null}
|
|
|
|
<WorkStatusSectionsDialog open={sectionsDialogOpen} onOpenChange={setSectionsDialogOpen} />
|
|
</aside>
|
|
);
|
|
};
|