feat: improve chat ui paddings (#83)
This commit is contained in:
@@ -30,6 +30,7 @@ export * from './label';
|
||||
export * from './menubar';
|
||||
export * from './navigation-menu';
|
||||
export * from './pagination';
|
||||
export * from './pannel-session';
|
||||
export * from './popover';
|
||||
export * from './progress';
|
||||
export * from './radio-group';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from './lib/utils';
|
||||
|
||||
type BorderSide = 'none' | 'left' | 'right';
|
||||
type MarginSide = 'none' | 'right' | 'top';
|
||||
|
||||
interface SessionPanelProps {
|
||||
children: ReactNode;
|
||||
borderSide?: BorderSide;
|
||||
margin?: MarginSide;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SessionPanelContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SessionPanel({
|
||||
children,
|
||||
borderSide = 'none',
|
||||
margin = 'none',
|
||||
className,
|
||||
}: SessionPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Base styles - always applied
|
||||
'h-full min-h-0 bg-card flex flex-col rounded-sm border border-border/70 p-2',
|
||||
// Border removal
|
||||
borderSide === 'left' && 'border-l-0',
|
||||
borderSide === 'right' && 'border-r-0',
|
||||
// Margins
|
||||
margin === 'right' && 'mr-[5px]',
|
||||
margin === 'top' && 'mt-[5px]',
|
||||
// Custom classes
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SessionPanelContent = forwardRef<HTMLDivElement, SessionPanelContentProps>(
|
||||
function SessionPanelContent({ children, className }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex-1 min-h-0 overflow-y-auto rounded-lg bg-background h-full p-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -88,10 +88,10 @@
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--scrollbar-thumb: oklch(0.78 0 0);
|
||||
--scrollbar-thumb-hover: oklch(0.66 0 0);
|
||||
--scrollbar-track: oklch(0.96 0 0 / 0.6);
|
||||
--scrollbar-track: var(--background);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -125,10 +125,10 @@
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--scrollbar-thumb: oklch(0.48 0 0 / 0.7);
|
||||
--scrollbar-thumb-hover: oklch(0.62 0 0 / 0.8);
|
||||
--scrollbar-track: oklch(0.2 0 0 / 0.6);
|
||||
--scrollbar-track: var(--background);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode, MouseEvent } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@kandev/ui/tabs';
|
||||
|
||||
export type SessionTab = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
closable?: boolean;
|
||||
onClose?: (event: MouseEvent) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type SessionTabsProps = {
|
||||
children?: ReactNode; // TabsContent elements (optional for cases where tabs are just visible)
|
||||
tabs: SessionTab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
showAddButton?: boolean;
|
||||
onAddTab?: () => void;
|
||||
addButtonLabel?: string;
|
||||
separatorAfterIndex?: number;
|
||||
className?: string;
|
||||
// Collapse support
|
||||
collapsible?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
};
|
||||
|
||||
export function SessionTabs({
|
||||
children,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
showAddButton = false,
|
||||
onAddTab,
|
||||
addButtonLabel = '+',
|
||||
separatorAfterIndex,
|
||||
className,
|
||||
collapsible = false,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
}: SessionTabsProps) {
|
||||
const tabsList = (
|
||||
<TabsList className="p-0 !h-6 rounded-sm">
|
||||
{tabs.map((tab, index) => (
|
||||
<Fragment key={tab.id}>
|
||||
{separatorAfterIndex !== undefined && index === separatorAfterIndex + 1 && (
|
||||
<div className="h-4 w-px bg-border mx-1" />
|
||||
)}
|
||||
<TabsTrigger
|
||||
value={tab.id}
|
||||
className={tab.className + ' py-1 cursor-pointer rounded-sm'}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className={tab.icon ? 'ml-1.5' : undefined}>{tab.label}</span>
|
||||
{tab.closable && tab.onClose && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className="ml-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:text-foreground text-muted-foreground"
|
||||
onClick={tab.onClose}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</Fragment>
|
||||
))}
|
||||
{showAddButton && onAddTab && (
|
||||
<TabsTrigger value="add" onClick={onAddTab} className="cursor-pointer">
|
||||
{addButtonLabel}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
);
|
||||
|
||||
const collapseButton = collapsible && onToggleCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={onToggleCollapse}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange} className={className}>
|
||||
{collapsible ? (
|
||||
<div className={`flex items-center justify-between ${children ? '' : 'p-2'}`}>
|
||||
{tabsList}
|
||||
{collapseButton}
|
||||
</div>
|
||||
) : (
|
||||
tabsList
|
||||
)}
|
||||
{children}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IconLoader2 } from '@tabler/icons-react';
|
||||
import { SessionPanelContent } from '@kandev/ui/pannel-session';
|
||||
import type { Message } from '@/lib/types/http';
|
||||
import { MessageRenderer } from '@/components/task/chat/message-renderer';
|
||||
import { TurnSummary } from '@/components/task/chat/messages/turn-summary';
|
||||
@@ -97,9 +98,9 @@ export function VirtualizedMessageList({
|
||||
}, [virtualItems, hasMore, isLoadingMore, loadMore]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<SessionPanelContent
|
||||
ref={messagesContainerRef}
|
||||
className="relative flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3"
|
||||
className="relative"
|
||||
>
|
||||
{isLoadingMore && hasMore && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 text-xs text-muted-foreground">
|
||||
@@ -147,6 +148,6 @@ export function VirtualizedMessageList({
|
||||
)}
|
||||
{/* Turn summary - shows duration and model after last message */}
|
||||
<TurnSummary sessionId={sessionId} />
|
||||
</div>
|
||||
</SessionPanelContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useState, useEffect } from 'react';
|
||||
import { IconRefresh, IconExternalLink, IconLoader2 } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session';
|
||||
import { usePreviewPanel } from '@/hooks/use-preview-panel';
|
||||
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
import { useLayoutStore } from '@/lib/state/layout-store';
|
||||
import { ProcessOutputTerminal } from '@/components/task/process-output-terminal';
|
||||
import { ShellTerminal } from '@/components/task/shell-terminal';
|
||||
import { getLocalStorage } from '@/lib/local-storage';
|
||||
|
||||
type PreviewPanelProps = {
|
||||
@@ -182,107 +183,105 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col rounded-lg border border-border/70 bg-card p-3 mr-[5px]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={previewUrlDraft}
|
||||
onChange={(event) => setPreviewUrlDraft(sessionId, event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleUrlSubmit();
|
||||
<SessionPanel margin="right">
|
||||
<div className="h-full flex flex-col gap-2 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={previewUrlDraft}
|
||||
onChange={(event) => setPreviewUrlDraft(sessionId, event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleUrlSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder={detectedUrl || "http://localhost:3000"}
|
||||
className="h-6 flex-1 min-w-[240px]"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleOpenInTab}
|
||||
disabled={!previewUrl}
|
||||
className="cursor-pointer"
|
||||
title="Open in browser tab"
|
||||
>
|
||||
<IconExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRefreshKey((value) => value + 1)}
|
||||
disabled={!previewUrl}
|
||||
className="cursor-pointer"
|
||||
title="Refresh preview"
|
||||
>
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStopClick}
|
||||
disabled={isStopping}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{isStopping ? 'Stopping…' : (isProcessFailed || isProcessExited) ? 'Close' : 'Stop'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={previewView === 'output' ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setPreviewView(sessionId, previewView === 'output' ? 'preview' : 'output')
|
||||
}
|
||||
}}
|
||||
placeholder={detectedUrl || "http://localhost:3000"}
|
||||
className="h-6 flex-1 min-w-[240px]"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleOpenInTab}
|
||||
disabled={!previewUrl}
|
||||
className="cursor-pointer"
|
||||
title="Open in browser tab"
|
||||
>
|
||||
<IconExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRefreshKey((value) => value + 1)}
|
||||
disabled={!previewUrl}
|
||||
className="cursor-pointer"
|
||||
title="Refresh preview"
|
||||
>
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStopClick}
|
||||
disabled={isStopping}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{isStopping ? 'Stopping…' : (isProcessFailed || isProcessExited) ? 'Close' : 'Stop'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={previewView === 'output' ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setPreviewView(sessionId, previewView === 'output' ? 'preview' : 'output')
|
||||
}
|
||||
>
|
||||
{isWaitingForUrl && (
|
||||
<IconLoader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
)}
|
||||
{previewView === 'output' ? 'Preview' : 'Logs'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 mt-3">
|
||||
{previewView === 'output' ? (
|
||||
<div className="h-full w-full">
|
||||
<ProcessOutputTerminal output={devOutput} processId={devProcessId ?? null} />
|
||||
</div>
|
||||
) : showIframe && previewUrl ? (
|
||||
<div className="h-full w-full flex items-center justify-center overflow-hidden">
|
||||
<div className="h-full w-full bg-background border border-border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
key={refreshKey}
|
||||
src={previewUrl}
|
||||
title="Preview"
|
||||
className="h-full w-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : previewUrl && !showIframe ? (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-sm">Loading preview...</p>
|
||||
</div>
|
||||
) : isRunning && showLoadingSpinner ? (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-sm">Waiting for preview URL...</p>
|
||||
</div>
|
||||
) : isRunning ? (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<p className="text-sm">No preview URL detected</p>
|
||||
{allowManualUrl && !detectedUrl && (
|
||||
<p className="text-sm text-muted-foreground/70 mt-2 max-w-md text-center">
|
||||
You can manually enter a URL in the input above and press Enter.
|
||||
</p>
|
||||
>
|
||||
{isWaitingForUrl && (
|
||||
<IconLoader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center text-muted-foreground">
|
||||
Start the dev server to enable preview.
|
||||
</div>
|
||||
)}
|
||||
{previewView === 'output' ? 'Preview' : 'Logs'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SessionPanelContent className={previewView === 'output' || (showIframe && previewUrl) ? 'p-0' : ''}>
|
||||
{previewView === 'output' ? (
|
||||
<ShellTerminal processOutput={devOutput} processId={devProcessId ?? null} />
|
||||
) : showIframe && previewUrl ? (
|
||||
<iframe
|
||||
key={refreshKey}
|
||||
src={previewUrl}
|
||||
title="Preview"
|
||||
className="h-full w-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : previewUrl && !showIframe ? (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-sm">Loading preview...</p>
|
||||
</div>
|
||||
) : isRunning && showLoadingSpinner ? (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-sm">Waiting for preview URL...</p>
|
||||
</div>
|
||||
) : isRunning ? (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<p className="text-sm">No preview URL detected</p>
|
||||
{allowManualUrl && !detectedUrl && (
|
||||
<p className="text-sm text-muted-foreground/70 mt-2 max-w-md text-center">
|
||||
You can manually enter a URL in the input above and press Enter.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center text-muted-foreground">
|
||||
Start the dev server to enable preview.
|
||||
</div>
|
||||
)}
|
||||
</SessionPanelContent>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</SessionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const DEFAULT_FONT = 'Menlo, Monaco, "Courier New", monospace';
|
||||
|
||||
type ProcessOutputTerminalProps = {
|
||||
output: string;
|
||||
processId?: string | null;
|
||||
isStopping?: boolean;
|
||||
};
|
||||
|
||||
export function ProcessOutputTerminal({ output, processId, isStopping = false }: ProcessOutputTerminalProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const lastOutputLengthRef = useRef(0);
|
||||
const processIdRef = useRef<string | null>(null);
|
||||
const outputRef = useRef(output);
|
||||
|
||||
useEffect(() => {
|
||||
outputRef.current = output;
|
||||
}, [output]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current || xtermRef.current) return;
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize: 12,
|
||||
fontFamily: DEFAULT_FONT,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#1e1e1e',
|
||||
red: '#f44747',
|
||||
green: '#6a9955',
|
||||
yellow: '#dcdcaa',
|
||||
blue: '#569cd6',
|
||||
magenta: '#c586c0',
|
||||
cyan: '#4ec9b0',
|
||||
white: '#d4d4d4',
|
||||
brightBlack: '#808080',
|
||||
brightRed: '#f44747',
|
||||
brightGreen: '#6a9955',
|
||||
brightYellow: '#dcdcaa',
|
||||
brightBlue: '#569cd6',
|
||||
brightMagenta: '#c586c0',
|
||||
brightCyan: '#4ec9b0',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
if (outputRef.current) {
|
||||
terminal.write(outputRef.current);
|
||||
lastOutputLengthRef.current = outputRef.current.length;
|
||||
}
|
||||
|
||||
const initialFitTimeout = setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 100);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && fitAddonRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
intersectionObserver.observe(terminalRef.current);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initialFitTimeout);
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
terminal.dispose();
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!xtermRef.current) return;
|
||||
if (processIdRef.current !== processId) {
|
||||
processIdRef.current = processId ?? null;
|
||||
lastOutputLengthRef.current = 0;
|
||||
xtermRef.current.clear();
|
||||
if (outputRef.current) {
|
||||
xtermRef.current.write(outputRef.current);
|
||||
lastOutputLengthRef.current = outputRef.current.length;
|
||||
}
|
||||
}
|
||||
}, [processId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!xtermRef.current) return;
|
||||
const newData = output.slice(lastOutputLengthRef.current);
|
||||
if (newData) {
|
||||
xtermRef.current.write(newData);
|
||||
lastOutputLengthRef.current = output.length;
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full rounded-md bg-[#1e1e1e] relative">
|
||||
<div ref={terminalRef} className="absolute inset-0" />
|
||||
{isStopping ? (
|
||||
<div className="absolute right-3 top-2 text-xs text-muted-foreground">
|
||||
Stopping…
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,21 @@ import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { useSession } from '@/hooks/domains/session/use-session';
|
||||
import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl';
|
||||
|
||||
export function ShellTerminal() {
|
||||
type ShellTerminalProps = {
|
||||
// Interactive shell mode - requires sessionId
|
||||
sessionId?: string;
|
||||
// Read-only process output mode - requires processOutput
|
||||
processOutput?: string;
|
||||
processId?: string | null;
|
||||
isStopping?: boolean;
|
||||
};
|
||||
|
||||
export function ShellTerminal({
|
||||
sessionId: propSessionId,
|
||||
processOutput,
|
||||
processId,
|
||||
isStopping = false,
|
||||
}: ShellTerminalProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
@@ -17,18 +31,33 @@ export function ShellTerminal() {
|
||||
const subscriptionIdRef = useRef(0);
|
||||
const onDataDisposableRef = useRef<{ dispose: () => void } | null>(null);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const processIdRef = useRef<string | null>(null);
|
||||
const outputRef = useRef(processOutput ?? '');
|
||||
const storeApi = useAppStoreApi();
|
||||
|
||||
const sessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const { session, isActive } = useSession(sessionId);
|
||||
// Determine mode: if processOutput is provided, use read-only mode
|
||||
const isReadOnlyMode = processOutput !== undefined;
|
||||
|
||||
// For interactive mode, get sessionId from store if not provided as prop
|
||||
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const sessionId = propSessionId ?? storeSessionId;
|
||||
|
||||
const { session, isActive } = useSession(isReadOnlyMode ? null : sessionId);
|
||||
// Hook also subscribes to session channel for real-time updates
|
||||
useSessionAgentctl(sessionId);
|
||||
useSessionAgentctl(isReadOnlyMode ? null : sessionId);
|
||||
const taskId = session?.task_id ?? null;
|
||||
const shellOutput = useAppStore((state) =>
|
||||
sessionId ? state.shell.outputs[sessionId] || '' : ''
|
||||
sessionId && !isReadOnlyMode ? state.shell.outputs[sessionId] || '' : ''
|
||||
);
|
||||
// Don't gate on isAgentctlReady - shell works as long as session is active
|
||||
const canSubscribe = Boolean(sessionId && isActive);
|
||||
const canSubscribe = Boolean(sessionId && isActive && !isReadOnlyMode);
|
||||
|
||||
// Update output ref when processOutput changes
|
||||
useEffect(() => {
|
||||
if (isReadOnlyMode) {
|
||||
outputRef.current = processOutput ?? '';
|
||||
}
|
||||
}, [processOutput, isReadOnlyMode]);
|
||||
|
||||
const send = useCallback(
|
||||
(action: string, payload: Record<string, unknown>) => {
|
||||
@@ -45,14 +74,16 @@ export function ShellTerminal() {
|
||||
if (!terminalRef.current || xtermRef.current) return;
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
cursorBlink: !isReadOnlyMode,
|
||||
disableStdin: isReadOnlyMode,
|
||||
convertEol: isReadOnlyMode,
|
||||
fontSize: isReadOnlyMode ? 12 : 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
background: 'transparent',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
cursorAccent: '#1e1e1e',
|
||||
cursorAccent: 'transparent',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#1e1e1e',
|
||||
red: '#f44747',
|
||||
@@ -81,6 +112,22 @@ export function ShellTerminal() {
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Force transparent background on all xterm elements to inherit from parent
|
||||
const xtermElement = terminalRef.current.querySelector('.xterm');
|
||||
const xtermViewport = terminalRef.current.querySelector('.xterm-viewport');
|
||||
const xtermScreen = terminalRef.current.querySelector('.xterm-screen');
|
||||
const xtermScrollable = terminalRef.current.querySelector('.xterm-scrollable-element');
|
||||
if (xtermElement) (xtermElement as HTMLElement).style.background = 'transparent';
|
||||
if (xtermViewport) (xtermViewport as HTMLElement).style.background = 'transparent';
|
||||
if (xtermScreen) (xtermScreen as HTMLElement).style.background = 'transparent';
|
||||
if (xtermScrollable) (xtermScrollable as HTMLElement).style.backgroundColor = 'transparent';
|
||||
|
||||
// For read-only mode, write initial output
|
||||
if (isReadOnlyMode && outputRef.current) {
|
||||
terminal.write(outputRef.current);
|
||||
lastOutputLengthRef.current = outputRef.current.length;
|
||||
}
|
||||
|
||||
// Ensure terminal is properly sized after initial render
|
||||
const initialFitTimeout = setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
@@ -118,10 +165,11 @@ export function ShellTerminal() {
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [taskId, sessionId, send]);
|
||||
}, [taskId, sessionId, send, isReadOnlyMode]);
|
||||
|
||||
// Handle user input (interactive mode only)
|
||||
useEffect(() => {
|
||||
if (!xtermRef.current) return;
|
||||
if (!xtermRef.current || isReadOnlyMode) return;
|
||||
onDataDisposableRef.current?.dispose();
|
||||
onDataDisposableRef.current = null;
|
||||
|
||||
@@ -141,19 +189,35 @@ export function ShellTerminal() {
|
||||
onDataDisposableRef.current?.dispose();
|
||||
onDataDisposableRef.current = null;
|
||||
};
|
||||
}, [taskId, sessionId, send]);
|
||||
}, [taskId, sessionId, send, isReadOnlyMode]);
|
||||
|
||||
// Handle processId changes in read-only mode (clear terminal and reset output)
|
||||
useEffect(() => {
|
||||
if (!xtermRef.current || !isReadOnlyMode) return;
|
||||
if (processIdRef.current !== processId) {
|
||||
processIdRef.current = processId ?? null;
|
||||
lastOutputLengthRef.current = 0;
|
||||
xtermRef.current.clear();
|
||||
if (outputRef.current) {
|
||||
xtermRef.current.write(outputRef.current);
|
||||
lastOutputLengthRef.current = outputRef.current.length;
|
||||
}
|
||||
}
|
||||
}, [processId, isReadOnlyMode]);
|
||||
|
||||
// Write new output to terminal
|
||||
useEffect(() => {
|
||||
if (!xtermRef.current) return;
|
||||
|
||||
const output = isReadOnlyMode ? (processOutput ?? '') : shellOutput;
|
||||
|
||||
// Only write new data since last update
|
||||
const newData = shellOutput.slice(lastOutputLengthRef.current);
|
||||
const newData = output.slice(lastOutputLengthRef.current);
|
||||
if (newData) {
|
||||
xtermRef.current.write(newData);
|
||||
lastOutputLengthRef.current = shellOutput.length;
|
||||
lastOutputLengthRef.current = output.length;
|
||||
}
|
||||
}, [shellOutput]);
|
||||
}, [shellOutput, processOutput, isReadOnlyMode]);
|
||||
|
||||
// Subscribe to shell once agentctl is ready.
|
||||
useEffect(() => {
|
||||
@@ -223,10 +287,23 @@ export function ShellTerminal() {
|
||||
};
|
||||
}, [taskId, sessionId, storeApi, canSubscribe, send]);
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
return (
|
||||
<div className="h-full w-full rounded-md bg-background relative">
|
||||
<div ref={terminalRef} className="p-1 absolute inset-0" />
|
||||
{isStopping ? (
|
||||
<div className="absolute right-3 top-2 text-xs text-muted-foreground">
|
||||
Stopping…
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="h-full w-full overflow-hidden rounded-md border border-border bg-[#1e1e1e]"
|
||||
className="h-full p-1 w-full overflow-hidden rounded-md bg-background"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback, useState, useEffect } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
|
||||
import { memo, useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import { TabsContent } from '@kandev/ui/tabs';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { SessionPanel } from '@kandev/ui/pannel-session';
|
||||
import { TaskChatPanel } from './task-chat-panel';
|
||||
import { TaskChangesPanel } from './task-changes-panel';
|
||||
import { FileViewerContent } from './file-viewer-content';
|
||||
import type { OpenFileTab } from '@/lib/types/backend';
|
||||
import { FILE_EXTENSION_COLORS } from '@/lib/types/backend';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
|
||||
|
||||
type TaskCenterPanelProps = {
|
||||
selectedDiffPath: string | null;
|
||||
@@ -71,67 +72,43 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
}
|
||||
}, [leftTab]);
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-r-0 mr-[5px]">
|
||||
<Tabs
|
||||
value={leftTab}
|
||||
onValueChange={(value) => setLeftTab(value)}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="notes" className="cursor-pointer">
|
||||
Notes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="changes" className="cursor-pointer">
|
||||
All changes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chat" className="cursor-pointer">
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{openFileTabs.length > 0 && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-border mx-1" />
|
||||
<TabsList className="bg-transparent">
|
||||
{openFileTabs.map((tab) => {
|
||||
const ext = tab.name.split('.').pop()?.toLowerCase() || '';
|
||||
const dotColor = FILE_EXTENSION_COLORS[ext] || 'bg-muted-foreground';
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={tab.path}
|
||||
value={`file:${tab.path}`}
|
||||
className="cursor-pointer relative group gap-1.5 data-[state=active]:bg-muted"
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
|
||||
<span className="truncate max-w-[100px]">{tab.name}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseFileTab(tab.path);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCloseFileTab(tab.path);
|
||||
}
|
||||
}}
|
||||
className="ml-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:text-foreground !cursor-pointer"
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
const tabs: SessionTab[] = useMemo(() => {
|
||||
const staticTabs: SessionTab[] = [
|
||||
{ id: 'notes', label: 'Notes' },
|
||||
{ id: 'changes', label: 'All changes' },
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
];
|
||||
|
||||
<TabsContent value="notes" className="mt-3 flex-1 min-h-0">
|
||||
const fileTabs: SessionTab[] = openFileTabs.map((tab) => {
|
||||
const ext = tab.name.split('.').pop()?.toLowerCase() || '';
|
||||
const dotColor = FILE_EXTENSION_COLORS[ext] || 'bg-muted-foreground';
|
||||
return {
|
||||
id: `file:${tab.path}`,
|
||||
label: tab.name,
|
||||
icon: <span className={`h-2 w-2 rounded-full ${dotColor}`} />,
|
||||
closable: true,
|
||||
onClose: (e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseFileTab(tab.path);
|
||||
},
|
||||
className: 'cursor-pointer group gap-1.5 data-[state=active]:bg-muted',
|
||||
};
|
||||
});
|
||||
|
||||
return [...staticTabs, ...fileTabs];
|
||||
}, [openFileTabs, handleCloseFileTab]);
|
||||
|
||||
return (
|
||||
<SessionPanel borderSide="right" margin="right">
|
||||
<SessionTabs
|
||||
tabs={tabs}
|
||||
activeTab={leftTab}
|
||||
onTabChange={setLeftTab}
|
||||
separatorAfterIndex={openFileTabs.length > 0 ? 2 : undefined}
|
||||
className="flex-1 min-h-0 flex flex-col gap-2"
|
||||
>
|
||||
|
||||
<TabsContent value="notes" className="flex-1 min-h-0">
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
@@ -140,14 +117,14 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="changes" className="mt-3 flex-1 min-h-0">
|
||||
<TabsContent value="changes" className="flex-1 min-h-0">
|
||||
<TaskChangesPanel
|
||||
selectedDiffPath={selectedDiffPath}
|
||||
onClearSelected={() => setSelectedDiffPath(null)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chat" className="mt-3 flex flex-col min-h-0 flex-1">
|
||||
<TabsContent value="chat" className="flex flex-col min-h-0 flex-1">
|
||||
{activeTaskId ? (
|
||||
<TaskChatPanel sessionId={sessionId} />
|
||||
) : (
|
||||
@@ -162,7 +139,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
<FileViewerContent path={tab.path} content={tab.content} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</SessionTabs>
|
||||
</SessionPanel>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IconArrowBackUp, IconCopy, IconLayoutColumns, IconLayoutRows } from '@t
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { SessionPanelContent } from '@kandev/ui/pannel-session';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
|
||||
@@ -155,7 +156,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 h-full">
|
||||
<SessionPanelContent>
|
||||
{changedFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No changes detected
|
||||
@@ -213,7 +214,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SessionPanelContent>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ export function TaskChatInput({
|
||||
highlightSelectionMatches: true,
|
||||
autocompletion: false,
|
||||
}}
|
||||
className="text-sm"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -128,7 +128,7 @@ export function TaskChatPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-2">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
||||
{todoItems.length > 0 && <TodoSummary todos={todoItems} />}
|
||||
<TaskChatInput
|
||||
value={messageInput}
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
IconCheck,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
|
||||
import { TabsContent } from '@kandev/ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session';
|
||||
import { LineStat } from '@/components/diff-stat';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor';
|
||||
@@ -19,6 +20,7 @@ import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-sta
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import type { FileInfo } from '@/lib/state/store';
|
||||
import { FileBrowser } from '@/components/task/file-browser';
|
||||
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
|
||||
|
||||
type OpenFileTab = {
|
||||
path: string;
|
||||
@@ -100,19 +102,27 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi
|
||||
}));
|
||||
}, [gitStatus]);
|
||||
|
||||
const tabs: SessionTab[] = [
|
||||
{
|
||||
id: 'diff',
|
||||
label: `Diff files${changedFiles.length > 0 ? ` (${changedFiles.length})` : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'All files',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-l-0">
|
||||
<Tabs value={topTab} onValueChange={(value) => setTopTab(value as 'diff' | 'files')} className="flex-1 min-h-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="diff" className="cursor-pointer">
|
||||
Diff files {changedFiles.length > 0 && `(${changedFiles.length})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="cursor-pointer">
|
||||
All files
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="diff" className="mt-3 flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 h-full">
|
||||
<SessionPanel borderSide="left">
|
||||
<SessionTabs
|
||||
tabs={tabs}
|
||||
activeTab={topTab}
|
||||
onTabChange={(value) => setTopTab(value as 'diff' | 'files')}
|
||||
className="flex-1 min-h-0"
|
||||
>
|
||||
<TabsContent value="diff" className="flex-1 min-h-0">
|
||||
<SessionPanelContent>
|
||||
{changedFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No changes detected
|
||||
@@ -208,10 +218,10 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SessionPanelContent>
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="mt-3 flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background h-full">
|
||||
<SessionPanelContent>
|
||||
{activeSessionId ? (
|
||||
<FileBrowser sessionId={activeSessionId} onOpenFile={onOpenFile} />
|
||||
) : (
|
||||
@@ -219,10 +229,10 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi
|
||||
No task selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SessionPanelContent>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SessionTabs>
|
||||
</SessionPanel>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { Group, Panel, Separator, type Layout } from 'react-resizable-panels';
|
||||
import { Group, Panel, type Layout } from 'react-resizable-panels';
|
||||
import { useDefaultLayout } from '@/lib/layout/use-default-layout';
|
||||
import { TaskCenterPanel } from './task-center-panel';
|
||||
import { TaskRightPanel } from './task-right-panel';
|
||||
@@ -101,7 +101,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
<PreviewController sessionId={sessionForPreview} hasDevScript={hasDevScript} />
|
||||
<Group
|
||||
orientation="horizontal"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
className="h-full min-h-0"
|
||||
id={horizontalLayoutKey}
|
||||
key={horizontalLayoutKey}
|
||||
defaultLayout={defaultHorizontalLayout}
|
||||
@@ -109,15 +109,14 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
>
|
||||
{layoutState.left ? (
|
||||
<>
|
||||
<Panel id="left" minSize={12} className="min-h-0 min-w-0">
|
||||
<Panel id="left" minSize="180px" className="min-h-0">
|
||||
<TaskSessionSidebar workspaceId={workspaceId} boardId={boardId} />
|
||||
</Panel>
|
||||
<Separator className="w-px" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{layoutState.preview ? (
|
||||
<Panel id="preview" minSize={40} className="min-h-0 min-w-0">
|
||||
<Panel id="preview" className="min-h-0 min-w-0">
|
||||
<Group
|
||||
orientation="horizontal"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
@@ -126,7 +125,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
defaultLayout={defaultPreviewLayout}
|
||||
onLayoutChanged={onPreviewLayoutChange}
|
||||
>
|
||||
<Panel id="chat" minSize={20} className="min-h-0 min-w-0">
|
||||
<Panel id="chat" minSize="400px" className="min-h-0 min-w-0">
|
||||
<TaskCenterPanel
|
||||
selectedDiffPath={selectedDiffPath}
|
||||
openFileRequest={openFileRequest}
|
||||
@@ -134,14 +133,13 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
onFileOpenHandled={() => setOpenFileRequest(null)}
|
||||
/>
|
||||
</Panel>
|
||||
<Separator className="w-px" />
|
||||
<Panel id="preview" minSize={20} className="min-h-0 min-w-0">
|
||||
<Panel id="preview" minSize="470px" className="min-h-0 min-w-0">
|
||||
<PreviewPanel sessionId={sessionForPreview} hasDevScript={hasDevScript} />
|
||||
</Panel>
|
||||
</Group>
|
||||
</Panel>
|
||||
) : (
|
||||
<Panel id="chat" minSize={45} className="min-h-0 min-w-0">
|
||||
<Panel id="chat" minSize="400px" className="min-h-0 min-w-0">
|
||||
<TaskCenterPanel
|
||||
selectedDiffPath={selectedDiffPath}
|
||||
openFileRequest={openFileRequest}
|
||||
@@ -154,8 +152,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
|
||||
{layoutState.right ? (
|
||||
<>
|
||||
<Separator className="w-px" />
|
||||
<Panel id="right" minSize={20} className="min-h-0 min-w-0">
|
||||
<Panel id="right" minSize="310px" className="min-h-0 min-w-0">
|
||||
<TaskRightPanel topPanel={topFilesPanel} sessionId={sessionForPreview} />
|
||||
</Panel>
|
||||
</>
|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode, MouseEvent } from 'react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { IconChevronDown, IconChevronUp, IconX } from '@tabler/icons-react';
|
||||
import { memo, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@kandev/ui/resizable';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
|
||||
import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session';
|
||||
import { Group, Panel } from 'react-resizable-panels';
|
||||
import { TabsContent } from '@kandev/ui/tabs';
|
||||
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
|
||||
import { COMMANDS } from '@/components/task/task-data';
|
||||
import { ShellTerminal } from '@/components/task/shell-terminal';
|
||||
import { ProcessOutputTerminal } from '@/components/task/process-output-terminal';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useLayoutStore } from '@/lib/state/layout-store';
|
||||
import { stopProcess } from '@/lib/api';
|
||||
import type { Layout } from 'react-resizable-panels';
|
||||
import { useDefaultLayout } from '@/lib/layout/use-default-layout';
|
||||
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
|
||||
|
||||
type TerminalType = 'commands' | 'dev-server' | 'shell';
|
||||
|
||||
type Terminal = {
|
||||
id: string;
|
||||
type: TerminalType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type TaskRightPanelProps = {
|
||||
topPanel: ReactNode;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_RIGHT_LAYOUT: Layout = { top: 55, bottom: 45 };
|
||||
const DEFAULT_RIGHT_LAYOUT: Record<string, number> = { top: 55, bottom: 45 };
|
||||
|
||||
const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null }: TaskRightPanelProps) {
|
||||
const [rightLayout, setRightLayout] = useState<Layout>(() =>
|
||||
getLocalStorage('task-layout-right', DEFAULT_RIGHT_LAYOUT)
|
||||
const rightPanelIds = ['top', 'bottom'];
|
||||
const rightLayoutKey = `task-layout-right-v2:${sessionId ?? 'default'}`;
|
||||
const { defaultLayout: rightLayout, onLayoutChanged: onRightLayoutChange } = useDefaultLayout({
|
||||
id: rightLayoutKey,
|
||||
panelIds: rightPanelIds,
|
||||
baseLayout: DEFAULT_RIGHT_LAYOUT,
|
||||
});
|
||||
const [terminals, setTerminals] = useState<Terminal[]>([
|
||||
{ id: 'shell-1', type: 'shell', label: 'Terminal' },
|
||||
]);
|
||||
const [isBottomCollapsed, setIsBottomCollapsed] = useState<boolean>(() =>
|
||||
getLocalStorage('task-right-panel-collapsed', false)
|
||||
);
|
||||
const [activeTerminalId, setActiveTerminalId] = useState(1);
|
||||
const [terminalIds, setTerminalIds] = useState([1]);
|
||||
const [isBottomCollapsed, setIsBottomCollapsed] = useState(false);
|
||||
const [isStoppingDev, setIsStoppingDev] = useState(false);
|
||||
const activeTab = useAppStore((state) =>
|
||||
sessionId ? state.rightPanel.activeTabBySessionId[sessionId] : undefined
|
||||
@@ -46,240 +61,202 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
|
||||
const previewOpen = useAppStore((state) =>
|
||||
sessionId ? state.previewPanel.openBySessionId[sessionId] ?? false : false
|
||||
);
|
||||
const showDevTab = Boolean(sessionId && previewOpen);
|
||||
|
||||
const addTerminal = () => {
|
||||
setTerminalIds((ids) => {
|
||||
const nextId = Math.max(0, ...ids) + 1;
|
||||
setActiveTerminalId(nextId);
|
||||
if (sessionId) {
|
||||
setRightPanelActiveTab(sessionId, `terminal-${nextId}`);
|
||||
}
|
||||
return [...ids, nextId];
|
||||
});
|
||||
};
|
||||
|
||||
const removeTerminal = (id: number) => {
|
||||
setTerminalIds((ids) => {
|
||||
const nextIds = ids.filter((terminalId) => terminalId !== id);
|
||||
if (activeTerminalId === id) {
|
||||
const fallback = nextIds[0] ?? 1;
|
||||
setActiveTerminalId(fallback);
|
||||
if (sessionId) {
|
||||
setRightPanelActiveTab(sessionId, `terminal-${fallback}`);
|
||||
}
|
||||
}
|
||||
return nextIds.length ? nextIds : [1];
|
||||
});
|
||||
};
|
||||
|
||||
const terminalTabValue =
|
||||
activeTab ?? (activeTerminalId === 0 ? 'commands' : `terminal-${activeTerminalId}`);
|
||||
|
||||
// Sync dev server terminal with preview state
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (activeTab && activeTab.startsWith('terminal-')) {
|
||||
const parsed = Number(activeTab.replace('terminal-', ''));
|
||||
if (!Number.isNaN(parsed) && parsed !== activeTerminalId) {
|
||||
setActiveTerminalId(parsed);
|
||||
}
|
||||
}
|
||||
}, [activeTab, activeTerminalId, sessionId]);
|
||||
|
||||
setTerminals((prev) => {
|
||||
const hasDevTerminal = prev.some((t) => t.type === 'dev-server');
|
||||
|
||||
if (previewOpen && !hasDevTerminal) {
|
||||
// Add dev server terminal at the beginning
|
||||
return [{ id: 'dev-server', type: 'dev-server', label: 'Dev Server' }, ...prev];
|
||||
}
|
||||
|
||||
if (!previewOpen && hasDevTerminal) {
|
||||
// Remove dev server terminal
|
||||
return prev.filter((t) => t.type !== 'dev-server');
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}, [previewOpen, sessionId]);
|
||||
|
||||
const addTerminal = useCallback(() => {
|
||||
setTerminals((prev) => {
|
||||
const shellTerminals = prev.filter((t) => t.type === 'shell');
|
||||
const nextNum = shellTerminals.length + 1;
|
||||
const newTerminal: Terminal = {
|
||||
id: `shell-${nextNum}`,
|
||||
type: 'shell',
|
||||
label: nextNum === 1 ? 'Terminal' : `Terminal ${nextNum}`,
|
||||
};
|
||||
if (sessionId) {
|
||||
setRightPanelActiveTab(sessionId, newTerminal.id);
|
||||
}
|
||||
return [...prev, newTerminal];
|
||||
});
|
||||
}, [sessionId, setRightPanelActiveTab]);
|
||||
|
||||
const removeTerminal = useCallback(
|
||||
(id: string) => {
|
||||
setTerminals((prev) => {
|
||||
const shellTerminals = prev.filter((t) => t.type === 'shell');
|
||||
if (shellTerminals.length <= 1) {
|
||||
return prev; // Keep at least one shell terminal
|
||||
}
|
||||
|
||||
const nextTerminals = prev.filter((t) => t.id !== id);
|
||||
|
||||
// If removing active tab, switch to first available shell terminal
|
||||
if (activeTab === id) {
|
||||
const fallbackShell = nextTerminals.find((t) => t.type === 'shell');
|
||||
if (fallbackShell && sessionId) {
|
||||
setRightPanelActiveTab(sessionId, fallbackShell.id);
|
||||
}
|
||||
}
|
||||
|
||||
return nextTerminals;
|
||||
});
|
||||
},
|
||||
[activeTab, sessionId, setRightPanelActiveTab]
|
||||
);
|
||||
|
||||
const terminalTabValue = activeTab ?? terminals.find((t) => t.type === 'shell')?.id ?? 'commands';
|
||||
|
||||
// Validate active tab exists in terminals
|
||||
useEffect(() => {
|
||||
if (!sessionId || !activeTab) return;
|
||||
if (activeTab.startsWith('terminal-')) {
|
||||
const parsed = Number(activeTab.replace('terminal-', ''));
|
||||
if (Number.isNaN(parsed) || !terminalIds.includes(parsed)) {
|
||||
const fallback = terminalIds[0] ?? 1;
|
||||
setActiveTerminalId(fallback);
|
||||
setRightPanelActiveTab(sessionId, `terminal-${fallback}`);
|
||||
|
||||
const tabExists =
|
||||
activeTab === 'commands' ||
|
||||
terminals.some((t) => t.id === activeTab);
|
||||
|
||||
if (!tabExists) {
|
||||
const fallbackShell = terminals.find((t) => t.type === 'shell');
|
||||
if (fallbackShell) {
|
||||
setRightPanelActiveTab(sessionId, fallbackShell.id);
|
||||
}
|
||||
}
|
||||
}, [activeTab, sessionId, terminalIds, setRightPanelActiveTab]);
|
||||
}, [activeTab, sessionId, terminals, setRightPanelActiveTab]);
|
||||
|
||||
// Save collapse state to local storage
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!showDevTab && terminalTabValue === 'dev-server') {
|
||||
const fallback = `terminal-${terminalIds[0] ?? 1}`;
|
||||
setRightPanelActiveTab(sessionId, fallback);
|
||||
}
|
||||
}, [showDevTab, sessionId, terminalTabValue, terminalIds, setRightPanelActiveTab]);
|
||||
setLocalStorage('task-right-panel-collapsed', isBottomCollapsed);
|
||||
}, [isBottomCollapsed]);
|
||||
|
||||
const handleCloseDevTab = async (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!sessionId) return;
|
||||
if (devProcessId) {
|
||||
setIsStoppingDev(true);
|
||||
try {
|
||||
await stopProcess(sessionId, { process_id: devProcessId });
|
||||
} finally {
|
||||
setIsStoppingDev(false);
|
||||
const handleCloseDevTab = useCallback(
|
||||
async (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!sessionId) return;
|
||||
if (devProcessId) {
|
||||
setIsStoppingDev(true);
|
||||
try {
|
||||
await stopProcess(sessionId, { process_id: devProcessId });
|
||||
} finally {
|
||||
setIsStoppingDev(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
setRightPanelActiveTab(sessionId, `terminal-${terminalIds[0] ?? 1}`);
|
||||
setPreviewOpen(sessionId, false);
|
||||
setPreviewStage(sessionId, 'closed');
|
||||
closeLayoutPreview(sessionId);
|
||||
};
|
||||
const fallbackShell = terminals.find((t) => t.type === 'shell');
|
||||
if (fallbackShell) {
|
||||
setRightPanelActiveTab(sessionId, fallbackShell.id);
|
||||
}
|
||||
setPreviewOpen(sessionId, false);
|
||||
setPreviewStage(sessionId, 'closed');
|
||||
closeLayoutPreview(sessionId);
|
||||
},
|
||||
[sessionId, devProcessId, terminals, setRightPanelActiveTab, setPreviewOpen, setPreviewStage, closeLayoutPreview]
|
||||
);
|
||||
|
||||
const tabs: SessionTab[] = useMemo(() => {
|
||||
const commandsTab: SessionTab = { id: 'commands', label: 'Commands' };
|
||||
|
||||
const terminalTabs: SessionTab[] = terminals.map((terminal) => {
|
||||
const shellCount = terminals.filter((t) => t.type === 'shell').length;
|
||||
return {
|
||||
id: terminal.id,
|
||||
label: terminal.label,
|
||||
className:
|
||||
terminal.type === 'dev-server' || (terminal.type === 'shell' && shellCount > 1)
|
||||
? 'group flex items-center gap-1 pr-1 cursor-pointer'
|
||||
: 'cursor-pointer',
|
||||
closable:
|
||||
terminal.type === 'dev-server' || (terminal.type === 'shell' && shellCount > 1),
|
||||
onClose:
|
||||
terminal.type === 'dev-server'
|
||||
? handleCloseDevTab
|
||||
: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeTerminal(terminal.id);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [commandsTab, ...terminalTabs];
|
||||
}, [terminals, handleCloseDevTab, removeTerminal]);
|
||||
|
||||
if (isBottomCollapsed) {
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col gap-1">
|
||||
<div className="flex-1 min-h-0">{topPanel}</div>
|
||||
<div className="h-12 border border-border/70 rounded-lg bg-card flex items-center justify-between px-3 border-l-0 mt-[2px]">
|
||||
<Tabs
|
||||
value={terminalTabValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'commands') {
|
||||
setActiveTerminalId(0);
|
||||
if (sessionId) setRightPanelActiveTab(sessionId, value);
|
||||
return;
|
||||
}
|
||||
if (value === 'dev-server') {
|
||||
if (sessionId) setRightPanelActiveTab(sessionId, value);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(value.replace('terminal-', ''));
|
||||
if (!Number.isNaN(parsed)) {
|
||||
setActiveTerminalId(parsed);
|
||||
if (sessionId) setRightPanelActiveTab(sessionId, value);
|
||||
<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"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="commands" className="cursor-pointer">
|
||||
Commands
|
||||
</TabsTrigger>
|
||||
{showDevTab ? (
|
||||
<TabsTrigger value="dev-server" className="group flex items-center gap-1 pr-1 cursor-pointer">
|
||||
Dev Server
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className="text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
|
||||
onClick={handleCloseDevTab}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
) : null}
|
||||
{terminalIds.map((id) => (
|
||||
<TabsTrigger key={id} value={`terminal-${id}`} className="cursor-pointer">
|
||||
{id === 1 ? 'Terminal' : `Terminal ${id}`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsTrigger value="add" onClick={addTerminal} className="cursor-pointer">
|
||||
+
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => setIsBottomCollapsed(false)}
|
||||
>
|
||||
<IconChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
</SessionPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
<Group
|
||||
orientation="vertical"
|
||||
className="h-full"
|
||||
id="task-right-panel"
|
||||
className="h-full min-h-0"
|
||||
id={rightLayoutKey}
|
||||
key={rightLayoutKey}
|
||||
defaultLayout={rightLayout}
|
||||
onLayoutChanged={(sizes) => {
|
||||
setRightLayout(sizes);
|
||||
setLocalStorage('task-layout-right', sizes);
|
||||
}}
|
||||
onLayoutChanged={onRightLayoutChange}
|
||||
>
|
||||
<ResizablePanel id="top" defaultSize={rightLayout.top} minSize={30}>
|
||||
<Panel id="top" minSize={30} className="min-h-0">
|
||||
{topPanel}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="h-px" />
|
||||
<ResizablePanel id="bottom" defaultSize={rightLayout.bottom} minSize={20}>
|
||||
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-l-0 mt-[5px]">
|
||||
<Tabs
|
||||
value={terminalTabValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'commands') {
|
||||
setActiveTerminalId(0);
|
||||
if (sessionId) setRightPanelActiveTab(sessionId, value);
|
||||
return;
|
||||
}
|
||||
if (value === 'dev-server') {
|
||||
if (sessionId) setRightPanelActiveTab(sessionId, value);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(value.replace('terminal-', ''));
|
||||
if (!Number.isNaN(parsed)) {
|
||||
setActiveTerminalId(parsed);
|
||||
if (sessionId) setRightPanelActiveTab(sessionId, value);
|
||||
</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"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<TabsList>
|
||||
<TabsTrigger value="commands" className="cursor-pointer">
|
||||
Commands
|
||||
</TabsTrigger>
|
||||
{showDevTab ? (
|
||||
<TabsTrigger value="dev-server" className="group flex items-center gap-1 pr-1 cursor-pointer">
|
||||
Dev Server
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className="text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
|
||||
onClick={handleCloseDevTab}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
) : null}
|
||||
{terminalIds.map((id) => (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={`terminal-${id}`}
|
||||
className="group flex items-center gap-1 pr-1 cursor-pointer"
|
||||
>
|
||||
{id === 1 ? 'Terminal' : `Terminal ${id}`}
|
||||
{terminalIds.length > 1 && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className="text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
removeTerminal(id);
|
||||
}}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsTrigger value="add" onClick={addTerminal} className="cursor-pointer">
|
||||
+
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => setIsBottomCollapsed(true)}
|
||||
>
|
||||
<IconChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<TabsContent value="commands" className="flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 space-y-3 h-full">
|
||||
<SessionPanelContent>
|
||||
<div className="grid gap-2">
|
||||
{COMMANDS.map((command) => (
|
||||
<button
|
||||
@@ -292,30 +269,27 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SessionPanelContent>
|
||||
</TabsContent>
|
||||
{showDevTab ? (
|
||||
<TabsContent value="dev-server" className="flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 h-full p-1">
|
||||
<ProcessOutputTerminal
|
||||
output={devOutput}
|
||||
processId={devProcessId ?? null}
|
||||
isStopping={isStoppingDev}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
{terminalIds.map((id) => (
|
||||
<TabsContent key={id} value={`terminal-${id}`} className="flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 h-full p-1">
|
||||
<ShellTerminal />
|
||||
</div>
|
||||
{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
|
||||
processOutput={devOutput}
|
||||
processId={devProcessId ?? null}
|
||||
isStopping={isStoppingDev}
|
||||
/>
|
||||
) : (
|
||||
<ShellTerminal sessionId={sessionId ?? undefined} />
|
||||
)}
|
||||
</SessionPanelContent>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</SessionTabs>
|
||||
</SessionPanel>
|
||||
</Panel>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TaskState, Workspace, Repository } from '@/lib/types/http';
|
||||
import type { KanbanState } from '@/lib/state/slices';
|
||||
import { TaskSwitcher } from './task-switcher';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { SessionPanel } from '@kandev/ui/pannel-session';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { TaskCreateDialog } from '@/components/task-create-dialog';
|
||||
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
@@ -104,7 +105,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full min-h-0 min-w-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-r-0 mr-[5px]">
|
||||
<SessionPanel borderSide="right" margin="right" className="min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium truncate text-muted-foreground">{workspaceName || 'Workspace'}</span>
|
||||
<Button
|
||||
@@ -128,7 +129,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SessionPanel>
|
||||
<TaskCreateDialog
|
||||
open={taskDialogOpen}
|
||||
onOpenChange={setTaskDialogOpen}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EditorView } from '@codemirror/view';
|
||||
export const chatEditorTheme = EditorView.theme({
|
||||
'&': {
|
||||
color: 'var(--foreground)',
|
||||
fontSize: '0.875rem',
|
||||
fontSize: '0.8125rem', // 13px
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-editor': {
|
||||
@@ -38,21 +38,27 @@ export const chatEditorTheme = EditorView.theme({
|
||||
opacity: '0.8',
|
||||
},
|
||||
'.cm-tooltip-autocomplete, .cm-tooltip-autocomplete li, .cm-completionDetail': {
|
||||
fontSize: '0.875rem',
|
||||
fontSize: '0.8125rem', // 13px
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: 'var(--foreground)',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'var(--primary)',
|
||||
opacity: '0.7',
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
'.cm-selectionMatch': {
|
||||
backgroundColor: 'var(--primary)',
|
||||
opacity: '0.6',
|
||||
backgroundColor: '#818cf8 !important',
|
||||
},
|
||||
'.cm-selectionLayer .cm-selectionBackground': {
|
||||
backgroundColor: 'var(--primary)',
|
||||
opacity: '0.7',
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground': {
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
'.cm-line ::selection': {
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
'.cm-content ::selection': {
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user