Files
kandev/apps/web/components/task/changes-tab.tsx
T
José Almeida 68c94dfa3a feat: refine workbench chrome (#761)
* docs: define product and design direction

* style: establish restrained workbench primitives

* refactor: declutter board and task topbars

* style: align dockview panel chrome

* fix: align task topbar vcs control sizing

* refactor: surface integrations navigation

* fix: repair integrations and task creation workflow

* refactor: include linear in integrations navigation

* style: restore familiar workbench tones

* fix: add dockview tab breathing room

* fix: show configured integration shortcuts

* fix: hover open integrations menu

* fix: stabilize integrations hover menu

* fix: align home and dockview topbars

* fix: normalize home topbar controls

* fix: compact home topbar height

* fix: simplify home chrome and split buttons

* fix: move integrations to left chrome

* fix: restore home stats shortcut

* fix: add dockview tab breathing room

* fix: border home stats button

* fix: streamline dockview topbar actions

* fix: align task split button seam

* fix: address review feedback

* fix: restore topbar e2e affordances

* fix: stabilize workflow e2e isolation

* fix: add task topbar action overflow

* fix: address PR review feedback
2026-05-01 21:02:41 +01:00

137 lines
5.0 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@kandev/ui/context-menu";
import { useAppStore } from "@/components/state-provider";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { cn } from "@kandev/ui/lib/utils";
/** Auto-activate the changes panel only when it lives in the right sidebar. */
function autoActivateChangesPanel(): void {
const { api, rightTopGroupId } = useDockviewStore.getState();
if (!api) return;
const panel = api.getPanel("changes");
// Only auto-focus when the panel is in the right sidebar.
// When it's in the center group (e.g. plan mode layout), never steal focus
// from the active chat/session panel.
if (panel && panel.group.id === rightTopGroupId) {
panel.api.setActive();
}
}
/**
* Custom tab component for the Changes panel.
* Provides auto-activation, flash animation on new changes,
* and a badge showing unseen change count.
*/
export function ChangesTab(props: IDockviewPanelHeaderProps) {
const { api, containerApi } = props;
const activeSessionId = useAppStore((s) => s.tasks.activeSessionId);
const gitStatus = useSessionGitStatus(activeSessionId);
const { commits } = useSessionCommits(activeSessionId ?? null);
const fileCount = gitStatus?.files ? Object.keys(gitStatus.files).length : 0;
const totalCount = fileCount + commits.length;
// gitStatus is undefined until the first WS git-status event arrives,
// which marks the end of the initial data load for this session.
const gitStatusLoaded = gitStatus !== undefined;
const prevTotalRef = useRef(totalCount);
const seenCountRef = useRef(api.isActive ? totalCount : 0);
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Armed once we know the initial git data has settled. Until then, any
// 0→N transition is treated as an initial load, not a real new change.
const initializedRef = useRef(false);
const [isFlashing, setIsFlashing] = useState(false);
const [badgeCount, setBadgeCount] = useState(0);
// Reset seenCount when the user activates this tab
useEffect(() => {
const disposable = api.onDidActiveChange((event) => {
if (event.isActive) {
seenCountRef.current = totalCount;
setBadgeCount(0);
}
});
return () => disposable.dispose();
}, [api, totalCount]);
// React to totalCount changes: auto-activate, flash, badge
useEffect(() => {
if (api.isActive) {
seenCountRef.current = totalCount;
}
const prev = prevTotalRef.current;
prevTotalRef.current = totalCount;
const increased = totalCount > prev && totalCount > 0;
const decreased = totalCount < prev;
// Auto-activate when changes appear for the first time (0 → N), but only
// after initial git data has settled. gitStatusLoaded is false until the
// first WS git-status event arrives, guaranteeing data has loaded before we
// arm auto-activate (handles both page-refresh and clean-session cases).
if (!initializedRef.current) {
if (gitStatusLoaded) initializedRef.current = true;
} else if (increased && prev === 0) {
autoActivateChangesPanel();
}
if (increased) {
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
// Defer setState to satisfy react-hooks/set-state-in-effect
flashTimerRef.current = setTimeout(() => setIsFlashing(false), 1000);
requestAnimationFrame(() => setIsFlashing(true));
}
if ((increased || decreased) && !api.isActive) {
const unseen = Math.max(0, totalCount - seenCountRef.current);
requestAnimationFrame(() => setBadgeCount(unseen));
}
}, [totalCount, api, gitStatusLoaded]);
// Cleanup flash timer on unmount
useEffect(() => {
return () => {
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
};
}, []);
const handleCloseOthers = useCallback(() => {
const toClose = api.group.panels.filter(
(p) => p.id !== api.id && p.id !== "chat" && !p.id.startsWith("session:"),
);
for (const panel of toClose) containerApi.removePanel(panel);
}, [api, containerApi]);
return (
<ContextMenu>
<ContextMenuTrigger className="flex h-full items-center cursor-pointer">
<div className={cn("relative", isFlashing && "animate-changes-flash")}>
<DockviewDefaultTab {...props} />
{badgeCount > 0 && (
<span className="absolute top-0.5 left-0 size-2 rounded-full bg-primary pointer-events-none" />
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem className="cursor-pointer" onSelect={handleCloseOthers}>
Close Others
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}