Files
kandev/apps/web/components/task/task-right-panel.tsx
T
2026-02-17 12:32:02 +00:00

235 lines
8.6 KiB
TypeScript

'use client';
import type { ReactNode, MouseEvent } from 'react';
import { memo, useEffect, useCallback, useState, useMemo } from 'react';
import { Badge } from '@kandev/ui/badge';
import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session';
import { Group, Panel } from 'react-resizable-panels';
import { TabsContent } from '@kandev/ui/tabs';
import { getLocalStorage, setLocalStorage, getSessionStorage, setSessionStorage } from '@/lib/local-storage';
import { ShellTerminal } from '@/components/task/shell-terminal';
import { PassthroughTerminal } from '@/components/task/passthrough-terminal';
import { useAppStore } from '@/components/state-provider';
import { useLayoutStore } from '@/lib/state/layout-store';
import { useDefaultLayout } from '@/lib/layout/use-default-layout';
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
import { useRepositoryScripts } from '@/hooks/domains/workspace/use-repository-scripts';
import { useTerminals } from '@/hooks/domains/session/use-terminals';
import type { RepositoryScript } from '@/lib/types/http';
import type { Terminal } from '@/hooks/domains/session/use-terminals';
type TaskRightPanelProps = {
topPanel: ReactNode;
sessionId?: string | null;
repositoryId?: string | null;
initialScripts?: RepositoryScript[];
initialTerminals?: Terminal[];
};
const DEFAULT_RIGHT_LAYOUT: Record<string, number> = { top: 55, bottom: 45 };
const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null, repositoryId = null, initialScripts = [], initialTerminals }: TaskRightPanelProps) {
const rightPanelIds = ['top', 'bottom'];
const rightLayoutKey = 'task-layout-right-v2';
const { defaultLayout: rightLayout, onLayoutChanged: onRightLayoutChange } = useDefaultLayout({
id: rightLayoutKey,
panelIds: rightPanelIds,
baseLayout: DEFAULT_RIGHT_LAYOUT,
});
const [isBottomCollapsed, setIsBottomCollapsed] = useState<boolean>(() =>
getLocalStorage('task-right-panel-collapsed', false)
);
const setRightPanelActiveTab = useAppStore((state) => state.setRightPanelActiveTab);
const closeLayoutPreview = useLayoutStore((state) => state.closePreview);
// Use the terminals hook
const {
terminals,
activeTab,
terminalTabValue,
addTerminal,
handleCloseDevTab: baseHandleCloseDevTab,
handleCloseTab,
handleRunCommand,
isStoppingDev,
devProcessId,
devOutput,
} = useTerminals({ sessionId, initialTerminals });
// Wrap handleCloseDevTab to also close the layout preview
const handleCloseDevTab = useCallback(async (event: MouseEvent) => {
await baseHandleCloseDevTab(event);
if (sessionId) {
closeLayoutPreview(sessionId);
}
}, [baseHandleCloseDevTab, sessionId, closeLayoutPreview]);
// Repository scripts - use initialScripts as fallback for SSR
const { scripts: storeScripts, isLoaded: scriptsLoaded } = useRepositoryScripts(repositoryId);
const scripts = scriptsLoaded ? storeScripts : (initialScripts.length > 0 ? initialScripts : storeScripts);
const hasScripts = scripts.length > 0;
// Restore 'commands' tab from sessionStorage (needs hasScripts check)
useEffect(() => {
if (!sessionId || !hasScripts) return;
const savedTab = getSessionStorage<string | null>(`rightPanel-tab-${sessionId}`, null);
if (savedTab === 'commands') {
setRightPanelActiveTab(sessionId, savedTab);
}
}, [sessionId, hasScripts, setRightPanelActiveTab]);
// Persist active tab to sessionStorage when it changes
useEffect(() => {
if (!sessionId || !activeTab) return;
setSessionStorage(`rightPanel-tab-${sessionId}`, activeTab);
}, [sessionId, activeTab]);
// Save collapse state to local storage
useEffect(() => {
setLocalStorage('task-right-panel-collapsed', isBottomCollapsed);
}, [isBottomCollapsed]);
const tabs: SessionTab[] = useMemo(() => {
const allTabs: SessionTab[] = [];
// Only show Commands tab if scripts exist (from SSR or loaded)
if (hasScripts) {
allTabs.push({ id: 'commands', label: 'Commands' });
}
// Add terminal tabs (dev-server, shell, script)
// Use closable from backend (source of truth) for shell terminals
const terminalTabs: SessionTab[] = terminals.map((terminal) => {
return {
id: terminal.id,
label: terminal.label,
className: terminal.closable
? 'group flex items-center cursor-pointer'
: 'cursor-pointer',
closable: terminal.closable,
onClose:
terminal.type === 'dev-server'
? handleCloseDevTab
: (e: MouseEvent) => handleCloseTab(e, terminal.id),
};
});
return [...allTabs, ...terminalTabs];
}, [hasScripts, terminals, handleCloseDevTab, handleCloseTab]);
if (isBottomCollapsed) {
return (
<div className="h-full min-h-0 flex flex-col gap-1">
<div className="flex-1 min-h-0">{topPanel}</div>
<SessionPanel
borderSide="left"
className="!h-10 !p-0 mt-[2px] justify-between items-center flex-row"
>
<SessionTabs
tabs={tabs}
activeTab={terminalTabValue}
onTabChange={(value) => {
if (sessionId) {
setRightPanelActiveTab(sessionId, value);
}
}}
showAddButton
onAddTab={addTerminal}
collapsible
isCollapsed={isBottomCollapsed}
onToggleCollapse={() => setIsBottomCollapsed(false)}
className="flex-1 min-h-0"
/>
</SessionPanel>
</div>
);
}
return (
<Group
orientation="vertical"
className="h-full min-h-0"
id={rightLayoutKey}
key={rightLayoutKey}
defaultLayout={rightLayout}
onLayoutChanged={onRightLayoutChange}
>
<Panel id="top" minSize={30} className="min-h-0">
{topPanel}
</Panel>
<Panel id="bottom" minSize={20} className="min-h-0">
<SessionPanel borderSide="left" margin="top">
<SessionTabs
tabs={tabs}
activeTab={terminalTabValue}
onTabChange={(value) => {
if (sessionId) {
setRightPanelActiveTab(sessionId, value);
}
}}
showAddButton
onAddTab={addTerminal}
collapsible
isCollapsed={isBottomCollapsed}
onToggleCollapse={() => setIsBottomCollapsed(true)}
className="flex-1 min-h-0"
>
<TabsContent value="commands" className="flex-1 min-h-0">
<SessionPanelContent>
<div className="grid gap-2">
{scripts.map((script) => (
<button
key={script.id}
type="button"
onClick={() => handleRunCommand(script)}
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-left hover:bg-muted cursor-pointer min-w-0"
>
<span className="flex-1 min-w-0 truncate text-xs">
{script.name}
</span>
<Badge
variant="secondary"
className="shrink-0 font-mono text-xs max-w-[60%] min-w-0"
>
<span className="truncate block">
{script.command}
</span>
</Badge>
</button>
))}
</div>
</SessionPanelContent>
</TabsContent>
{terminals.map((terminal) => (
<TabsContent key={terminal.id} value={terminal.id} className="flex-1 min-h-0">
<SessionPanelContent className="p-0">
{terminal.type === 'dev-server' ? (
<ShellTerminal
key={devProcessId}
processOutput={devOutput}
processId={devProcessId ?? null}
isStopping={isStoppingDev}
/>
) : (
<PassthroughTerminal
key={terminal.id}
sessionId={sessionId ?? undefined}
mode="shell"
terminalId={terminal.id}
label={terminal.type === 'shell' ? terminal.label : undefined}
/>
)}
</SessionPanelContent>
</TabsContent>
))}
</SessionTabs>
</SessionPanel>
</Panel>
</Group>
);
});
export { TaskRightPanel };