mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 13:38:43 +02:00
1033 lines
39 KiB
TypeScript
1033 lines
39 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { CommitComposer } from '@renderer/components/chat/CommitComposer';
|
|
import { AppShell } from '@renderer/components/AppShell';
|
|
import { ActivityPanel } from '@renderer/components/ActivityPanel';
|
|
import { ChatPane } from '@renderer/components/ChatPane';
|
|
import { CommandPalette } from '@renderer/components/CommandPalette';
|
|
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
|
|
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
|
|
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
|
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 { 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,
|
|
applySessionUsageEvent,
|
|
applyAssistantUsageEvent,
|
|
applyTurnEventLog,
|
|
pruneSessionActivities,
|
|
pruneSessionUsage,
|
|
pruneSessionRequestUsage,
|
|
pruneTurnEventLogs,
|
|
purgeCompletedActivity,
|
|
type SessionActivityMap,
|
|
type SessionUsageMap,
|
|
type SessionRequestUsageMap,
|
|
type TurnEventLogMap,
|
|
} from '@renderer/lib/sessionActivity';
|
|
import { applySubagentEvent, pruneSubagentMap, type ActiveSubagentMap } from '@renderer/lib/subagentTracker';
|
|
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
|
|
import { WelcomePane } from '@renderer/components/WelcomePane';
|
|
import { getElectronApi } from '@renderer/lib/electronApi';
|
|
import { useTheme, useSidecarCapabilities } from '@renderer/hooks/useAppHooks';
|
|
import {
|
|
buildAvailableModelCatalog,
|
|
findModel,
|
|
normalizeWorkflowModels,
|
|
resolveReasoningEffort,
|
|
} from '@shared/domain/models';
|
|
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
|
import { type ReasoningEffort, type WorkflowDefinition } from '@shared/domain/workflow';
|
|
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
|
import type { ProjectGitFileReference } from '@shared/domain/project';
|
|
import { applySessionModelConfig } from '@shared/domain/session';
|
|
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
|
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
|
import type { UpdateStatus } from '@shared/contracts/ipc';
|
|
import { createId, nowIso } from '@shared/utils/ids';
|
|
import { WorkflowPicker } from '@renderer/components/workflow/WorkflowPicker';
|
|
|
|
function createDraftMcpServer(): McpServerDefinition {
|
|
const timestamp = nowIso();
|
|
return {
|
|
id: createId('mcp'),
|
|
name: 'New MCP Server',
|
|
transport: 'local',
|
|
command: '',
|
|
args: [],
|
|
tools: ['*'],
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
};
|
|
}
|
|
|
|
function createDraftLspProfile(): LspProfileDefinition {
|
|
const timestamp = nowIso();
|
|
return {
|
|
id: createId('lsp'),
|
|
name: 'New LSP Profile',
|
|
command: '',
|
|
args: ['--stdio'],
|
|
languageId: 'typescript',
|
|
fileExtensions: ['.ts', '.tsx'],
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
};
|
|
}
|
|
|
|
function createDraftWorkspaceAgent(defaultModelId: string): WorkspaceAgentDefinition {
|
|
const timestamp = nowIso();
|
|
return {
|
|
id: createId('agent'),
|
|
name: '',
|
|
description: '',
|
|
instructions: '',
|
|
model: defaultModelId,
|
|
reasoningEffort: 'high',
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
};
|
|
}
|
|
|
|
function createDraftWorkflow(defaultModelId: string, defaultReasoningEffort?: ReasoningEffort): WorkflowDefinition {
|
|
const timestamp = nowIso();
|
|
const startId = createId('wf-start');
|
|
const agentId = createId('wf-agent');
|
|
const endId = createId('wf-end');
|
|
return {
|
|
id: createId('workflow'),
|
|
name: 'New Workflow',
|
|
description: '',
|
|
graph: {
|
|
nodes: [
|
|
{ id: startId, kind: 'start', label: 'Start', position: { x: 0, y: 100 }, config: { kind: 'start' } },
|
|
{
|
|
id: agentId,
|
|
kind: 'agent',
|
|
label: 'Primary Agent',
|
|
position: { x: 250, y: 100 },
|
|
config: {
|
|
kind: 'agent',
|
|
id: createId('agent'),
|
|
name: 'Primary Agent',
|
|
description: 'General-purpose assistant.',
|
|
instructions: 'You are a helpful coding assistant working inside the selected project.',
|
|
model: defaultModelId,
|
|
reasoningEffort: defaultReasoningEffort,
|
|
},
|
|
},
|
|
{ id: endId, kind: 'end', label: 'End', position: { x: 500, y: 100 }, config: { kind: 'end' } },
|
|
],
|
|
edges: [
|
|
{ id: `edge-${startId}-to-${agentId}`, source: startId, target: agentId, kind: 'direct' },
|
|
{ id: `edge-${agentId}-to-${endId}`, source: agentId, target: endId, kind: 'direct' },
|
|
],
|
|
},
|
|
settings: {
|
|
checkpointing: { enabled: false },
|
|
executionMode: 'off-thread',
|
|
maxIterations: 5,
|
|
},
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
};
|
|
}
|
|
|
|
export default function App() {
|
|
const api = getElectronApi();
|
|
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
|
const [error, setError] = useState<string>();
|
|
const { capabilities: sidecarCapabilities, isRefreshing: isRefreshingCapabilities, refresh: refreshCapabilities } = useSidecarCapabilities(api);
|
|
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
|
|
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
|
|
const [sessionRequestUsage, setSessionRequestUsage] = useState<SessionRequestUsageMap>({});
|
|
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
|
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
|
const activityPurgeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
|
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
|
|
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
|
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
const [showSearch, setShowSearch] = useState(false);
|
|
const [showBookmarks, setShowBookmarks] = useState(false);
|
|
|
|
// Workflow picker state — holds the projectId we're creating a session for
|
|
const [workflowPickerProjectId, setWorkflowPickerProjectId] = useState<string | null>(null);
|
|
|
|
// Commit composer state
|
|
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
|
|
|
// 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(() => {
|
|
let disposed = false;
|
|
|
|
void api
|
|
.loadWorkspace()
|
|
.then((ws) => !disposed && setWorkspace(ws))
|
|
.catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e)));
|
|
|
|
const offWorkspace = api.onWorkspaceUpdated((ws) => {
|
|
setWorkspace(ws);
|
|
setError(undefined);
|
|
setSessionActivities((current) =>
|
|
pruneSessionActivities(
|
|
current,
|
|
ws.sessions.map((session) => session.id),
|
|
),
|
|
);
|
|
setSessionUsage((current) =>
|
|
pruneSessionUsage(
|
|
current,
|
|
ws.sessions.map((session) => session.id),
|
|
),
|
|
);
|
|
setSessionRequestUsage((current) =>
|
|
pruneSessionRequestUsage(
|
|
current,
|
|
ws.sessions.map((session) => session.id),
|
|
),
|
|
);
|
|
setTurnEventLogs((current) =>
|
|
pruneTurnEventLogs(
|
|
current,
|
|
ws.sessions.map((session) => session.id),
|
|
),
|
|
);
|
|
setActiveSubagents((current) =>
|
|
pruneSubagentMap(
|
|
current,
|
|
ws.sessions.map((session) => session.id),
|
|
),
|
|
);
|
|
});
|
|
|
|
const offSessionEvent = api.onSessionEvent((event) => {
|
|
setWorkspace((current) => applySessionEventWorkspace(current, event));
|
|
setSessionActivities((current) => applySessionEventActivity(current, event));
|
|
setSessionUsage((current) => applySessionUsageEvent(current, event));
|
|
setSessionRequestUsage((current) => applyAssistantUsageEvent(current, event));
|
|
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
|
setActiveSubagents((current) => applySubagentEvent(current, event));
|
|
|
|
// Schedule purge of completed activity labels after grace period
|
|
if (event.kind === 'status' && event.status === 'idle') {
|
|
const existing = activityPurgeTimers.current.get(event.sessionId);
|
|
if (existing) clearTimeout(existing);
|
|
activityPurgeTimers.current.set(
|
|
event.sessionId,
|
|
setTimeout(() => {
|
|
setSessionActivities((current) => purgeCompletedActivity(current, event.sessionId));
|
|
activityPurgeTimers.current.delete(event.sessionId);
|
|
}, 1500),
|
|
);
|
|
}
|
|
// Cancel pending purge if a new run starts
|
|
if (event.kind === 'status' && event.status === 'running') {
|
|
const existing = activityPurgeTimers.current.get(event.sessionId);
|
|
if (existing) {
|
|
clearTimeout(existing);
|
|
activityPurgeTimers.current.delete(event.sessionId);
|
|
}
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
offWorkspace();
|
|
offSessionEvent();
|
|
for (const timer of activityPurgeTimers.current.values()) clearTimeout(timer);
|
|
activityPurgeTimers.current.clear();
|
|
};
|
|
}, [api]);
|
|
|
|
// Subscribe to auto-update status pushes from the main process
|
|
useEffect(() => {
|
|
const off = api.onUpdateStatus((status) => setUpdateStatus(status));
|
|
return off;
|
|
}, [api]);
|
|
|
|
// Apply theme to the document root
|
|
const themeSetting: AppearanceTheme = workspace?.settings.theme ?? 'dark';
|
|
useTheme(themeSetting);
|
|
|
|
// Derived state
|
|
const selectedSession = useMemo(
|
|
() => workspace?.sessions.find((s) => s.id === workspace.selectedSessionId),
|
|
[workspace?.selectedSessionId, workspace?.sessions],
|
|
);
|
|
const projectForSession = useMemo(
|
|
() =>
|
|
selectedSession
|
|
? workspace?.projects.find((p) => p.id === selectedSession.projectId)
|
|
: undefined,
|
|
[selectedSession, workspace?.projects],
|
|
);
|
|
const availableModels = useMemo(
|
|
() => buildAvailableModelCatalog(sidecarCapabilities?.models),
|
|
[sidecarCapabilities?.models],
|
|
);
|
|
const workflowForSession = useMemo(() => {
|
|
if (!selectedSession) {
|
|
return undefined;
|
|
}
|
|
|
|
const baseWorkflow = workspace?.workflows.find((workflow) => workflow.id === selectedSession.workflowId);
|
|
if (!baseWorkflow) {
|
|
return undefined;
|
|
}
|
|
|
|
const workflowWithSessionConfig =
|
|
projectForSession && selectedSession.sessionModelConfig
|
|
? applySessionModelConfig(baseWorkflow, selectedSession)
|
|
: baseWorkflow;
|
|
|
|
return normalizeWorkflowModels(workflowWithSessionConfig, availableModels);
|
|
}, [availableModels, projectForSession, selectedSession, workspace?.workflows]);
|
|
const activityForSession = useMemo(
|
|
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
|
|
[selectedSession, sessionActivities],
|
|
);
|
|
const usageForSession = useMemo(
|
|
() => (selectedSession ? sessionUsage[selectedSession.id] : undefined),
|
|
[selectedSession, sessionUsage],
|
|
);
|
|
const requestUsageForSession = useMemo(
|
|
() => (selectedSession ? sessionRequestUsage[selectedSession.id] : undefined),
|
|
[selectedSession, sessionRequestUsage],
|
|
);
|
|
const subagentsForSession = useMemo(
|
|
() => (selectedSession ? activeSubagents[selectedSession.id] : undefined),
|
|
[selectedSession, activeSubagents],
|
|
);
|
|
const turnEventsForSession = useMemo(
|
|
() => (selectedSession ? turnEventLogs[selectedSession.id] : undefined),
|
|
[selectedSession, turnEventLogs],
|
|
);
|
|
const hasUserProjects = useMemo(
|
|
() => (workspace?.projects.some((project) => !isScratchpadProject(project)) ?? false),
|
|
[workspace?.projects],
|
|
);
|
|
|
|
// Show discovery modal when pending discovered MCPs exist
|
|
const selectedProject = useMemo(
|
|
() => workspace?.projects.find((p) => p.id === workspace.selectedProjectId),
|
|
[workspace?.projects, workspace?.selectedProjectId],
|
|
);
|
|
|
|
const chatToolingSettings = useMemo(
|
|
() => resolveChatToolingSettings(workspace, projectForSession),
|
|
[projectForSession, workspace],
|
|
);
|
|
|
|
const hasPendingDiscoveries = useMemo(() => {
|
|
if (!workspace) return false;
|
|
const pendingUser = listPendingDiscoveredMcpServers(workspace.settings.discoveredUserTooling);
|
|
const pendingProject = listPendingDiscoveredMcpServers(selectedProject?.discoveredTooling);
|
|
return pendingUser.length > 0 || pendingProject.length > 0;
|
|
}, [workspace?.settings.discoveredUserTooling, selectedProject?.discoveredTooling]);
|
|
|
|
useEffect(() => {
|
|
if (hasPendingDiscoveries) setShowDiscoveryModal(true);
|
|
}, [hasPendingDiscoveries]);
|
|
|
|
// Keep refs for values the keyboard handler reads — avoids re-registering on every render.
|
|
const workspaceRef = useRef(workspace);
|
|
workspaceRef.current = workspace;
|
|
const showSettingsRef = useRef(showSettings);
|
|
showSettingsRef.current = showSettings;
|
|
const showShortcutsRef = useRef(showShortcuts);
|
|
showShortcutsRef.current = showShortcuts;
|
|
const commandPaletteOpenRef = useRef(commandPaletteOpen);
|
|
commandPaletteOpenRef.current = commandPaletteOpen;
|
|
const projectSettingsIdRef = useRef(projectSettingsId);
|
|
projectSettingsIdRef.current = projectSettingsId;
|
|
|
|
// ── Global keyboard shortcuts ──
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const mod = e.ctrlKey || e.metaKey;
|
|
const ws = workspaceRef.current;
|
|
|
|
// Ignore keyboard shortcuts while typing in inputs (except our global combos)
|
|
const target = e.target as HTMLElement;
|
|
const isInput = target.tagName === 'INPUT'
|
|
|| target.tagName === 'TEXTAREA'
|
|
|| target.isContentEditable;
|
|
|
|
// ── Ctrl+` — Toggle terminal ──
|
|
if (e.ctrlKey && e.key === '`') {
|
|
e.preventDefault();
|
|
handleTerminalToggle();
|
|
return;
|
|
}
|
|
|
|
// ── Ctrl/Cmd+K — Command palette ──
|
|
if (mod && e.key === 'k') {
|
|
e.preventDefault();
|
|
setCommandPaletteOpen((prev) => !prev);
|
|
return;
|
|
}
|
|
|
|
// ── Ctrl/Cmd+/ — Keyboard shortcuts cheat sheet ──
|
|
if (mod && e.key === '/') {
|
|
e.preventDefault();
|
|
setShowShortcuts((prev) => !prev);
|
|
return;
|
|
}
|
|
|
|
// ── Ctrl/Cmd+Shift+F — Search sessions ──
|
|
if (mod && e.shiftKey && e.key === 'F') {
|
|
e.preventDefault();
|
|
setShowSearch((prev) => !prev);
|
|
return;
|
|
}
|
|
|
|
// ── Ctrl/Cmd+Shift+B — Bookmarks ──
|
|
if (mod && e.shiftKey && e.key === 'B') {
|
|
e.preventDefault();
|
|
setShowBookmarks((prev) => !prev);
|
|
return;
|
|
}
|
|
|
|
// ── Ctrl/Cmd+, — Open settings ──
|
|
if (mod && e.key === ',') {
|
|
e.preventDefault();
|
|
setShowSettings(true);
|
|
return;
|
|
}
|
|
|
|
// ── Escape — Close overlays or cancel running turn ──
|
|
if (e.key === 'Escape') {
|
|
// Close overlays in priority order (command palette and shortcuts use their own capture listeners)
|
|
if (projectSettingsIdRef.current) {
|
|
e.preventDefault();
|
|
setProjectSettingsId(undefined);
|
|
return;
|
|
}
|
|
if (showSettingsRef.current) {
|
|
e.preventDefault();
|
|
setShowSettings(false);
|
|
return;
|
|
}
|
|
|
|
// If nothing is open, cancel a running turn on the selected session
|
|
if (ws) {
|
|
const session = ws.sessions.find((s) => s.id === ws.selectedSessionId);
|
|
if (session?.status === 'running' && !isInput) {
|
|
e.preventDefault();
|
|
void api.cancelSessionTurn({ sessionId: session.id });
|
|
return;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Skip remaining shortcuts when focus is in an input field
|
|
if (isInput) return;
|
|
|
|
// ── Ctrl/Cmd+N — New session ──
|
|
if (mod && e.key === 'n') {
|
|
e.preventDefault();
|
|
if (ws) {
|
|
const defaultProjectId =
|
|
ws.selectedProjectId ??
|
|
ws.projects.find((p) => !isScratchpadProject(p))?.id;
|
|
if (defaultProjectId) {
|
|
if (ws.workflows.length <= 1) {
|
|
const wf = ws.workflows[0];
|
|
if (wf) void api.createSession({ projectId: defaultProjectId, workflowId: wf.id });
|
|
} else {
|
|
setWorkflowPickerProjectId(defaultProjectId);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Ctrl/Cmd+W — Archive / close current session ──
|
|
if (mod && e.key === 'w') {
|
|
e.preventDefault();
|
|
if (ws?.selectedSessionId) {
|
|
void api.setSessionArchived({ sessionId: ws.selectedSessionId, isArchived: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Ctrl+Tab / Ctrl+Shift+Tab — Cycle sessions ──
|
|
if (e.ctrlKey && e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (ws) {
|
|
const activeSessions = ws.sessions.filter((s) => !s.isArchived);
|
|
if (activeSessions.length > 1) {
|
|
const currentIdx = activeSessions.findIndex((s) => s.id === ws.selectedSessionId);
|
|
const direction = e.shiftKey ? -1 : 1;
|
|
const nextIdx = (currentIdx + direction + activeSessions.length) % activeSessions.length;
|
|
void api.selectSession(activeSessions[nextIdx].id);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Ctrl/Cmd+. — Quick approve pending tool call ──
|
|
if (mod && e.key === '.') {
|
|
e.preventDefault();
|
|
if (ws?.selectedSessionId) {
|
|
const session = ws.sessions.find((s) => s.id === ws.selectedSessionId);
|
|
if (session?.pendingApproval?.status === 'pending') {
|
|
void api.resolveSessionApproval({
|
|
sessionId: session.id,
|
|
approvalId: session.pendingApproval.id,
|
|
decision: 'approved',
|
|
});
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Ctrl/Cmd+L — Focus the composer ──
|
|
if (mod && e.key === 'l') {
|
|
e.preventDefault();
|
|
const editor = document.querySelector<HTMLElement>('.markdown-composer-editable');
|
|
editor?.focus();
|
|
return;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
// Track terminal running state via exit events
|
|
const offExit = api.onTerminalExit(() => setTerminalRunning(false));
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
offExit();
|
|
};
|
|
}, [api]);
|
|
|
|
// Sync bottom panel height from workspace settings when workspace loads
|
|
useEffect(() => {
|
|
if (workspace?.settings.terminalHeight) {
|
|
setBottomPanelHeight(workspace.settings.terminalHeight);
|
|
}
|
|
}, [workspace?.settings.terminalHeight]);
|
|
|
|
const handleBottomPanelHeightChange = useCallback((newHeight: number) => {
|
|
const clamped = Math.max(MIN_BOTTOM_HEIGHT, Math.round(newHeight));
|
|
setBottomPanelHeight(clamped);
|
|
void api.setTerminalHeight({ height: clamped });
|
|
}, [api]);
|
|
|
|
const handleBottomPanelClose = useCallback(() => {
|
|
setBottomPanelOpen(false);
|
|
}, []);
|
|
|
|
const handleTerminalToggle = useCallback(() => {
|
|
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)}"]`);
|
|
if (element) {
|
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
element.classList.add('ring-1', 'ring-indigo-500/40', 'rounded-lg');
|
|
setTimeout(() => element.classList.remove('ring-1', 'ring-indigo-500/40', 'rounded-lg'), 1500);
|
|
}
|
|
}, []);
|
|
|
|
const handleDiscardRunChanges = useCallback(
|
|
(sessionId: string, runId: string, files?: ProjectGitFileReference[]) =>
|
|
api.discardSessionRunGitChanges({ sessionId, runId, files }),
|
|
[api],
|
|
);
|
|
|
|
const handleOpenCommitComposer = useCallback(() => {
|
|
if (!selectedSession) return;
|
|
setCommitComposerCtx({
|
|
projectId: selectedSession.projectId,
|
|
sessionId: selectedSession.id,
|
|
});
|
|
}, [selectedSession]);
|
|
|
|
const handleCreateScratchpad = useCallback(() => {
|
|
if (!workspace) return;
|
|
if (workspace.workflows.length <= 1) {
|
|
const wf = workspace.workflows[0];
|
|
if (wf) void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, workflowId: wf.id });
|
|
return;
|
|
}
|
|
setWorkflowPickerProjectId(SCRATCHPAD_PROJECT_ID);
|
|
}, [api, workspace]);
|
|
|
|
/** Opens the workflow picker, or creates immediately if ≤1 workflow. */
|
|
const handleNewSession = useCallback((projectId: string) => {
|
|
if (!workspace) return;
|
|
if (workspace.workflows.length <= 1) {
|
|
const wf = workspace.workflows[0];
|
|
if (wf) void api.createSession({ projectId, workflowId: wf.id });
|
|
return;
|
|
}
|
|
setWorkflowPickerProjectId(projectId);
|
|
}, [api, workspace]);
|
|
|
|
/** Called when a workflow is picked from the picker. */
|
|
const handleWorkflowPicked = useCallback((workflowId: string) => {
|
|
if (!workflowPickerProjectId) return;
|
|
void api.createSession({ projectId: workflowPickerProjectId, workflowId });
|
|
setWorkflowPickerProjectId(null);
|
|
}, [api, workflowPickerProjectId]);
|
|
|
|
const handleOpenSettingsAt = useCallback((section?: SettingsSection) => {
|
|
setSettingsSection(section);
|
|
setShowSettings(true);
|
|
}, []);
|
|
|
|
const handleInstallUpdate = useCallback(() => {
|
|
void api.installUpdate();
|
|
}, [api]);
|
|
|
|
// Listen for tray "Quick Scratchpad" action
|
|
const scratchpadRef = useRef(handleCreateScratchpad);
|
|
scratchpadRef.current = handleCreateScratchpad;
|
|
useEffect(() => {
|
|
return api.onTrayCreateScratchpad(() => scratchpadRef.current());
|
|
}, [api]);
|
|
|
|
const projectForSettings = useMemo(
|
|
() => workspace?.projects.find((p) => p.id === projectSettingsId),
|
|
[workspace?.projects, projectSettingsId],
|
|
);
|
|
|
|
// Close project settings if the project was removed
|
|
useEffect(() => {
|
|
if (projectSettingsId && !projectForSettings) setProjectSettingsId(undefined);
|
|
}, [projectSettingsId, projectForSettings]);
|
|
|
|
// Loading state
|
|
if (!workspace) {
|
|
return (
|
|
<div className="flex h-screen items-center justify-center bg-[var(--color-surface-0)]">
|
|
<div className="text-sm text-zinc-500">Loading workspace…</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Determine main content
|
|
let content: React.ReactNode;
|
|
let detailPanel: React.ReactNode | undefined;
|
|
if (error) {
|
|
content = (
|
|
<div className="flex h-full items-center justify-center px-8">
|
|
<div className="max-w-md rounded-xl border border-red-500/20 bg-red-500/5 p-6 text-center">
|
|
<h2 className="text-sm font-semibold text-red-300">Something went wrong</h2>
|
|
<p className="mt-2 text-[13px] leading-relaxed text-red-400/80">{error}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
} else if (selectedSession && workflowForSession && projectForSession) {
|
|
content = (
|
|
<ChatPane
|
|
onSend={(c, attachments, messageMode, promptInvocation) => api.sendSessionMessage({
|
|
sessionId: selectedSession.id,
|
|
content: c,
|
|
attachments: attachments?.length ? attachments : undefined,
|
|
messageMode,
|
|
promptInvocation,
|
|
})}
|
|
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
|
|
onResolveApproval={(approvalId, decision, alwaysApprove) =>
|
|
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision, alwaysApprove })
|
|
}
|
|
onResolveUserInput={(userInputId, answer, wasFreeform) =>
|
|
api.resolveSessionUserInput({ sessionId: selectedSession.id, userInputId, answer, wasFreeform })
|
|
}
|
|
onSetInteractionMode={(mode) => {
|
|
void api.setSessionInteractionMode({ sessionId: selectedSession.id, mode });
|
|
}}
|
|
onDismissPlanReview={() => {
|
|
void api.dismissSessionPlanReview({ sessionId: selectedSession.id });
|
|
}}
|
|
onDismissMcpAuth={() => {
|
|
void api.dismissSessionMcpAuth({ sessionId: selectedSession.id });
|
|
}}
|
|
onAuthenticateMcp={() => {
|
|
void api.startSessionMcpAuth({ sessionId: selectedSession.id });
|
|
}}
|
|
onUpdateSessionModelConfig={(config) =>
|
|
api.updateSessionModelConfig({
|
|
sessionId: selectedSession.id,
|
|
model: config.model,
|
|
reasoningEffort: config.reasoningEffort,
|
|
})
|
|
}
|
|
onUpdateSessionTooling={(selection) => {
|
|
void api.updateSessionTooling({
|
|
sessionId: selectedSession.id,
|
|
enabledMcpServerIds: selection.enabledMcpServerIds,
|
|
enabledLspProfileIds: selection.enabledLspProfileIds,
|
|
});
|
|
}}
|
|
onUpdateSessionApprovalSettings={(settings) => {
|
|
void api.updateSessionApprovalSettings({
|
|
sessionId: selectedSession.id,
|
|
autoApprovedToolNames: settings.autoApprovedToolNames,
|
|
});
|
|
}}
|
|
onBranchFromMessage={(messageId) => {
|
|
void api.branchSession({ sessionId: selectedSession.id, messageId });
|
|
}}
|
|
onPinMessage={(messageId, isPinned) => {
|
|
void api.setSessionMessagePinned({ sessionId: selectedSession.id, messageId, isPinned });
|
|
}}
|
|
onRegenerateMessage={(messageId) => {
|
|
void api.regenerateSessionMessage({ sessionId: selectedSession.id, messageId });
|
|
}}
|
|
onEditAndResendMessage={(messageId, content) => {
|
|
void api.editAndResendSessionMessage({ sessionId: selectedSession.id, messageId, content });
|
|
}}
|
|
branchOriginLabel={
|
|
selectedSession.branchOrigin
|
|
? workspace.sessions.find((s) => s.id === selectedSession.branchOrigin!.sourceSessionId)?.title
|
|
: undefined
|
|
}
|
|
availableModels={availableModels}
|
|
mcpProbingServerIds={workspace.mcpProbingServerIds}
|
|
onTerminalToggle={handleTerminalToggle}
|
|
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
|
|
workflow={workflowForSession}
|
|
project={projectForSession}
|
|
runtimeTools={sidecarCapabilities?.runtimeTools}
|
|
session={selectedSession}
|
|
sessionUsage={usageForSession}
|
|
sessionActivity={activityForSession}
|
|
activeSubagents={subagentsForSession}
|
|
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
|
|
terminalRunning={terminalRunning}
|
|
gitPanelOpen={bottomPanelOpen && bottomPanelTab === 'git'}
|
|
gitDirty={gitDirty}
|
|
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
|
/>
|
|
);
|
|
detailPanel = (
|
|
<ActivityPanel
|
|
activity={activityForSession}
|
|
onDiscard={handleDiscardRunChanges}
|
|
onJumpToMessage={jumpToMessage}
|
|
onOpenCommitComposer={handleOpenCommitComposer}
|
|
workflow={workflowForSession}
|
|
session={selectedSession}
|
|
sessionRequestUsage={requestUsageForSession}
|
|
turnEvents={turnEventsForSession}
|
|
/>
|
|
);
|
|
} else {
|
|
content = (
|
|
<WelcomePane
|
|
hasProjects={hasUserProjects}
|
|
connectionStatus={sidecarCapabilities?.connection.status}
|
|
onAddProject={() => void api.addProject()}
|
|
onNewScratchpad={() => handleCreateScratchpad()}
|
|
onOpenSettings={() => setShowSettings(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Settings overlay
|
|
const overlay = showSettings ? (
|
|
<SettingsPanel
|
|
availableModels={availableModels}
|
|
initialSection={settingsSection}
|
|
isRefreshingCapabilities={isRefreshingCapabilities}
|
|
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
|
onDeleteLspProfile={async (id) => {
|
|
await api.deleteLspProfile(id);
|
|
}}
|
|
onDeleteMcpServer={async (id) => {
|
|
await api.deleteMcpServer(id);
|
|
}}
|
|
onDeleteWorkflow={async (id) => {
|
|
await api.deleteWorkflow(id);
|
|
}}
|
|
onNewLspProfile={createDraftLspProfile}
|
|
onNewMcpServer={createDraftMcpServer}
|
|
onNewWorkflow={() => {
|
|
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
|
return createDraftWorkflow(
|
|
defaultModel?.id ?? 'gpt-5.4',
|
|
resolveReasoningEffort(defaultModel, 'high'),
|
|
);
|
|
}}
|
|
onRefreshCapabilities={refreshCapabilities}
|
|
onSaveLspProfile={async (profile) => {
|
|
await api.saveLspProfile({ profile });
|
|
}}
|
|
onSaveMcpServer={async (server) => {
|
|
await api.saveMcpServer({ server });
|
|
}}
|
|
onSaveWorkflow={async (workflow) => {
|
|
await api.saveWorkflow({ workflow });
|
|
}}
|
|
onSaveWorkspaceAgent={async (agent) => {
|
|
await api.saveWorkspaceAgent({ agent });
|
|
}}
|
|
onDeleteWorkspaceAgent={async (id) => {
|
|
await api.deleteWorkspaceAgent(id);
|
|
}}
|
|
onNewWorkspaceAgent={() => {
|
|
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
|
return createDraftWorkspaceAgent(defaultModel?.id ?? 'gpt-5.4');
|
|
}}
|
|
workspaceAgents={workspace.settings.agents ?? []}
|
|
onSetTheme={(theme) => void api.setTheme(theme)}
|
|
notificationsEnabled={workspace.settings.notificationsEnabled !== false}
|
|
onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)}
|
|
minimizeToTray={workspace.settings.minimizeToTray === true}
|
|
onSetMinimizeToTray={(enabled) => void api.setMinimizeToTray(enabled)}
|
|
gitAutoRefreshEnabled={workspace.settings.gitAutoRefreshEnabled !== false}
|
|
onSetGitAutoRefreshEnabled={(enabled) => void api.setGitAutoRefreshEnabled(enabled)}
|
|
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
|
onResetLocalWorkspace={async () => {
|
|
const fresh = await api.resetLocalWorkspace();
|
|
setWorkspace(fresh);
|
|
setSessionActivities({});
|
|
setShowSettings(false);
|
|
}}
|
|
workflows={workspace.workflows}
|
|
workflowTemplates={workspace.workflowTemplates}
|
|
onCreateWorkflowFromTemplate={async (templateId, name) => {
|
|
await api.createWorkflowFromTemplate({ templateId, options: name ? { name } : undefined });
|
|
}}
|
|
sidecarCapabilities={sidecarCapabilities}
|
|
theme={workspace.settings.theme}
|
|
toolingSettings={workspace.settings.tooling}
|
|
discoveredUserTooling={workspace.settings.discoveredUserTooling}
|
|
onResolveUserDiscoveredTooling={(serverIds, resolution) => {
|
|
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
|
|
}}
|
|
onGetQuota={() => api.getQuota()}
|
|
/>
|
|
) : null;
|
|
|
|
return (
|
|
<>
|
|
<AppShell
|
|
content={content}
|
|
detailPanel={detailPanel}
|
|
overlay={overlay}
|
|
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
|
|
}
|
|
sidebar={
|
|
<Sidebar
|
|
onAddProject={() => void api.addProject()}
|
|
onCreateScratchpad={() => handleCreateScratchpad()}
|
|
onNewProjectSession={(projectId) => handleNewSession(projectId)}
|
|
onOpenSettings={() => setShowSettings(true)}
|
|
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
|
onProjectSelect={(projectId) => {
|
|
void api.selectProject(projectId);
|
|
}}
|
|
onSessionSelect={(sessionId) => {
|
|
void api.selectSession(sessionId);
|
|
}}
|
|
onRenameSession={(sessionId, title) => {
|
|
void api.renameSession({ sessionId, title });
|
|
}}
|
|
onDuplicateSession={(sessionId) => {
|
|
void api.duplicateSession({ sessionId });
|
|
}}
|
|
onSetSessionPinned={(sessionId, isPinned) => {
|
|
void api.setSessionPinned({ sessionId, isPinned });
|
|
}}
|
|
onSetSessionArchived={(sessionId, isArchived) => {
|
|
void api.setSessionArchived({ sessionId, isArchived });
|
|
}}
|
|
onDeleteSession={(sessionId) => {
|
|
void api.deleteSession({ sessionId });
|
|
}}
|
|
onRefreshGitContext={(projectId) => {
|
|
void api.refreshProjectGitContext(projectId);
|
|
}}
|
|
updateStatus={updateStatus}
|
|
onViewUpdateDetails={() => handleOpenSettingsAt('troubleshooting')}
|
|
onInstallUpdate={handleInstallUpdate}
|
|
workspace={workspace}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
{showDiscoveryModal && (
|
|
<DiscoveredToolingModal
|
|
onClose={() => setShowDiscoveryModal(false)}
|
|
onResolveProjectServers={(serverIds, resolution) => {
|
|
if (selectedProject) {
|
|
void api.resolveProjectDiscoveredTooling({ projectId: selectedProject.id, serverIds, resolution });
|
|
}
|
|
}}
|
|
onResolveUserServers={(serverIds, resolution) => {
|
|
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
|
|
}}
|
|
projectDiscoveredTooling={selectedProject?.discoveredTooling}
|
|
projectName={selectedProject?.name}
|
|
userDiscoveredTooling={workspace.settings.discoveredUserTooling}
|
|
/>
|
|
)}
|
|
|
|
{projectForSettings && (
|
|
<ProjectSettingsPanel
|
|
project={projectForSettings}
|
|
onClose={() => setProjectSettingsId(undefined)}
|
|
onRescanConfigs={() => {
|
|
void api.rescanProjectConfigs({ projectId: projectForSettings.id });
|
|
}}
|
|
onRescanCustomization={() => {
|
|
void api.rescanProjectCustomization({ projectId: projectForSettings.id });
|
|
}}
|
|
onResolveDiscoveredTooling={(serverIds, resolution) => {
|
|
void api.resolveProjectDiscoveredTooling({ projectId: projectForSettings.id, serverIds, resolution });
|
|
}}
|
|
onSetAgentProfileEnabled={(agentProfileId, enabled) => {
|
|
void api.setProjectAgentProfileEnabled({ projectId: projectForSettings.id, agentProfileId, enabled });
|
|
}}
|
|
onRemoveProject={() => {
|
|
void api.removeProject(projectForSettings.id);
|
|
setProjectSettingsId(undefined);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{commandPaletteOpen && workspace && (
|
|
<CommandPalette
|
|
workspace={workspace}
|
|
onClose={() => setCommandPaletteOpen(false)}
|
|
onSelectSession={(sessionId) => {
|
|
void api.selectSession(sessionId);
|
|
}}
|
|
onSelectProject={(projectId) => {
|
|
void api.selectProject(projectId);
|
|
}}
|
|
onNewSession={(projectId) => handleNewSession(projectId)}
|
|
onCreateScratchpad={handleCreateScratchpad}
|
|
onOpenSettings={() => setShowSettings(true)}
|
|
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
|
onToggleTerminal={handleTerminalToggle}
|
|
onSetTheme={(theme) => void api.setTheme(theme)}
|
|
onDuplicateSession={(sessionId) => {
|
|
void api.duplicateSession({ sessionId });
|
|
}}
|
|
onPinSession={(sessionId, isPinned) => {
|
|
void api.setSessionPinned({ sessionId, isPinned });
|
|
}}
|
|
onArchiveSession={(sessionId, isArchived) => {
|
|
void api.setSessionArchived({ sessionId, isArchived });
|
|
}}
|
|
onAddProject={() => void api.addProject()}
|
|
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
|
onShowShortcuts={() => setShowShortcuts(true)}
|
|
onShowSearch={() => setShowSearch(true)}
|
|
onShowBookmarks={() => setShowBookmarks(true)}
|
|
/>
|
|
)}
|
|
|
|
{showShortcuts && (
|
|
<KeyboardShortcutsPanel onClose={() => setShowShortcuts(false)} />
|
|
)}
|
|
|
|
{showSearch && workspace && (
|
|
<SessionSearchPanel
|
|
workspace={workspace}
|
|
onClose={() => setShowSearch(false)}
|
|
onSelectSession={(sessionId) => {
|
|
void api.selectSession(sessionId);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showBookmarks && workspace && (
|
|
<BookmarksPanel
|
|
workspace={workspace}
|
|
onClose={() => setShowBookmarks(false)}
|
|
onSelectSession={(sessionId) => {
|
|
void api.selectSession(sessionId);
|
|
}}
|
|
onUnpinMessage={(sessionId, messageId) => {
|
|
void api.setSessionMessagePinned({ sessionId, messageId, isPinned: false });
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{commitComposerCtx && (
|
|
<CommitComposer
|
|
onClose={() => setCommitComposerCtx(undefined)}
|
|
projectId={commitComposerCtx.projectId}
|
|
runId={commitComposerCtx.runId}
|
|
sessionId={commitComposerCtx.sessionId}
|
|
/>
|
|
)}
|
|
|
|
{workflowPickerProjectId && workspace && (
|
|
<WorkflowPicker
|
|
workflows={workspace.workflows}
|
|
onSelect={handleWorkflowPicked}
|
|
onClose={() => setWorkflowPickerProjectId(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|