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
170 changes: 136 additions & 34 deletions apps/web/src/components/PhaseGroupedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ import {
buildPhaseSidebarSections,
isPhaseSidebarSectionCollapsed,
listPhaseSidebarCustomGroups,
PHASE_SIDEBAR_RUNNING_SHELF_SECTION,
PHASE_SIDEBAR_SHELF_SECTIONS,
PHASE_SIDEBAR_UNGROUPED_ID,
phaseSidebarCustomGroupIdForRow,
Expand All @@ -256,6 +257,7 @@ import {
} from "@t3tools/client-runtime/state/phase-sidebar-grouping";
import { usePhaseSidebarGroupingStore } from "../phaseSidebarGroupingStore";
import { PhaseSidebarGroupByPopover } from "./sidebar/PhaseSidebarGroupByPopover";
import { PhaseSidebarRunningShelfSwitch } from "./sidebar/PhaseSidebarRunningShelfSwitch";
import { PhaseSidebarGroupNameDialog } from "./sidebar/PhaseSidebarGroupNameDialog";
import {
PHASE_SIDEBAR_SECTION_ACTION_BUTTON_CLASS_NAME,
Expand Down Expand Up @@ -2888,7 +2890,7 @@ export function PhaseGroupedSidebar() {
null,
[environments],
);
const { sections, forcedExpansionKeys } = useMemo(
const { sections, forcedExpansionKeys, runningNodes } = useMemo(
() =>
buildPhaseSidebarSections({
rows: activeRows,
Expand Down Expand Up @@ -3040,8 +3042,10 @@ export function PhaseGroupedSidebar() {
for (const child of node.children) visit(child);
};
for (const group of groups) for (const node of group.nodes) visit(node);
// T3-CUSTOM(expbkt3): the Running shelf's trees are no longer in a group.
for (const node of runningNodes) visit(node);
return keys;
}, [groups]);
}, [groups, runningNodes]);
// T3-CUSTOM(expbkt3): END
// T3-CUSTOM(expbkt3): Separate idle lifecycle groups from live agent work.
// Only meaningful when the sections ARE lifecycle phases.
Expand Down Expand Up @@ -3114,12 +3118,42 @@ export function PhaseGroupedSidebar() {
}),
[routeThreadKey, settledRows, settledShelfCollapsed, settledVisibleCount],
);
// The Running shelf (custom mode, switched on) holds live session trees, so
// an open shelf flattens them like any section for traversal.
const runningShelfCollapsed = isPhaseSidebarSectionCollapsed(
PHASE_SIDEBAR_RUNNING_SHELF_SECTION,
collapsedSectionKeys,
);
const toggleRunningShelf = useCallback(
() => toggleSectionCollapsed(PHASE_SIDEBAR_RUNNING_SHELF_SECTION.key),
[toggleSectionCollapsed],
);
const runningShelfRows = useMemo(
() =>
runningShelfCollapsed
? []
: flattenPhaseSidebarTree(runningNodes, isTreeKeyExpanded).map((node) => node.row),
[isTreeKeyExpanded, runningNodes, runningShelfCollapsed],
);
const runningShelfHoldsRoutedThread = useMemo(
() =>
routeThreadKey !== null &&
flattenPhaseSidebarTree(runningNodes, () => true).some(
(node) => phaseSidebarRowKey(node.row) === routeThreadKey,
),
[routeThreadKey, runningNodes],
);
// T3-CUSTOM(expbkt3): END
// Traversal, jump labels and shift-range selection operate on what is
// actually on screen, in screen order: the list, then the docked shelves.
const visibleRows = useMemo(
() => [...activeVisibleRows, ...snoozedShelf.rendered, ...settledShelf.rendered],
[activeVisibleRows, snoozedShelf.rendered, settledShelf.rendered],
() => [
...activeVisibleRows,
...runningShelfRows,
...snoozedShelf.rendered,
...settledShelf.rendered,
],
[activeVisibleRows, runningShelfRows, snoozedShelf.rendered, settledShelf.rendered],
);
// T3-CUSTOM(expbkt3): BEGIN — environment markers are conditional on what is
// actually rendered, not on how many environments are configured. With one
Expand Down Expand Up @@ -3257,6 +3291,7 @@ export function PhaseGroupedSidebar() {
!visibleRowByKey.has(routeThreadKey) &&
// T3-CUSTOM(expbkt3): a thread folded into a collapsed section or shelf is
// not hidden by the filters; it is one header click away.
!runningShelfHoldsRoutedThread &&
!snoozedShelf.containsRoutedThread &&
!settledShelf.containsRoutedThread &&
!sections.some(
Expand Down Expand Up @@ -4024,6 +4059,8 @@ export function PhaseGroupedSidebar() {
// clean while silently leaving every treeXxx prop undefined, which reads as
// "the feature is off" rather than as a build error.
tree?: PhaseThreadRowTreeProps,
// T3-CUSTOM(expbkt3): Running shelf rows left their group, so they name it.
showGroup = false,
) => {
const key = scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id));
const project =
Expand Down Expand Up @@ -4078,7 +4115,9 @@ export function PhaseGroupedSidebar() {
// T3-CUSTOM(expbkt3): custom groups.
{...(groupActions ? { groupActions } : {})}
customGroupId={phaseSidebarCustomGroupIdForRow(row)}
customGroupLabel={grouping.groupBy === "custom" ? null : (row.customGroup ?? null)}
customGroupLabel={
grouping.groupBy === "custom" && !showGroup ? null : (row.customGroup ?? null)
}
customGroupColor={customGroupColors.get(phaseSidebarCustomGroupIdForRow(row) ?? "") ?? null}
linearIssueStatuses={linearStatusesForRow(row)}
{...(tree ?? {})}
Expand All @@ -4088,22 +4127,27 @@ export function PhaseGroupedSidebar() {

// T3-CUSTOM(expbkt3): BEGIN — a session tree renders as nested <ul>s so the
// list stays a list for assistive tech, and each level animates on its own.
const renderTreeNode = (node: PhaseSidebarTreeNode): ReactNode => {
const renderTreeNode = (node: PhaseSidebarTreeNode, showGroup = false): ReactNode => {
const expanded = expandedKeys.has(node.key);
return (
<Fragment key={node.key}>
{renderThreadRow(node.row, "active", {
treeActions,
treeDepth: node.depth,
treeDescendantCount: node.descendantCount,
treeHasBusyDescendant: node.hasBusyDescendant,
treeDescendantUnreadCount: node.descendantUnreadCount,
treeDescendantRunningCount: node.descendantRunningCount,
treeDescendantAttention: node.descendantAttention,
treeExpanded: expanded,
treeParentKey: node.orphanedFrom?.key ?? null,
treeParentTitle: node.orphanedFrom?.title ?? null,
})}
{renderThreadRow(
node.row,
"active",
{
treeActions,
treeDepth: node.depth,
treeDescendantCount: node.descendantCount,
treeHasBusyDescendant: node.hasBusyDescendant,
treeDescendantUnreadCount: node.descendantUnreadCount,
treeDescendantRunningCount: node.descendantRunningCount,
treeDescendantAttention: node.descendantAttention,
treeExpanded: expanded,
treeParentKey: node.orphanedFrom?.key ?? null,
treeParentTitle: node.orphanedFrom?.title ?? null,
},
showGroup,
)}
{expanded && node.children.length > 0 ? (
<li>
<ul
Expand Down Expand Up @@ -4158,14 +4202,17 @@ export function PhaseGroupedSidebar() {
<SidebarGroup className="px-2 pt-1 pb-2">
<div className="flex items-center justify-between px-1">
{/* T3-CUSTOM(expbkt3): the caption is the group-by control. */}
<PhaseSidebarGroupByPopover
groups={customGroupOptions}
onCreateGroup={createCustomGroup}
onRenameGroup={renameCustomGroup}
onDeleteGroup={deleteCustomGroup}
onMoveGroup={moveCustomGroup}
{...(canRecolorCustomGroups ? { onRecolorGroup: recolorCustomGroup } : {})}
/>
<div className="flex min-w-0 items-center gap-3">
<PhaseSidebarGroupByPopover
groups={customGroupOptions}
onCreateGroup={createCustomGroup}
onRenameGroup={renameCustomGroup}
onDeleteGroup={deleteCustomGroup}
onMoveGroup={moveCustomGroup}
{...(canRecolorCustomGroups ? { onRecolorGroup: recolorCustomGroup } : {})}
/>
{grouping.groupBy === "custom" ? <PhaseSidebarRunningShelfSwitch /> : null}
</div>
<PhaseFilterPopover
repositories={repositoryOptions}
providers={providerOptions}
Expand Down Expand Up @@ -4227,7 +4274,7 @@ export function PhaseGroupedSidebar() {
{/* T3-CUSTOM(expbkt3): drafts alone are a populated sidebar, even when collapsed. */}
{sections.every((section) => section.nodes.length === 0) &&
visibleDraftCount === 0 &&
snoozedRows.length + settledRows.length === 0 ? (
runningNodes.length + snoozedRows.length + settledRows.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-4 py-12 text-center">
<FilterIcon className="size-5 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">No threads match these filters</p>
Expand All @@ -4244,6 +4291,19 @@ export function PhaseGroupedSidebar() {
just above the footer, so their headers stay on screen and only the
list above gives up height when anything else in the sidebar grows. */}
<PhaseSidebarShelfDock
{...(runningNodes.length > 0
? {
running: {
count: runningNodes.length,
collapsed: runningShelfCollapsed,
containsRoutedThread: runningShelfHoldsRoutedThread,
items: runningShelfCollapsed
? null
: runningNodes.map((node) => renderTreeNode(node, true)),
onToggle: toggleRunningShelf,
},
}
: {})}
snoozed={{
count: snoozedRows.length,
collapsed: snoozedShelfCollapsed,
Expand Down Expand Up @@ -4376,11 +4436,37 @@ function PhaseSidebarSectionBlock({
const manageable =
onRenameGroup !== undefined || onDeleteGroup !== undefined || onRecolorGroup !== undefined;
const [colorPickerOpen, setColorPickerOpen] = useState(false);
// A custom group says in words what needs you, open or closed, in place of
// its session count and the closed-header pills.
const summaryWords =
section.kind === "custom"
? [
{
key: "attention",
count: summary.attention,
label: "asking",
className: "text-red-700 dark:text-red-300",
},
{
key: "unread",
count: summary.unread,
label: "unread",
className: "text-emerald-700 dark:text-emerald-300",
},
{
key: "running",
count: summary.running,
label: "running",
className: "text-sky-700 dark:text-sky-300",
},
].filter((word) => word.count > 0)
: [];
const showPills = summaryWords.length === 0;
const toggle = (
<button
type="button"
aria-expanded={!collapsed}
aria-label={`${section.label}, ${section.nodes.length} session${section.nodes.length === 1 ? "" : "s"}${collapsed ? ", collapsed" : ""}`}
aria-label={`${section.label}, ${section.nodes.length} session${section.nodes.length === 1 ? "" : "s"}${summaryWords.map((word) => `, ${word.count} ${word.label}`).join("")}${collapsed ? ", collapsed" : ""}`}
onClick={() => onToggleCollapsed(section.key)}
className={cn(
phaseSidebarSectionHeaderClassName(headerPhaseId),
Expand All @@ -4405,26 +4491,42 @@ function PhaseSidebarSectionBlock({
<span className="truncate text-[11px] font-bold uppercase tracking-[0.1em]">
{section.label}
</span>
<span className="min-w-0 flex-1 truncate text-[9px] text-current/55">
{section.helperText}
</span>
{collapsed && summary.attention > 0 ? (
{summaryWords.length > 0 ? (
<span
className="min-w-0 flex-1 truncate text-[9px] font-semibold"
data-testid="phase-sidebar-section-summary-words"
>
{summaryWords.map((word, index) => (
<Fragment key={word.key}>
{index > 0 ? <span className="text-current/40"> · </span> : null}
<span className={word.className}>
{word.count} {word.label}
</span>
</Fragment>
))}
</span>
) : (
<span className="min-w-0 flex-1 truncate text-[9px] text-current/55">
{section.helperText}
</span>
)}
{showPills && collapsed && summary.attention > 0 ? (
<span
className="rounded-full bg-red-500/15 px-1.5 py-0.5 text-[9px] font-semibold tabular-nums text-red-700 dark:text-red-300"
aria-label={`${summary.attention} waiting on you`}
>
{summary.attention}
</span>
) : null}
{collapsed && summary.running > 0 ? (
{showPills && collapsed && summary.running > 0 ? (
<span
className="rounded-full bg-sky-500/15 px-1.5 py-0.5 text-[9px] font-semibold tabular-nums text-sky-700 dark:text-sky-300"
aria-label={`${summary.running} running`}
>
{summary.running}
</span>
) : null}
{collapsed && summary.unread > 0 ? (
{showPills && collapsed && summary.unread > 0 ? (
<span
className="rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
aria-label={`${summary.unread} unread`}
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/sidebar/PhaseSidebarRunningShelfSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// T3-CUSTOM(expbkt3): the Running shelf switch beside the group-by caption.
// Custom mode only: when it is on, sessions that run with nothing waiting on a
// human leave their groups for the Running shelf in the dock.
import { usePhaseSidebarGroupingStore } from "../../phaseSidebarGroupingStore";
import { Switch } from "../ui/switch";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";

export function PhaseSidebarRunningShelfSwitch() {
const enabled = usePhaseSidebarGroupingStore((state) => state.grouping.runningShelf === true);
const setRunningShelf = usePhaseSidebarGroupingStore((state) => state.setRunningShelf);
return (
<Tooltip>
<TooltipTrigger
render={
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70 hover:text-foreground">
<Switch
size="sm"
checked={enabled}
onCheckedChange={setRunningShelf}
aria-label="Move running sessions to the Running shelf"
data-testid="phase-sidebar-running-shelf-switch"
/>
Running
</label>
}
/>
<TooltipPopup side="bottom">
Move running sessions without a question to the Running shelf
</TooltipPopup>
</Tooltip>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const onToggle = vi.fn();
const onShowMoreSettled = vi.fn();
let renderer: ReactTestRenderer | null = null;

function mount(props: Pick<PhaseSidebarShelfDockProps, "snoozed" | "settled">) {
function mount(props: Pick<PhaseSidebarShelfDockProps, "running" | "snoozed" | "settled">) {
act(() => {
renderer = create(
<PhaseSidebarShelfDock
Expand Down Expand Up @@ -109,4 +109,41 @@ describe("PhaseSidebarShelfDock", () => {
act(() => showMore.props.onClick());
expect(onShowMoreSettled).toHaveBeenCalledTimes(1);
});

it("shows the Running shelf on top, open with its rows, and toggles it on its own", () => {
const onToggleRunning = vi.fn();
mount({
running: {
count: 1,
collapsed: false,
containsRoutedThread: false,
items: <li data-testid="row-running">r1</li>,
onToggle: onToggleRunning,
},
snoozed: shelf([], { collapsed: true }),
settled: shelf([], { collapsed: true }),
});

expect(byTestId("row-running")).toHaveLength(1);
act(() => byTestId("phase-sidebar-running-shelf-toggle")[0]!.props.onClick());
expect(onToggleRunning).toHaveBeenCalledTimes(1);
expect(onToggle).not.toHaveBeenCalled();
});

it("keeps a collapsed Running shelf to its header and marks the open thread", () => {
mount({
running: {
count: 2,
collapsed: true,
containsRoutedThread: true,
items: <li data-testid="row-running">r1</li>,
onToggle: vi.fn(),
},
snoozed: shelf([], { collapsed: true }),
settled: shelf([], { collapsed: true }),
});

expect(byTestId("row-running")).toHaveLength(0);
expect(byTestId("phase-sidebar-running-shelf-open-thread")).toHaveLength(1);
});
});
Loading
Loading