feat: improve chat ui paddings (#83)

This commit is contained in:
Carlos Florêncio
2026-01-23 21:21:36 +00:00
committed by GitHub
parent 5e699019ef
commit 1737cab44f
17 changed files with 712 additions and 607 deletions
+1
View File
@@ -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';
+60
View File
@@ -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>
);
}
);
+4 -4
View File
@@ -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 {
+141
View File
@@ -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>
);
}
+94 -17
View File
@@ -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"
/>
);
}
+44 -67
View File
@@ -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>
);
});
+1 -1
View File
@@ -93,7 +93,7 @@ export function TaskChatInput({
highlightSelectionMatches: true,
autocompletion: false,
}}
className="text-sm"
className="text-xs"
/>
</div>
);
+1 -1
View File
@@ -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}
+28 -18
View File
@@ -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>
);
});
+8 -11
View File
@@ -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>
</>
+207 -233
View File
@@ -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}
+14 -8
View File
@@ -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',
},
});