From 38ec3c95c0fc0243e560531ee8a331ce8d7b262f Mon Sep 17 00:00:00 2001 From: kewton Date: Tue, 14 Apr 2026 01:27:24 +0900 Subject: [PATCH 1/2] feat(assistant): implement assistant chat feature for Home page Issue #649: Add global assistant chat panel with CLI tool session management. Backend: - Add global session constants (GLOBAL_SESSION_WORKTREE_ID='__global__') - Add global session poller (polling without DB operations) - Add context builder for initial session context with repository info - Add API routes: start, terminal, current-output, session (DELETE) - Add cleanupGlobalSessions() to session-cleanup for orphan cleanup - Add __global__ early return in worktree-status-helper - Add assistant-api client module Frontend: - Add AssistantMessageInput (simplified, no slash commands/image) - Add AssistantChatPanel (collapsible, repo/tool selection, output display) - Integrate AssistantChatPanel into Home page above Session Overview Tests: - global-session-poller: polling lifecycle, start/stop, max retries - session-cleanup-global: cleanupGlobalSessions function - assistant-context-builder: buildGlobalContext, getEnabledRepositories - All 334 test files pass (6319 tests), 0 lint/type errors Resolves #649 Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/assistant/current-output/route.ts | 65 ++++ src/app/api/assistant/session/route.ts | 59 ++++ src/app/api/assistant/start/route.ts | 108 ++++++ src/app/api/assistant/terminal/route.ts | 88 +++++ src/app/page.tsx | 4 + src/components/home/AssistantChatPanel.tsx | 325 ++++++++++++++++++ src/components/home/AssistantMessageInput.tsx | 186 ++++++++++ src/lib/api/assistant-api.ts | 108 ++++++ src/lib/assistant/context-builder.ts | 59 ++++ src/lib/polling/global-session-poller.ts | 157 +++++++++ src/lib/session-cleanup.ts | 51 ++- src/lib/session/global-session-constants.ts | 28 ++ src/lib/session/worktree-status-helper.ts | 12 + src/types/assistant.ts | 51 +++ tests/unit/assistant-context-builder.test.ts | 160 +++++++++ tests/unit/global-session-poller.test.ts | 168 +++++++++ tests/unit/session-cleanup-global.test.ts | 145 ++++++++ 17 files changed, 1773 insertions(+), 1 deletion(-) create mode 100644 src/app/api/assistant/current-output/route.ts create mode 100644 src/app/api/assistant/session/route.ts create mode 100644 src/app/api/assistant/start/route.ts create mode 100644 src/app/api/assistant/terminal/route.ts create mode 100644 src/components/home/AssistantChatPanel.tsx create mode 100644 src/components/home/AssistantMessageInput.tsx create mode 100644 src/lib/api/assistant-api.ts create mode 100644 src/lib/assistant/context-builder.ts create mode 100644 src/lib/polling/global-session-poller.ts create mode 100644 src/lib/session/global-session-constants.ts create mode 100644 src/types/assistant.ts create mode 100644 tests/unit/assistant-context-builder.test.ts create mode 100644 tests/unit/global-session-poller.test.ts create mode 100644 tests/unit/session-cleanup-global.test.ts diff --git a/src/app/api/assistant/current-output/route.ts b/src/app/api/assistant/current-output/route.ts new file mode 100644 index 00000000..7122879a --- /dev/null +++ b/src/app/api/assistant/current-output/route.ts @@ -0,0 +1,65 @@ +/** + * Assistant Current Output API endpoint + * GET /api/assistant/current-output + * + * Issue #649: Capture terminal output from a global assistant session. + * - No DB operations + * - Uses tmux capturePane to get current terminal output + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { isCliToolType } from '@/lib/cli-tools/types'; +import { CLIToolManager } from '@/lib/cli-tools/manager'; +import { GLOBAL_SESSION_WORKTREE_ID } from '@/lib/session/global-session-constants'; +import { hasSession, capturePane } from '@/lib/tmux/tmux'; +import { createLogger } from '@/lib/logger'; + +const logger = createLogger('api/assistant/current-output'); + +/** Default capture lines for output */ +const DEFAULT_CAPTURE_LINES = 1000; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const cliToolId = searchParams.get('cliToolId'); + + // Validate cliToolId + if (!cliToolId || !isCliToolType(cliToolId)) { + return NextResponse.json( + { error: 'Invalid cliToolId parameter' }, + { status: 400 } + ); + } + + // Derive session name + const manager = CLIToolManager.getInstance(); + const cliTool = manager.getTool(cliToolId); + const sessionName = cliTool.getSessionName(GLOBAL_SESSION_WORKTREE_ID); + + // Check session exists + const sessionExists = await hasSession(sessionName); + if (!sessionExists) { + return NextResponse.json({ + output: '', + sessionActive: false, + }); + } + + // Capture output + const output = await capturePane(sessionName, DEFAULT_CAPTURE_LINES); + + return NextResponse.json({ + output, + sessionActive: true, + }); + } catch (error) { + logger.error('current-output-api-error:', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to capture assistant output' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/assistant/session/route.ts b/src/app/api/assistant/session/route.ts new file mode 100644 index 00000000..86da9674 --- /dev/null +++ b/src/app/api/assistant/session/route.ts @@ -0,0 +1,59 @@ +/** + * Assistant Session API endpoint + * DELETE /api/assistant/session + * + * Issue #649: Stop a global assistant session. + * - Stops polling + * - Kills the tmux session + * - No DB operations + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { isCliToolType } from '@/lib/cli-tools/types'; +import { CLIToolManager } from '@/lib/cli-tools/manager'; +import { GLOBAL_SESSION_WORKTREE_ID } from '@/lib/session/global-session-constants'; +import { stopGlobalSessionPolling } from '@/lib/polling/global-session-poller'; +import { killSession } from '@/lib/tmux/tmux'; +import { createLogger } from '@/lib/logger'; + +const logger = createLogger('api/assistant/session'); + +export async function DELETE(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const cliToolId = searchParams.get('cliToolId'); + + // Validate cliToolId + if (!cliToolId || !isCliToolType(cliToolId)) { + return NextResponse.json( + { error: 'Invalid cliToolId parameter' }, + { status: 400 } + ); + } + + // Stop polling + stopGlobalSessionPolling(cliToolId); + + // Kill the tmux session + const manager = CLIToolManager.getInstance(); + const cliTool = manager.getTool(cliToolId); + const sessionName = cliTool.getSessionName(GLOBAL_SESSION_WORKTREE_ID); + + const killed = await killSession(sessionName); + + logger.info('session:stopped', { cliToolId, sessionName, killed }); + + return NextResponse.json({ + success: true, + killed, + }); + } catch (error) { + logger.error('session-api-error:', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to stop assistant session' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/assistant/start/route.ts b/src/app/api/assistant/start/route.ts new file mode 100644 index 00000000..e960e53e --- /dev/null +++ b/src/app/api/assistant/start/route.ts @@ -0,0 +1,108 @@ +/** + * Assistant Start API endpoint + * POST /api/assistant/start + * + * Issue #649: Start a global assistant session. + * - No DB operations (no worktree record required) + * - Validates cliToolId and working directory + * - Creates tmux session + sends initial context + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { isCliToolType } from '@/lib/cli-tools/types'; +import { CLIToolManager } from '@/lib/cli-tools/manager'; +import { GLOBAL_SESSION_WORKTREE_ID } from '@/lib/session/global-session-constants'; +import { buildGlobalContext } from '@/lib/assistant/context-builder'; +import { pollGlobalSession } from '@/lib/polling/global-session-poller'; +import { getDbInstance } from '@/lib/db/db-instance'; +import { isSystemDirectory } from '@/config/system-directories'; +import { createLogger } from '@/lib/logger'; + +const logger = createLogger('api/assistant/start'); + +/** Maximum working directory path length */ +const MAX_PATH_LENGTH = 4096; + +export async function POST(req: NextRequest) { + try { + const { cliToolId, workingDirectory } = await req.json(); + + // Validate cliToolId + if (!cliToolId || typeof cliToolId !== 'string' || !isCliToolType(cliToolId)) { + return NextResponse.json( + { error: 'Invalid cliToolId parameter' }, + { status: 400 } + ); + } + + // Validate workingDirectory + if (!workingDirectory || typeof workingDirectory !== 'string') { + return NextResponse.json( + { error: 'Missing workingDirectory parameter' }, + { status: 400 } + ); + } + + if (workingDirectory.length > MAX_PATH_LENGTH) { + return NextResponse.json( + { error: 'Invalid workingDirectory parameter' }, + { status: 400 } + ); + } + + // Null byte check (path traversal prevention) + if (workingDirectory.includes('\0')) { + return NextResponse.json( + { error: 'Invalid workingDirectory parameter' }, + { status: 400 } + ); + } + + // System directory check + if (isSystemDirectory(workingDirectory)) { + return NextResponse.json( + { error: 'Invalid workingDirectory parameter' }, + { status: 400 } + ); + } + + // Check CLI tool installation + const manager = CLIToolManager.getInstance(); + const cliTool = manager.getTool(cliToolId); + const installed = await cliTool.isInstalled(); + if (!installed) { + return NextResponse.json( + { error: `CLI tool '${cliToolId}' is not installed` }, + { status: 400 } + ); + } + + // Start session using BaseCLITool.startSession with GLOBAL_SESSION_WORKTREE_ID + await cliTool.startSession(GLOBAL_SESSION_WORKTREE_ID, workingDirectory); + + // Build and send initial context + const db = getDbInstance(); + const context = buildGlobalContext(cliToolId, db); + await cliTool.sendMessage(GLOBAL_SESSION_WORKTREE_ID, context); + + // Start polling for session output + pollGlobalSession(cliToolId); + + const sessionName = cliTool.getSessionName(GLOBAL_SESSION_WORKTREE_ID); + + logger.info('session:started', { cliToolId, sessionName }); + + return NextResponse.json({ + success: true, + sessionName, + }); + } catch (error) { + logger.error('start-api-error:', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to start assistant session' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/assistant/terminal/route.ts b/src/app/api/assistant/terminal/route.ts new file mode 100644 index 00000000..60f895ba --- /dev/null +++ b/src/app/api/assistant/terminal/route.ts @@ -0,0 +1,88 @@ +/** + * Assistant Terminal API endpoint + * POST /api/assistant/terminal + * + * Issue #649: Send commands to a global assistant session. + * - No DB operations + * - Validates cliToolId and command + * - Sends keys to the active tmux session + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { isCliToolType } from '@/lib/cli-tools/types'; +import { CLIToolManager } from '@/lib/cli-tools/manager'; +import { GLOBAL_SESSION_WORKTREE_ID } from '@/lib/session/global-session-constants'; +import { hasSession, sendKeys, sendSpecialKeys } from '@/lib/tmux/tmux'; +import { invalidateCache } from '@/lib/tmux/tmux-capture-cache'; +import { COPILOT_SEND_ENTER_DELAY_MS } from '@/config/copilot-constants'; +import { createLogger } from '@/lib/logger'; + +const logger = createLogger('api/assistant/terminal'); + +/** Maximum command length to prevent DoS */ +const MAX_COMMAND_LENGTH = 10000; + +export async function POST(req: NextRequest) { + try { + const { cliToolId, command } = await req.json(); + + // Validate cliToolId + if (!cliToolId || typeof cliToolId !== 'string' || !isCliToolType(cliToolId)) { + return NextResponse.json( + { error: 'Invalid cliToolId parameter' }, + { status: 400 } + ); + } + + // Validate command + if (!command || typeof command !== 'string') { + return NextResponse.json( + { error: 'Missing command parameter' }, + { status: 400 } + ); + } + + if (command.length > MAX_COMMAND_LENGTH) { + return NextResponse.json( + { error: 'Invalid command parameter' }, + { status: 400 } + ); + } + + // Check session exists + const manager = CLIToolManager.getInstance(); + const cliTool = manager.getTool(cliToolId); + const sessionName = cliTool.getSessionName(GLOBAL_SESSION_WORKTREE_ID); + + const sessionExists = await hasSession(sessionName); + if (!sessionExists) { + return NextResponse.json( + { error: 'Session not found. Use start API to create a session first.' }, + { status: 404 } + ); + } + + // Send command to tmux session (same pattern as terminal/route.ts) + if (cliToolId === 'copilot') { + const copilotCommand = command.replace(/\n+/g, ' ').trim(); + await sendKeys(sessionName, copilotCommand, false); + await new Promise(resolve => setTimeout(resolve, COPILOT_SEND_ENTER_DELAY_MS)); + await sendSpecialKeys(sessionName, ['Enter']); + } else { + await sendKeys(sessionName, command); + } + + // Invalidate cache after sending command + invalidateCache(sessionName); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('terminal-api-error:', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to send command to terminal' }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 57a84f32..de5167e0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -17,6 +17,7 @@ import { useState, useEffect, useCallback } from 'react'; import Link from 'next/link'; import { AppShell } from '@/components/layout'; import { HomeSessionSummary } from '@/components/home/HomeSessionSummary'; +import { AssistantChatPanel } from '@/components/home/AssistantChatPanel'; import type { Worktree } from '@/types/models'; /** @@ -143,6 +144,9 @@ export default function Home() {

