mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-27 06:58:44 +02:00
refactor: move git panel to tabbed bottom panel alongside terminal
Replace the narrow 256px sidebar git section with a shared tabbed bottom panel that hosts both Terminal and Git as peer tabs. This gives git operations full horizontal width for file paths, diff previews, branch lists, and commit history. - Create BottomPanel with shared resize handle, tab bar, and content switching (terminal stays mounted via CSS visibility when inactive) - Simplify TerminalPanel by extracting resize/close to BottomPanel - Add InlineGitPill toggle in composer footer next to Terminal pill - Wire tab switching: clicking active tab closes panel, clicking inactive tab switches to it - Remove GitPanel from ActivityPanel sidebar - Update ARCHITECTURE.md to describe tabbed bottom panel layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+1
-1
@@ -302,7 +302,7 @@ This lets the application treat tooling as reusable workspace capability while s
|
||||
|
||||
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
|
||||
|
||||
For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the run timeline after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the activity panel and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. All git write operations flow through IPC to the main process; the renderer never runs git commands directly.
|
||||
For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the run timeline after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the tabbed bottom panel (alongside the terminal) and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. The bottom panel uses a shared resize handle and tab bar so the terminal and git views coexist without competing for screen real estate. All git write operations flow through IPC to the main process; the renderer never runs git commands directly.
|
||||
|
||||
### Execution observability
|
||||
|
||||
|
||||
+59
-23
@@ -13,7 +13,9 @@ import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
|
||||
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
||||
import { SettingsPanel, type SettingsSection } from '@renderer/components/SettingsPanel';
|
||||
import { Sidebar } from '@renderer/components/Sidebar';
|
||||
import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel';
|
||||
import { BottomPanel, DEFAULT_HEIGHT as DEFAULT_BOTTOM_HEIGHT, MIN_HEIGHT as MIN_BOTTOM_HEIGHT, type BottomPanelTab } from '@renderer/components/BottomPanel';
|
||||
import { GitPanel } from '@renderer/components/GitPanel';
|
||||
import { TerminalPanel } from '@renderer/components/TerminalPanel';
|
||||
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
|
||||
import {
|
||||
applySessionEventActivity,
|
||||
@@ -129,12 +131,14 @@ export default function App() {
|
||||
// Commit composer state
|
||||
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
||||
|
||||
// Terminal state
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalHeight, setTerminalHeight] = useState(
|
||||
() => workspace?.settings.terminalHeight ?? DEFAULT_TERMINAL_HEIGHT,
|
||||
// Bottom panel state (terminal + git)
|
||||
const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
|
||||
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
|
||||
const [bottomPanelHeight, setBottomPanelHeight] = useState(
|
||||
() => workspace?.settings.terminalHeight ?? DEFAULT_BOTTOM_HEIGHT,
|
||||
);
|
||||
const [terminalRunning, setTerminalRunning] = useState(false);
|
||||
const [gitDirty, setGitDirty] = useState(false);
|
||||
|
||||
// Load workspace on mount
|
||||
useEffect(() => {
|
||||
@@ -315,7 +319,7 @@ export default function App() {
|
||||
// ── Ctrl+` — Toggle terminal ──
|
||||
if (e.ctrlKey && e.key === '`') {
|
||||
e.preventDefault();
|
||||
setTerminalOpen((prev) => !prev);
|
||||
handleTerminalToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -462,26 +466,38 @@ export default function App() {
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Sync terminalHeight from workspace settings when workspace loads
|
||||
// Sync bottom panel height from workspace settings when workspace loads
|
||||
useEffect(() => {
|
||||
if (workspace?.settings.terminalHeight) {
|
||||
setTerminalHeight(workspace.settings.terminalHeight);
|
||||
setBottomPanelHeight(workspace.settings.terminalHeight);
|
||||
}
|
||||
}, [workspace?.settings.terminalHeight]);
|
||||
|
||||
const handleTerminalHeightChange = useCallback((newHeight: number) => {
|
||||
const clamped = Math.max(MIN_TERMINAL_HEIGHT, Math.round(newHeight));
|
||||
setTerminalHeight(clamped);
|
||||
const handleBottomPanelHeightChange = useCallback((newHeight: number) => {
|
||||
const clamped = Math.max(MIN_BOTTOM_HEIGHT, Math.round(newHeight));
|
||||
setBottomPanelHeight(clamped);
|
||||
void api.setTerminalHeight({ height: clamped });
|
||||
}, [api]);
|
||||
|
||||
const handleTerminalClose = useCallback(() => {
|
||||
setTerminalOpen(false);
|
||||
const handleBottomPanelClose = useCallback(() => {
|
||||
setBottomPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleTerminalToggle = useCallback(() => {
|
||||
setTerminalOpen((prev) => !prev);
|
||||
}, []);
|
||||
setBottomPanelOpen((prev) => {
|
||||
if (prev && bottomPanelTab === 'terminal') return false;
|
||||
return true;
|
||||
});
|
||||
setBottomPanelTab('terminal');
|
||||
}, [bottomPanelTab]);
|
||||
|
||||
const handleGitToggle = useCallback(() => {
|
||||
setBottomPanelOpen((prev) => {
|
||||
if (prev && bottomPanelTab === 'git') return false;
|
||||
return true;
|
||||
});
|
||||
setBottomPanelTab('git');
|
||||
}, [bottomPanelTab]);
|
||||
|
||||
const jumpToMessage = useCallback((messageId: string) => {
|
||||
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
|
||||
@@ -637,14 +653,17 @@ export default function App() {
|
||||
availableModels={availableModels}
|
||||
mcpProbingServerIds={workspace.mcpProbingServerIds}
|
||||
onTerminalToggle={handleTerminalToggle}
|
||||
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
|
||||
pattern={patternForSession}
|
||||
project={projectForSession}
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
session={selectedSession}
|
||||
sessionUsage={usageForSession}
|
||||
activeSubagents={subagentsForSession}
|
||||
terminalOpen={terminalOpen}
|
||||
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
|
||||
terminalRunning={terminalRunning}
|
||||
gitPanelOpen={bottomPanelOpen && bottomPanelTab === 'git'}
|
||||
gitDirty={gitDirty}
|
||||
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
||||
/>
|
||||
);
|
||||
@@ -740,13 +759,30 @@ export default function App() {
|
||||
content={content}
|
||||
detailPanel={detailPanel}
|
||||
overlay={overlay}
|
||||
terminalPanel={
|
||||
terminalOpen ? (
|
||||
<TerminalPanel
|
||||
height={terminalHeight}
|
||||
onHeightChange={handleTerminalHeightChange}
|
||||
onClose={handleTerminalClose}
|
||||
onMinimize={handleTerminalClose}
|
||||
bottomPanel={
|
||||
bottomPanelOpen ? (
|
||||
<BottomPanel
|
||||
activeTab={bottomPanelTab}
|
||||
gitContent={
|
||||
selectedSession && !isScratchpadProject(selectedSession.projectId) ? (
|
||||
<GitPanel
|
||||
onDirtyChange={setGitDirty}
|
||||
projectId={selectedSession.projectId}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-[11px] text-[var(--color-text-muted)]">
|
||||
Git is not available for scratchpad sessions
|
||||
</div>
|
||||
)
|
||||
}
|
||||
gitDirty={gitDirty}
|
||||
height={bottomPanelHeight}
|
||||
onClose={handleBottomPanelClose}
|
||||
onHeightChange={handleBottomPanelHeightChange}
|
||||
onTabChange={setBottomPanelTab}
|
||||
showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)}
|
||||
terminalContent={<TerminalPanel onRunningChange={setTerminalRunning} />}
|
||||
terminalRunning={terminalRunning}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, GitBranch as GitBranchIcon, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildAgentActivityRows,
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
type TurnEventLog,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { RunTimeline } from '@renderer/components/RunTimeline';
|
||||
import { GitPanel } from '@renderer/components/GitPanel';
|
||||
import { inferProvider } from '@shared/domain/models';
|
||||
import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject } from '@shared/domain/project';
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { ProviderIcon } from './ProviderIcons';
|
||||
@@ -395,18 +393,6 @@ export function ActivityPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Git panel section ────────────────────────────── */}
|
||||
{!isScratchpadProject(session.projectId) && (
|
||||
<div className="mb-4">
|
||||
<SectionHeader>
|
||||
<GitBranchIcon className="size-3" />
|
||||
<span>Git</span>
|
||||
</SectionHeader>
|
||||
|
||||
<GitPanel projectId={session.projectId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,11 @@ interface AppShellProps {
|
||||
sidebar: ReactNode;
|
||||
content: ReactNode;
|
||||
detailPanel?: ReactNode;
|
||||
terminalPanel?: ReactNode;
|
||||
bottomPanel?: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay }: AppShellProps) {
|
||||
export function AppShell({ sidebar, content, detailPanel, bottomPanel, overlay }: AppShellProps) {
|
||||
return (
|
||||
<div className="relative flex h-screen bg-[var(--color-surface-0)] text-[var(--color-text-primary)]">
|
||||
{/* Full-width drag region matching the title bar overlay height */}
|
||||
@@ -19,7 +19,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
|
||||
{sidebar}
|
||||
</aside>
|
||||
|
||||
{/* Main content + terminal */}
|
||||
{/* Main content + bottom panel */}
|
||||
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||
{/* Ambient glow behind active content area */}
|
||||
<div
|
||||
@@ -27,7 +27,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
|
||||
style={{ background: 'var(--gradient-glow)' }}
|
||||
/>
|
||||
<div className="relative min-h-0 flex-1">{content}</div>
|
||||
{terminalPanel}
|
||||
{bottomPanel}
|
||||
</main>
|
||||
|
||||
{/* Detail panel */}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useRef, useState, type ReactNode } from 'react';
|
||||
import { GitBranch, Minus, TerminalSquare, X } from 'lucide-react';
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────── */
|
||||
|
||||
const MIN_HEIGHT = 120;
|
||||
const MAX_HEIGHT_FRACTION = 0.7;
|
||||
const DEFAULT_HEIGHT = 280;
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────── */
|
||||
|
||||
export type BottomPanelTab = 'terminal' | 'git';
|
||||
|
||||
/* ── BottomPanel ──────────────────────────────────────────── */
|
||||
|
||||
interface BottomPanelProps {
|
||||
activeTab: BottomPanelTab;
|
||||
onTabChange: (tab: BottomPanelTab) => void;
|
||||
onClose: () => void;
|
||||
height: number;
|
||||
onHeightChange: (height: number) => void;
|
||||
terminalContent: ReactNode;
|
||||
gitContent: ReactNode;
|
||||
showGitTab: boolean;
|
||||
terminalRunning?: boolean;
|
||||
gitDirty?: boolean;
|
||||
}
|
||||
|
||||
export function BottomPanel({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onClose,
|
||||
height,
|
||||
onHeightChange,
|
||||
terminalContent,
|
||||
gitContent,
|
||||
showGitTab,
|
||||
terminalRunning,
|
||||
gitDirty,
|
||||
}: BottomPanelProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartRef.current = { y: e.clientY, height };
|
||||
setIsDragging(true);
|
||||
|
||||
const handleDragMove = (moveEvent: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
|
||||
const delta = dragStartRef.current.y - moveEvent.clientY;
|
||||
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
|
||||
onHeightChange(nextHeight);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
document.removeEventListener('mousemove', handleDragMove);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleDragMove);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
}, [height, onHeightChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
|
||||
style={{ height, minHeight: MIN_HEIGHT }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
|
||||
onMouseDown={handleDragStart}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize panel"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex h-8 shrink-0 items-center border-b border-[var(--color-border)] px-1">
|
||||
{/* Terminal tab */}
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
|
||||
activeTab === 'terminal'
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => onTabChange('terminal')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'terminal'}
|
||||
>
|
||||
{terminalRunning && <span className="size-1.5 shrink-0 rounded-full bg-emerald-400" />}
|
||||
<TerminalSquare className="size-3" />
|
||||
Terminal
|
||||
</button>
|
||||
|
||||
{/* Git tab */}
|
||||
{showGitTab && (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
|
||||
activeTab === 'git'
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => onTabChange('git')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'git'}
|
||||
>
|
||||
{gitDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
|
||||
<GitBranch className="size-3" />
|
||||
Git
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Minimize */}
|
||||
<button
|
||||
aria-label="Minimize panel"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
aria-label="Close panel"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content — both always mounted, only active one visible */}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div className={`absolute inset-0 flex flex-col ${activeTab === 'terminal' ? '' : 'invisible'}`}>
|
||||
{terminalContent}
|
||||
</div>
|
||||
{showGitTab && (
|
||||
<div className={`absolute inset-0 flex flex-col overflow-y-auto ${activeTab === 'git' ? '' : 'hidden'}`}>
|
||||
{gitContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DEFAULT_HEIGHT, MIN_HEIGHT };
|
||||
@@ -9,7 +9,7 @@ import { MessageEditComposer } from '@renderer/components/chat/MessageEditCompos
|
||||
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
|
||||
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
||||
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
||||
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
||||
import { InlineApprovalPill, InlineGitPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
||||
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
|
||||
@@ -53,6 +53,8 @@ interface ChatPaneProps {
|
||||
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
||||
terminalOpen?: boolean;
|
||||
terminalRunning?: boolean;
|
||||
gitPanelOpen?: boolean;
|
||||
gitDirty?: boolean;
|
||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
||||
onCancelTurn?: () => void;
|
||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
|
||||
@@ -62,6 +64,7 @@ interface ChatPaneProps {
|
||||
onDismissMcpAuth?: () => void;
|
||||
onAuthenticateMcp?: () => void;
|
||||
onTerminalToggle?: () => void;
|
||||
onGitToggle?: () => void;
|
||||
onUpdateSessionModelConfig?: (config: {
|
||||
model: string;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
@@ -87,6 +90,8 @@ export function ChatPane({
|
||||
activeSubagents,
|
||||
terminalOpen,
|
||||
terminalRunning,
|
||||
gitPanelOpen,
|
||||
gitDirty,
|
||||
onSend,
|
||||
onCancelTurn,
|
||||
onResolveApproval,
|
||||
@@ -96,6 +101,7 @@ export function ChatPane({
|
||||
onDismissMcpAuth,
|
||||
onAuthenticateMcp,
|
||||
onTerminalToggle,
|
||||
onGitToggle,
|
||||
onUpdateSessionModelConfig,
|
||||
onUpdateSessionTooling,
|
||||
onUpdateSessionApprovalSettings,
|
||||
@@ -751,6 +757,13 @@ export function ChatPane({
|
||||
onToggle={onTerminalToggle}
|
||||
/>
|
||||
)}
|
||||
{onGitToggle && !isScratchpad && (
|
||||
<InlineGitPill
|
||||
isDirty={!!gitDirty}
|
||||
isOpen={!!gitPanelOpen}
|
||||
onToggle={onGitToggle}
|
||||
/>
|
||||
)}
|
||||
{!isScratchpad && promptFiles.length > 0 && (
|
||||
<InlinePromptPill
|
||||
disabled={isComposerDisabled}
|
||||
|
||||
@@ -310,9 +310,10 @@ function CreateBranchForm({
|
||||
|
||||
interface GitPanelProps {
|
||||
projectId: string;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function GitPanel({ projectId }: GitPanelProps) {
|
||||
export function GitPanel({ projectId, onDirtyChange }: GitPanelProps) {
|
||||
const api = getElectronApi();
|
||||
|
||||
const [details, setDetails] = useState<ProjectGitDetails>();
|
||||
@@ -334,6 +335,7 @@ export function GitPanel({ projectId }: GitPanelProps) {
|
||||
const result = await api.getProjectGitDetails({ projectId });
|
||||
setDetails(result);
|
||||
setOperationError(undefined);
|
||||
onDirtyChange?.(result.context.isDirty ?? false);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RotateCcw, Minus, X } from 'lucide-react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
@@ -44,17 +44,11 @@ const DEFAULT_HEIGHT = 280;
|
||||
/* ── TerminalPanel ────────────────────────────────────────── */
|
||||
|
||||
interface TerminalPanelProps {
|
||||
height: number;
|
||||
onHeightChange: (height: number) => void;
|
||||
onClose: () => void;
|
||||
onMinimize: () => void;
|
||||
onRunningChange?: (running: boolean) => void;
|
||||
}
|
||||
|
||||
export function TerminalPanel({
|
||||
height,
|
||||
onHeightChange,
|
||||
onClose,
|
||||
onMinimize,
|
||||
onRunningChange,
|
||||
}: TerminalPanelProps) {
|
||||
const api = getElectronApi();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -62,8 +56,6 @@ export function TerminalPanel({
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<TerminalSnapshot>();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
|
||||
// Create or recover terminal on mount
|
||||
useEffect(() => {
|
||||
@@ -74,11 +66,13 @@ export function TerminalPanel({
|
||||
if (existing) {
|
||||
setSnapshot(existing);
|
||||
setIsRunning(true);
|
||||
onRunningChange?.(true);
|
||||
} else {
|
||||
void api.createTerminal().then((created) => {
|
||||
if (disposed) return;
|
||||
setSnapshot(created);
|
||||
setIsRunning(true);
|
||||
onRunningChange?.(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -135,6 +129,7 @@ export function TerminalPanel({
|
||||
});
|
||||
const offExit = api.onTerminalExit((_info: TerminalExitInfo) => {
|
||||
setIsRunning(false);
|
||||
onRunningChange?.(false);
|
||||
terminalRef.current?.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
|
||||
});
|
||||
|
||||
@@ -144,19 +139,7 @@ export function TerminalPanel({
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Refit on height changes
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current || !terminalRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
const terminal = terminalRef.current;
|
||||
if (terminal) {
|
||||
api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows });
|
||||
}
|
||||
});
|
||||
}, [height, api]);
|
||||
|
||||
// ResizeObserver for container width changes
|
||||
// ResizeObserver for container size changes (width or height from parent)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
@@ -174,100 +157,31 @@ export function TerminalPanel({
|
||||
return () => observer.disconnect();
|
||||
}, [api]);
|
||||
|
||||
// Drag-to-resize
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartRef.current = { y: e.clientY, height };
|
||||
setIsDragging(true);
|
||||
|
||||
const handleDragMove = (moveEvent: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
|
||||
const delta = dragStartRef.current.y - moveEvent.clientY;
|
||||
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
|
||||
onHeightChange(nextHeight);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
document.removeEventListener('mousemove', handleDragMove);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleDragMove);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
}, [height, onHeightChange]);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
void api.restartTerminal().then((restarted) => {
|
||||
setSnapshot(restarted);
|
||||
setIsRunning(true);
|
||||
onRunningChange?.(true);
|
||||
terminalRef.current?.clear();
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void api.killTerminal();
|
||||
onClose();
|
||||
}, [api, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
|
||||
style={{ height, minHeight: MIN_HEIGHT }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
|
||||
onMouseDown={handleDragStart}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize terminal"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Header bar */}
|
||||
<div className="flex h-7 shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3">
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${isRunning ? 'bg-emerald-400' : 'bg-[var(--color-text-muted)]'}`} />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-[var(--color-text-muted)]">
|
||||
{snapshot ? `${snapshot.shell} — ${snapshot.cwd}` : 'Terminal'}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
aria-label="Restart terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={handleRestart}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Minimize terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onMinimize}
|
||||
type="button"
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Close terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Restart terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={handleRestart}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal body */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, GitBranch, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
||||
|
||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||
import { PopoverToggleRow } from '@renderer/components/ui';
|
||||
@@ -757,3 +757,32 @@ export function InlineTerminalPill({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── InlineGitPill ─────────────────────────────────────────── */
|
||||
|
||||
export function InlineGitPill({
|
||||
isDirty,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
isDirty: boolean;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isOpen}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium transition-all duration-200 ${
|
||||
isOpen
|
||||
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)] hover:bg-[var(--color-accent)]/20'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{isDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
|
||||
<GitBranch className="size-3" />
|
||||
<span>Git</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user