+ {/* Assistant Chat Panel */} + + {/* Session Summary */}

Session Overview

diff --git a/src/components/home/AssistantChatPanel.tsx b/src/components/home/AssistantChatPanel.tsx new file mode 100644 index 00000000..c7ecfe9f --- /dev/null +++ b/src/components/home/AssistantChatPanel.tsx @@ -0,0 +1,325 @@ +/** + * AssistantChatPanel Component + * Issue #649: Main assistant chat panel for the Home page. + * + * Features: + * - Collapsible panel (max 50vh when expanded) + * - Repository selection dropdown + * - CLI tool selection + * - Terminal output display with polling + * - Session start/stop controls + * - Dark mode support + * - Mobile responsive layout + */ + +'use client'; + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { AssistantMessageInput } from './AssistantMessageInput'; +import { assistantApi } from '@/lib/api/assistant-api'; +import { GLOBAL_POLL_INTERVAL_MS } from '@/lib/session/global-session-constants'; +import type { CLIToolType } from '@/lib/cli-tools/types'; +import { CLI_TOOL_IDS, getCliToolDisplayName } from '@/lib/cli-tools/types'; + +/** localStorage key for panel collapsed state */ +const COLLAPSED_KEY = 'commandmate-assistant-collapsed'; + +/** localStorage key for selected CLI tool */ +const CLI_TOOL_KEY = 'commandmate-assistant-cli-tool'; + +interface RepositoryOption { + path: string; + name: string; + displayName?: string; +} + +export function AssistantChatPanel() { + const [collapsed, setCollapsed] = useState(true); + const [repositories, setRepositories] = useState([]); + const [selectedRepo, setSelectedRepo] = useState(''); + const [selectedTool, setSelectedTool] = useState('claude'); + const [sessionActive, setSessionActive] = useState(false); + const [output, setOutput] = useState(''); + const [error, setError] = useState(null); + const [starting, setStarting] = useState(false); + const [stopping, setStopping] = useState(false); + const outputRef = useRef(null); + const pollIntervalRef = useRef(null); + + // Restore collapsed state from localStorage + useEffect(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem(COLLAPSED_KEY); + if (saved !== null) { + setCollapsed(saved === 'true'); + } + const savedTool = localStorage.getItem(CLI_TOOL_KEY); + if (savedTool && (CLI_TOOL_IDS as readonly string[]).includes(savedTool)) { + setSelectedTool(savedTool as CLIToolType); + } + } + }, []); + + // Fetch repositories + useEffect(() => { + async function fetchRepos() { + try { + const res = await fetch('/api/worktrees'); + if (res.ok) { + const data = await res.json(); + const repos: RepositoryOption[] = (data.repositories ?? []).map( + (r: { path: string; name: string; displayName?: string }) => ({ + path: r.path, + name: r.name, + displayName: r.displayName, + }), + ); + setRepositories(repos); + if (repos.length > 0 && !selectedRepo) { + setSelectedRepo(repos[0].path); + } + } + } catch { + // Silently handle fetch errors + } + } + fetchRepos(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Polling for output + useEffect(() => { + if (!sessionActive) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + return; + } + + const poll = async () => { + try { + const data = await assistantApi.getCurrentOutput(selectedTool); + setOutput(data.output); + if (!data.sessionActive) { + setSessionActive(false); + } + } catch { + // Silently handle poll errors + } + }; + + // Initial poll + void poll(); + + pollIntervalRef.current = setInterval(poll, GLOBAL_POLL_INTERVAL_MS); + + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + }; + }, [sessionActive, selectedTool]); + + // Auto-scroll output to bottom + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [output]); + + const toggleCollapsed = useCallback(() => { + setCollapsed((prev) => { + const next = !prev; + if (typeof window !== 'undefined') { + localStorage.setItem(COLLAPSED_KEY, String(next)); + } + return next; + }); + }, []); + + const handleToolChange = useCallback((tool: CLIToolType) => { + setSelectedTool(tool); + if (typeof window !== 'undefined') { + localStorage.setItem(CLI_TOOL_KEY, tool); + } + }, []); + + const handleStart = useCallback(async () => { + if (!selectedRepo || starting) return; + + setStarting(true); + setError(null); + try { + await assistantApi.startSession(selectedTool, selectedRepo); + setSessionActive(true); + setOutput(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start session'); + } finally { + setStarting(false); + } + }, [selectedRepo, selectedTool, starting]); + + const handleStop = useCallback(async () => { + if (stopping) return; + + setStopping(true); + setError(null); + try { + await assistantApi.stopSession(selectedTool); + setSessionActive(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to stop session'); + } finally { + setStopping(false); + } + }, [selectedTool, stopping]); + + const handleSendMessage = useCallback( + async (message: string) => { + setError(null); + try { + await assistantApi.sendCommand(selectedTool, message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send message'); + throw err; + } + }, + [selectedTool], + ); + + const handleRepoChange = useCallback( + (newRepo: string) => { + if (sessionActive) { + const confirmed = window.confirm( + 'Changing repository will not affect the active session. Do you want to continue?', + ); + if (!confirmed) return; + } + setSelectedRepo(newRepo); + }, + [sessionActive], + ); + + return ( +
+ {/* Header */} + + + {/* Content */} + {!collapsed && ( +
+ {/* Controls */} +
+ {/* Repository selector */} + + + {/* CLI tool selector */} + + + {/* Start/Stop button */} + {!sessionActive ? ( + + ) : ( + + )} +
+ + {/* Error display */} + {error && ( +
+ {error} +
+ )} + + {/* Terminal output */} +
+            {output || (sessionActive ? 'Waiting for output...' : 'Start a session to begin.')}
+          
+ + {/* Message input */} + +
+ )} +
+ ); +} diff --git a/src/components/home/AssistantMessageInput.tsx b/src/components/home/AssistantMessageInput.tsx new file mode 100644 index 00000000..d32da6cb --- /dev/null +++ b/src/components/home/AssistantMessageInput.tsx @@ -0,0 +1,186 @@ +/** + * AssistantMessageInput Component + * Issue #649: Simplified message input for the assistant chat panel. + * + * Unlike the main MessageInput, this component: + * - Has no slash command support + * - Has no image attachment + * - Has no draft persistence + * - Only supports simple text input + send + * + * Supports IME composing guard and dark mode. + */ + +'use client'; + +import React, { memo, useState, useCallback, useRef, useEffect } from 'react'; + +export interface AssistantMessageInputProps { + /** Called when the user sends a message */ + onSend: (message: string) => Promise; + /** Whether the input should be disabled */ + disabled?: boolean; + /** Placeholder text */ + placeholder?: string; +} + +export const AssistantMessageInput = memo(function AssistantMessageInput({ + onSend, + disabled = false, + placeholder = 'Type your message...', +}: AssistantMessageInputProps) { + const [message, setMessage] = useState(''); + const [sending, setSending] = useState(false); + const [isComposing, setIsComposing] = useState(false); + const textareaRef = useRef(null); + const compositionTimeoutRef = useRef(null); + const justFinishedComposingRef = useRef(false); + + // Auto-resize textarea based on content + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + if (!message) { + textarea.style.height = '24px'; + } else { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`; + } + } + }, [message]); + + const submitMessage = useCallback(async () => { + if (isComposing || !message.trim() || sending || disabled) { + return; + } + + try { + setSending(true); + await onSend(message.trim()); + setMessage(''); + } catch { + // Error handling is delegated to the parent component + } finally { + setSending(false); + } + }, [isComposing, message, sending, disabled, onSend]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + await submitMessage(); + }, + [submitMessage], + ); + + const handleCompositionStart = useCallback(() => { + setIsComposing(true); + justFinishedComposingRef.current = false; + if (compositionTimeoutRef.current) { + clearTimeout(compositionTimeoutRef.current); + } + }, []); + + const handleCompositionEnd = useCallback(() => { + setIsComposing(false); + justFinishedComposingRef.current = true; + if (compositionTimeoutRef.current) { + clearTimeout(compositionTimeoutRef.current); + } + compositionTimeoutRef.current = setTimeout(() => { + justFinishedComposingRef.current = false; + }, 300); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // IME composition check via keyCode + const { keyCode } = e.nativeEvent; + if (keyCode === 229) { + return; + } + + // Ignore Enter right after composition end + if (justFinishedComposingRef.current && e.key === 'Enter') { + justFinishedComposingRef.current = false; + return; + } + + // Enter submits, Shift+Enter inserts newline + if (e.key === 'Enter' && !isComposing && !e.shiftKey) { + e.preventDefault(); + void submitMessage(); + } + }, + [isComposing, submitMessage], + ); + + return ( +
+