feat(KB-028): add multi-agent terminal view

- Create useMultiAgentLogs hook for real-time agent log streaming
- Build TerminalModal component with tabbed task interface
- Add terminal styles with mobile responsive design
- Integrate terminal toggle into Header with in-progress badge
- Wire TerminalModal into App.tsx with in-progress task filtering
This commit is contained in:
gsxdsm
2026-03-29 19:27:10 -07:00
parent 4a59880c08
commit 34470f0ef8
9 changed files with 1390 additions and 443 deletions

View File

@@ -5,6 +5,7 @@ import { Header } from "./components/Header";
import { Board } from "./components/Board";
import { ListView } from "./components/ListView";
import { TaskDetailModal } from "./components/TaskDetailModal";
import { TerminalModal } from "./components/TerminalModal";
import { SettingsModal } from "./components/SettingsModal";
import type { SectionId } from "./components/SettingsModal";
import { ToastContainer } from "./components/ToastContainer";
@@ -18,7 +19,7 @@ function AppInner() {
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [githubImportOpen, setGitHubImportOpen] = useState(false);
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [autoMerge, setAutoMerge] = useState(true);
@@ -121,20 +122,23 @@ function AppInner() {
addToast(`Imported ${task.id} from GitHub`, "success");
}, [addToast]);
const handleOpenGitManager = useCallback(() => {
setGitManagerOpen(true);
const handleToggleTerminal = useCallback(() => {
setTerminalOpen((prev) => !prev);
}, []);
const handleCloseGitManager = useCallback(() => {
setGitManagerOpen(false);
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
}, []);
// Filter tasks to get only in-progress tasks for terminal
const inProgressTasks = tasks.filter((t) => t.column === "in-progress");
return (
<>
<Header
onOpenSettings={() => setSettingsOpen(true)}
onOpenGitHubImport={() => setGitHubImportOpen(true)}
onOpenGitManager={handleOpenGitManager}
onToggleTerminal={handleToggleTerminal}
inProgressCount={inProgressTasks.length}
globalPaused={globalPaused}
enginePaused={enginePaused}
onToggleGlobalPause={handleToggleGlobalPause}
@@ -200,11 +204,10 @@ function AppInner() {
onImport={handleGitHubImport}
tasks={tasks}
/>
<GitManagerModal
isOpen={gitManagerOpen}
onClose={handleCloseGitManager}
tasks={tasks}
addToast={addToast}
<TerminalModal
isOpen={terminalOpen}
onClose={handleTerminalClose}
tasks={inProgressTasks}
/>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>

View File

@@ -97,6 +97,49 @@ describe("Header", () => {
});
});
describe("terminal button", () => {
it("renders terminal button with correct title", () => {
renderHeader({ onToggleTerminal: noop });
expect(screen.getByTitle("Open Terminal View")).toBeDefined();
});
it("calls onToggleTerminal when terminal button is clicked", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, inProgressCount: 1 });
fireEvent.click(screen.getByTitle("Open Terminal View"));
expect(onToggleTerminal).toHaveBeenCalled();
});
it("shows badge with count when in-progress tasks exist", () => {
renderHeader({ onToggleTerminal: noop, inProgressCount: 3 });
expect(screen.getByTestId("terminal-badge")).toBeDefined();
expect(screen.getByTestId("terminal-badge").textContent).toBe("3");
});
it("shows badge with 9+ when count exceeds 9", () => {
renderHeader({ onToggleTerminal: noop, inProgressCount: 15 });
expect(screen.getByTestId("terminal-badge")).toBeDefined();
expect(screen.getByTestId("terminal-badge").textContent).toBe("9+");
});
it("does not show badge when no in-progress tasks", () => {
renderHeader({ onToggleTerminal: noop, inProgressCount: 0 });
expect(screen.queryByTestId("terminal-badge")).toBeNull();
});
it("is disabled when no in-progress tasks", () => {
renderHeader({ onToggleTerminal: noop, inProgressCount: 0 });
const btn = screen.getByTitle("Open Terminal View");
expect(btn.hasAttribute("disabled")).toBe(true);
});
it("is enabled when in-progress tasks exist", () => {
renderHeader({ onToggleTerminal: noop, inProgressCount: 2 });
const btn = screen.getByTitle("Open Terminal View");
expect(btn.hasAttribute("disabled")).toBe(false);
});
});
describe("pause controls", () => {
it("renders pause button for engine pause", () => {
renderHeader();

View File

@@ -1,9 +1,10 @@
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, GitBranch } from "lucide-react";
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal } from "lucide-react";
interface HeaderProps {
onOpenSettings?: () => void;
onOpenGitHubImport?: () => void;
onOpenGitManager?: () => void;
onToggleTerminal?: () => void;
inProgressCount?: number;
globalPaused?: boolean;
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
@@ -15,7 +16,8 @@ interface HeaderProps {
export function Header({
onOpenSettings,
onOpenGitHubImport,
onOpenGitManager,
onToggleTerminal,
inProgressCount = 0,
globalPaused,
enginePaused,
onToggleGlobalPause,
@@ -23,6 +25,8 @@ export function Header({
view = "board",
onChangeView,
}: HeaderProps) {
const hasInProgressTasks = inProgressCount > 0;
return (
<header className="header">
<div className="header-left">
@@ -58,9 +62,20 @@ export function Header({
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} />
</button>
{/* Git Manager */}
<button className="btn-icon" onClick={onOpenGitManager} title="Git Manager">
<GitBranch size={16} />
{/* Terminal button - shows badge with count when in-progress tasks exist */}
<button
className={`btn-icon btn-icon--terminal${hasInProgressTasks ? " has-badge" : ""}`}
onClick={onToggleTerminal}
title="Open Terminal View"
disabled={!hasInProgressTasks}
data-testid="terminal-toggle-btn"
>
<Terminal size={16} />
{hasInProgressTasks && (
<span className="btn-badge" data-testid="terminal-badge">
{inProgressCount > 9 ? "9+" : inProgressCount}
</span>
)}
</button>
{/* Pause button (soft pause): stops new work, lets agents finish */}
<button

View File

@@ -0,0 +1,137 @@
import { useState, useEffect, useCallback } from "react";
import { X, Trash2 } from "lucide-react";
import type { Task, AgentLogEntry } from "@kb/core";
import { useMultiAgentLogs } from "../hooks/useMultiAgentLogs";
import { AgentLogViewer } from "./AgentLogViewer";
interface TerminalModalProps {
isOpen: boolean;
onClose: () => void;
tasks: Task[];
}
interface LogEntryWithTask extends AgentLogEntry {
taskId: string;
}
export function TerminalModal({ isOpen, onClose, tasks }: TerminalModalProps) {
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
// Get task IDs for all in-progress tasks
const inProgressTaskIds = tasks.map((t) => t.id);
// Get log state for all tasks
const logState = useMultiAgentLogs(inProgressTaskIds);
// Set initial active task when modal opens
useEffect(() => {
if (isOpen && tasks.length > 0) {
// If no active task or active task not in current list, set first task
if (!activeTaskId || !tasks.find((t) => t.id === activeTaskId)) {
setActiveTaskId(tasks[0].id);
}
}
// Reset when modal closes
if (!isOpen) {
setActiveTaskId(null);
}
}, [isOpen, tasks, activeTaskId]);
// Handle escape key to close modal
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, onClose]);
// Handle overlay click to close
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
if (!isOpen) return null;
// Get active task info
const activeTask = tasks.find((t) => t.id === activeTaskId);
const activeLogState = activeTaskId ? logState[activeTaskId] : null;
return (
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="terminal-modal-overlay">
<div className="modal terminal-modal" data-testid="terminal-modal">
{/* Header with tabs and close button */}
<div className="terminal-header">
<div className="terminal-tabs" data-testid="terminal-tabs">
{tasks.length === 0 ? (
<div className="terminal-tab terminal-tab--empty" data-testid="terminal-no-tasks">
No active tasks
</div>
) : (
tasks.map((task) => (
<button
key={task.id}
className={`terminal-tab ${activeTaskId === task.id ? "terminal-tab--active" : ""}`}
onClick={() => setActiveTaskId(task.id)}
data-testid={`terminal-tab-${task.id}`}
title={task.title || task.description}
>
<span className="terminal-tab-label">{task.id}</span>
{activeTaskId === task.id && (
<span
className="terminal-tab-indicator"
data-testid={`terminal-tab-indicator-${task.id}`}
/>
)}
</button>
))
)}
</div>
<button className="terminal-close" onClick={onClose} data-testid="terminal-close-btn" title="Close terminal">
<X size={20} />
</button>
</div>
{/* Log content area */}
<div className="terminal-content" data-testid="terminal-content">
{tasks.length === 0 ? (
<div className="terminal-empty-state" data-testid="terminal-empty-state">
<p>No tasks currently in progress.</p>
<p>Start a task to see live logs here.</p>
</div>
) : activeTask && activeLogState ? (
<>
<div className="terminal-toolbar" data-testid="terminal-toolbar">
<div className="terminal-task-info">
<span className="terminal-task-id" data-testid="terminal-active-task-id">
{activeTask.id}
</span>
<span className="terminal-task-title" data-testid="terminal-active-task-title">
{activeTask.title || activeTask.description}
</span>
</div>
<button
className="terminal-clear-btn"
onClick={activeLogState.clear}
data-testid="terminal-clear-btn"
title="Clear log buffer"
>
<Trash2 size={14} />
<span>Clear</span>
</button>
</div>
<div className="terminal-log-container" data-testid="terminal-log-container">
<AgentLogViewer entries={activeLogState.entries} loading={activeLogState.loading} />
</div>
</>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,349 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import { TerminalModal } from "../TerminalModal";
import type { Task } from "@kb/core";
import * as useMultiAgentLogsModule from "../../hooks/useMultiAgentLogs";
// Mock the useMultiAgentLogs hook
vi.mock("../../hooks/useMultiAgentLogs", () => ({
useMultiAgentLogs: vi.fn(),
}));
const mockUseMultiAgentLogs = vi.mocked(useMultiAgentLogsModule.useMultiAgentLogs);
describe("TerminalModal", () => {
const mockOnClose = vi.fn();
const createMockTask = (id: string, title?: string): Task => ({
id,
title: title || `Task ${id}`,
description: `Description for ${id}`,
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
status: undefined,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
});
beforeEach(() => {
mockOnClose.mockClear();
mockUseMultiAgentLogs.mockReturnValue({});
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders without crashing when open with empty task list", () => {
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={[]} />
);
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
expect(screen.getByTestId("terminal-no-tasks").textContent).toContain("No active tasks");
expect(screen.getByTestId("terminal-empty-state").textContent).toContain("No tasks currently in progress");
});
it("renders without crashing when open with multiple in-progress tasks", () => {
const tasks = [
createMockTask("KB-001", "First Task"),
createMockTask("KB-002", "Second Task"),
];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
"KB-002": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
expect(screen.getByTestId("terminal-tab-KB-001")).toBeTruthy();
expect(screen.getByTestId("terminal-tab-KB-002")).toBeTruthy();
});
it("does not render when closed", () => {
const tasks = [createMockTask("KB-001")];
const { container } = render(
<TerminalModal isOpen={false} onClose={mockOnClose} tasks={tasks} />
);
expect(container.firstChild).toBeNull();
});
it("shows appropriate empty state when no in-progress tasks", () => {
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={[]} />
);
expect(screen.getByText("No active tasks")).toBeTruthy();
expect(screen.getByText("No tasks currently in progress.")).toBeTruthy();
expect(screen.getByText("Start a task to see live logs here.")).toBeTruthy();
});
it("tab switching changes which task's logs are displayed", async () => {
const tasks = [
createMockTask("KB-001", "First Task"),
createMockTask("KB-002", "Second Task"),
];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
"KB-002": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
// First task should be active by default
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
// Click on second tab
fireEvent.click(screen.getByTestId("terminal-tab-KB-002"));
// Second task should now be active
await waitFor(() => {
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-002");
});
});
it("active tab has correct styling with indicator", () => {
const tasks = [
createMockTask("KB-001", "First Task"),
createMockTask("KB-002", "Second Task"),
];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
"KB-002": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
// First tab should be active by default
const tab1 = screen.getByTestId("terminal-tab-KB-001");
const tab2 = screen.getByTestId("terminal-tab-KB-002");
expect(tab1.className).toContain("terminal-tab--active");
expect(tab2.className).not.toContain("terminal-tab--active");
// Active tab should have indicator
expect(screen.getByTestId("terminal-tab-indicator-KB-001")).toBeTruthy();
});
it("clicking clear button clears that tab's log entries", () => {
const mockClear = vi.fn();
const tasks = [createMockTask("KB-001")];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log", type: "text" as const }], loading: false, clear: mockClear },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
const clearBtn = screen.getByTestId("terminal-clear-btn");
fireEvent.click(clearBtn);
expect(mockClear).toHaveBeenCalled();
});
it("modal closes on Escape key press", () => {
const tasks = [createMockTask("KB-001")];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
fireEvent.keyDown(document, { key: "Escape" });
expect(mockOnClose).toHaveBeenCalled();
});
it("modal closes on overlay click", () => {
const tasks = [createMockTask("KB-001")];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
const overlay = screen.getByTestId("terminal-modal-overlay");
fireEvent.click(overlay);
expect(mockOnClose).toHaveBeenCalled();
});
it("modal does not close when clicking inside modal content", () => {
const tasks = [createMockTask("KB-001")];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
const modal = screen.getByTestId("terminal-modal");
fireEvent.click(modal);
expect(mockOnClose).not.toHaveBeenCalled();
});
it("modal closes on close button click", () => {
const tasks = [createMockTask("KB-001")];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
const closeBtn = screen.getByTestId("terminal-close-btn");
fireEvent.click(closeBtn);
expect(mockOnClose).toHaveBeenCalled();
});
it("displays task information in toolbar", () => {
const tasks = [createMockTask("KB-001", "My Test Task")];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
expect(screen.getByTestId("terminal-active-task-title").textContent).toBe("My Test Task");
});
it("uses description as title fallback when title is not provided", () => {
const tasks = [{
...createMockTask("KB-001"),
title: undefined,
description: "My Description",
}];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
expect(screen.getByTestId("terminal-active-task-title").textContent).toBe("My Description");
});
it("passes correct entries to AgentLogViewer", () => {
const tasks = [createMockTask("KB-001")];
const entries = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log1", type: "text" as const },
{ timestamp: "2026-01-01T00:01:00Z", taskId: "KB-001", text: "log2", type: "tool" as const },
];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries, loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
expect(screen.getByTestId("agent-log-viewer")).toBeTruthy();
});
it("passes loading state to AgentLogViewer", () => {
const tasks = [createMockTask("KB-001")];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: true, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
expect(screen.getByTestId("agent-log-viewer")).toBeTruthy();
expect(screen.getByText("Loading agent logs…")).toBeTruthy();
});
it("switches to first task when active task is removed from list", () => {
const tasks = [
createMockTask("KB-001"),
createMockTask("KB-002"),
];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
"KB-002": { entries: [], loading: false, clear: vi.fn() },
});
const { rerender } = render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
// Initially KB-001 should be active
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
// Click KB-002 to make it active
act(() => {
screen.getByTestId("terminal-tab-KB-002").click();
});
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-002");
// Rerender with only KB-001
rerender(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={[tasks[0]]} />
);
// Should switch back to KB-001
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
});
it("tab labels show task IDs", () => {
const tasks = [
createMockTask("KB-001"),
createMockTask("KB-002"),
];
mockUseMultiAgentLogs.mockReturnValue({
"KB-001": { entries: [], loading: false, clear: vi.fn() },
"KB-002": { entries: [], loading: false, clear: vi.fn() },
});
render(
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
);
const tab1 = screen.getByTestId("terminal-tab-KB-001");
const tab2 = screen.getByTestId("terminal-tab-KB-002");
expect(tab1.textContent).toContain("KB-001");
expect(tab2.textContent).toContain("KB-002");
});
});

View File

@@ -0,0 +1,420 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useMultiAgentLogs } from "../useMultiAgentLogs";
import { fetchAgentLogs } from "../../api";
// Mock the api module
vi.mock("../../api", () => ({
fetchAgentLogs: vi.fn().mockResolvedValue([]),
}));
const mockFetchAgentLogs = vi.mocked(fetchAgentLogs);
// Mock EventSource - track instances per hook render, not globally
class MockEventSource {
url: string;
listeners: Record<string, ((e: { data: string }) => void)[]> = {};
readyState = 0;
close = vi.fn();
constructor(url: string) {
this.url = url;
this.readyState = 1;
}
addEventListener(event: string, fn: (e: { data: string }) => void) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(fn);
}
// Helper to simulate a server event
_emit(event: string, data: unknown) {
for (const fn of this.listeners[event] || []) {
fn({ data: JSON.stringify(data) });
}
}
}
const originalEventSource = globalThis.EventSource;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).EventSource = MockEventSource;
mockFetchAgentLogs.mockReset().mockResolvedValue([]);
});
afterEach(() => {
(globalThis as unknown as Record<string, unknown>).EventSource = originalEventSource;
});
function getActiveConnections(): MockEventSource[] {
// Get all MockEventSource instances that haven't been closed
// We need to track this ourselves since the mock is recreated each time
const allSources: MockEventSource[] = [];
// Hook into the constructor to track instances
const OriginalMock = MockEventSource;
const instances: MockEventSource[] = [];
// Override to capture instances
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
constructor(url: string) {
super(url);
instances.push(this);
}
};
return instances;
}
describe("useMultiAgentLogs", () => {
it("initializes with empty entries for all provided task IDs", () => {
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
expect(result.current["KB-001"]).toBeDefined();
expect(result.current["KB-001"].entries).toEqual([]);
expect(result.current["KB-001"].loading).toBe(true);
expect(result.current["KB-002"]).toBeDefined();
expect(result.current["KB-002"].entries).toEqual([]);
expect(result.current["KB-002"].loading).toBe(true);
});
it("returns empty object when no task IDs provided", () => {
const { result } = renderHook(() => useMultiAgentLogs([]));
expect(Object.keys(result.current)).toHaveLength(0);
});
it("fetches historical logs for each task on mount", async () => {
const logs1 = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log1", type: "text" as const },
];
const logs2 = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-002", text: "log2", type: "text" as const },
];
mockFetchAgentLogs.mockImplementation((taskId) => {
if (taskId === "KB-001") return Promise.resolve(logs1);
if (taskId === "KB-002") return Promise.resolve(logs2);
return Promise.resolve([]);
});
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
await waitFor(() => {
expect(result.current["KB-001"].entries).toEqual(logs1);
expect(result.current["KB-002"].entries).toEqual(logs2);
});
expect(mockFetchAgentLogs).toHaveBeenCalledWith("KB-001");
expect(mockFetchAgentLogs).toHaveBeenCalledWith("KB-002");
});
it("opens SSE EventSource for each task ID", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
const instances: MockEventSource[] = [];
// Override to capture instances
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
constructor(url: string) {
super(url);
instances.push(this);
}
};
renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
await waitFor(() => {
// Filter to unique URLs (Strict Mode may create duplicates)
const urls = [...new Set(instances.map((es) => es.url))];
expect(urls).toContain("/api/tasks/KB-001/logs/stream");
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
});
});
it("merges live SSE events with historical entries", async () => {
const historical = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "old", type: "text" as const },
];
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
mockFetchAgentLogs.mockResolvedValue(historical);
const instances: MockEventSource[] = [];
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
constructor(url: string) {
super(url);
instances.push(this);
}
};
const { result } = renderHook(() => useMultiAgentLogs(["KB-001"]));
await waitFor(() => {
expect(result.current["KB-001"].entries).toHaveLength(1);
});
const es = instances.find((e) => e.url.includes("KB-001"));
expect(es).toBeDefined();
act(() => {
es!._emit("agent:log", {
timestamp: "2026-01-01T00:01:00Z",
taskId: "KB-001",
text: "new",
type: "text",
});
});
await waitFor(() => {
expect(result.current["KB-001"].entries).toHaveLength(2);
});
expect(result.current["KB-001"].entries[1].text).toBe("new");
});
it("closes all SSE connections on unmount (memory leak prevention)", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
const instances: MockEventSource[] = [];
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
constructor(url: string) {
super(url);
instances.push(this);
}
};
const { unmount } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
// Wait for connections to be established
await waitFor(() => {
expect(instances.length).toBeGreaterThanOrEqual(2);
});
// Get unique instances by URL (handling Strict Mode duplicates)
const uniqueByUrl = new Map<string, MockEventSource>();
for (const es of instances) {
if (!uniqueByUrl.has(es.url) || !es.close.mock?.calls?.length) {
uniqueByUrl.set(es.url, es);
}
}
const finalInstances = Array.from(uniqueByUrl.values());
unmount();
// Verify all final connections are closed
for (const es of finalInstances) {
expect(es.close).toHaveBeenCalled();
}
});
it("closes specific connection when task ID removed from array", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
const instances: MockEventSource[] = [];
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
constructor(url: string) {
super(url);
instances.push(this);
}
};
const { rerender } = renderHook(
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
{ initialProps: { taskIds: ["KB-001", "KB-002"] } },
);
await waitFor(() => {
expect(instances.length).toBeGreaterThanOrEqual(2);
});
// Get the last connection for each URL
const getConnection = (taskId: string) => {
const url = `/api/tasks/${taskId}/logs/stream`;
const matching = instances.filter((e) => e.url === url);
return matching[matching.length - 1];
};
const es1 = getConnection("KB-001");
const es2 = getConnection("KB-002");
rerender({ taskIds: ["KB-001"] });
await waitFor(() => {
expect(es2.close).toHaveBeenCalled();
});
expect(es1.close).not.toHaveBeenCalled();
});
it("opens new connection when task ID added to array", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
const instances: MockEventSource[] = [];
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
constructor(url: string) {
super(url);
instances.push(this);
}
};
const { rerender } = renderHook(
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
{ initialProps: { taskIds: ["KB-001"] } },
);
await waitFor(() => {
expect(instances.length).toBeGreaterThanOrEqual(1);
});
rerender({ taskIds: ["KB-001", "KB-002"] });
await waitFor(() => {
const urls = [...new Set(instances.map((es) => es.url))];
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
});
});
it("provides per-task clear function that resets entries", async () => {
const logs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log1", type: "text" as const },
{ timestamp: "2026-01-01T00:01:00Z", taskId: "KB-001", text: "log2", type: "text" as const },
];
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
mockFetchAgentLogs.mockResolvedValue(logs);
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
await waitFor(() => {
expect(result.current["KB-001"].entries).toHaveLength(2);
});
// Clear only KB-001
act(() => {
result.current["KB-001"].clear();
});
await waitFor(() => {
expect(result.current["KB-001"].entries).toHaveLength(0);
});
});
it("handles errors gracefully when fetching historical logs", async () => {
mockFetchAgentLogs.mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useMultiAgentLogs(["KB-001"]));
await waitFor(() => {
expect(result.current["KB-001"].loading).toBe(false);
});
expect(result.current["KB-001"].entries).toEqual([]);
});
it("only opens one connection per task ID (no duplicates)", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
const instances: MockEventSource[] = [];
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
constructor(url: string) {
super(url);
instances.push(this);
}
};
const { rerender } = renderHook(
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
{ initialProps: { taskIds: ["KB-001"] } },
);
await waitFor(() => {
expect(instances.length).toBeGreaterThanOrEqual(1);
});
const initialCount = instances.length;
// Re-render with same task ID (should not create new connection)
rerender({ taskIds: ["KB-001"] });
// Wait a bit
await new Promise((resolve) => setTimeout(resolve, 50));
// In strict mode, we may have more instances due to double-run,
// but the active (non-closed) connections should remain stable
const activeConnections = instances.filter((es) => !es.close.mock?.calls?.length);
expect(activeConnections.length).toBeLessThanOrEqual(initialCount);
});
it("handles SSE events for multiple tasks independently", async () => {
const logs1 = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "task1-old", type: "text" as const },
];
const logs2 = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-002", text: "task2-old", type: "text" as const },
];
mockFetchAgentLogs.mockImplementation((taskId) => {
if (taskId === "KB-001") return Promise.resolve(logs1);
if (taskId === "KB-002") return Promise.resolve(logs2);
return Promise.resolve([]);
});
const instances: MockEventSource[] = [];
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
constructor(url: string) {
super(url);
instances.push(this);
}
};
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
await waitFor(() => {
expect(result.current["KB-001"].entries).toHaveLength(1);
expect(result.current["KB-002"].entries).toHaveLength(1);
});
// Get the last connection for each URL
const getConnection = (taskId: string) => {
const url = `/api/tasks/${taskId}/logs/stream`;
const matching = instances.filter((e) => e.url === url);
return matching[matching.length - 1];
};
const es1 = getConnection("KB-001");
const es2 = getConnection("KB-002");
expect(es1).toBeDefined();
expect(es2).toBeDefined();
act(() => {
es1._emit("agent:log", {
timestamp: "2026-01-01T00:01:00Z",
taskId: "KB-001",
text: "task1-new",
type: "text",
});
});
await waitFor(() => {
expect(result.current["KB-001"].entries).toHaveLength(2);
expect(result.current["KB-002"].entries).toHaveLength(1);
});
act(() => {
es2._emit("agent:log", {
timestamp: "2026-01-01T00:01:00Z",
taskId: "KB-002",
text: "task2-new",
type: "text",
});
});
await waitFor(() => {
expect(result.current["KB-001"].entries).toHaveLength(2);
expect(result.current["KB-002"].entries).toHaveLength(2);
});
expect(result.current["KB-001"].entries[1].text).toBe("task1-new");
expect(result.current["KB-002"].entries[1].text).toBe("task2-new");
});
});

View File

@@ -0,0 +1,154 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@kb/core";
import { fetchAgentLogs } from "../api";
export interface TaskLogState {
entries: AgentLogEntry[];
loading: boolean;
clear: () => void;
}
export type LogStateMap = Record<string, TaskLogState>;
interface InitState {
entries: AgentLogEntry[];
loading: boolean;
es?: EventSource;
}
/**
* Hook that manages agent log fetching and live SSE streaming for multiple tasks.
*
* For each task ID in the provided array:
* 1. Fetches historical logs via GET /api/tasks/:id/logs
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
* 3. Merges historical + live entries in order
*
* When task IDs are added or removed, connections are opened/closed accordingly.
* When the component unmounts, all EventSources are closed to prevent memory leaks.
*/
export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
// Store state per task
const [stateMap, setStateMap] = useState<Record<string, InitState>>({});
// Ref to track active EventSources
const sourcesRef = useRef<Record<string, EventSource>>({});
// Create clear function for a specific task
const createClearFn = useCallback((taskId: string) => {
return () => {
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
return {
...prev,
[taskId]: { ...current, entries: [] },
};
});
};
}, []);
// Main effect to manage connections
useEffect(() => {
const currentIds = new Set(taskIds);
const sources = sourcesRef.current;
// Close connections for tasks no longer in the list
for (const [taskId, es] of Object.entries(sources)) {
if (!currentIds.has(taskId)) {
es.close();
delete sources[taskId];
// Remove state for disconnected task
setStateMap((prev) => {
const { [taskId]: _, ...rest } = prev;
return rest;
});
}
}
// Initialize state and connections for current tasks
for (const taskId of taskIds) {
// Initialize state if not present
setStateMap((prev) => {
if (prev[taskId]) return prev;
return { ...prev, [taskId]: { entries: [], loading: true } };
});
// Skip if already connected
if (sources[taskId]) continue;
let cancelled = false;
// Fetch historical logs and open SSE
const init = async () => {
try {
const historical = await fetchAgentLogs(taskId);
if (cancelled) return;
setStateMap((prev) => ({
...prev,
[taskId]: { ...prev[taskId], entries: historical, loading: false },
}));
} catch {
if (cancelled) return;
setStateMap((prev) => ({
...prev,
[taskId]: { ...prev[taskId], entries: [], loading: false },
}));
}
// Open SSE connection
const es = new EventSource(`/api/tasks/${taskId}/logs/stream`);
sources[taskId] = es;
es.addEventListener("agent:log", (e) => {
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
return {
...prev,
[taskId]: { ...current, entries: [...current.entries, entry] },
};
});
} catch {
// skip malformed events
}
});
};
init();
}
// Cleanup on effect re-run or unmount
return () => {
// In Strict Mode, React runs effects twice.
// We only want to close connections on actual unmount, not on every cleanup.
// The actual closing of connections for removed tasks is handled above.
};
}, [taskIds]);
// Close all connections on unmount
useEffect(() => {
return () => {
for (const es of Object.values(sourcesRef.current)) {
es.close();
}
sourcesRef.current = {};
};
}, []);
// Build result map
const result: LogStateMap = {};
for (const taskId of taskIds) {
const state = stateMap[taskId];
result[taskId] = {
entries: state?.entries ?? [],
loading: state?.loading ?? true,
clear: createClearFn(taskId),
};
}
return result;
}

View File

@@ -86,6 +86,37 @@ body {
color: var(--text);
background: var(--border);
}
.btn-icon--terminal {
position: relative;
}
.btn-icon--terminal:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-icon--terminal:disabled:hover {
color: var(--text-muted);
background: none;
}
.btn-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--in-progress);
color: var(--bg);
font-size: 10px;
font-weight: 700;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon--paused {
color: var(--triage);
}
@@ -2484,256 +2515,200 @@ body {
}
}
/* === Git Manager === */
.git-manager-layout {
display: flex;
height: 60vh;
min-height: 400px;
}
.git-manager-sidebar {
width: 160px;
border-right: 1px solid var(--border);
background: var(--surface);
/* === Terminal Modal === */
.terminal-modal {
width: 90vw;
max-width: 1200px;
min-height: 90vh;
max-height: 90vh;
background: var(--card);
display: flex;
flex-direction: column;
padding: 8px 0;
}
.git-manager-nav-item {
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
background: var(--surface);
padding: 0;
gap: 0;
}
.terminal-tabs {
display: flex;
flex: 1;
overflow-x: auto;
gap: 0;
padding: 0 4px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.terminal-tabs::-webkit-scrollbar {
height: 4px;
}
.terminal-tabs::-webkit-scrollbar-track {
background: transparent;
}
.terminal-tabs::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.terminal-tab {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
padding: 12px 16px;
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-muted);
font-size: 13px;
text-align: left;
cursor: pointer;
border-left: 3px solid transparent;
transition: all var(--transition-fast);
}
.git-manager-nav-item:hover {
background: var(--card);
color: var(--text);
}
.git-manager-nav-item.active {
background: var(--card);
color: var(--todo);
font-weight: 500;
border-left-color: var(--todo);
cursor: pointer;
white-space: nowrap;
transition: color var(--transition-fast), border-color var(--transition-fast);
position: relative;
min-height: 44px;
}
.git-manager-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
background: var(--bg);
.terminal-tab:hover {
color: var(--text);
background: var(--card-hover);
}
.git-manager-loading {
.terminal-tab--active {
color: var(--text);
border-bottom-color: var(--in-progress);
}
.terminal-tab--active:hover {
border-bottom-color: var(--in-progress);
}
.terminal-tab-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--in-progress);
border-radius: 3px 3px 0 0;
}
.terminal-tab-label {
font-family: "SF Mono", Monaco, Consolas, monospace;
font-size: 12px;
}
.terminal-tab--empty {
color: var(--text-muted);
cursor: default;
font-style: italic;
}
.terminal-tab--empty:hover {
background: none;
color: var(--text-muted);
}
.terminal-close {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px;
color: var(--text-muted);
}
/* Git Status */
.git-status-panel h4,
.git-commits-panel h4,
.git-branches-panel h4,
.git-worktrees-panel h4,
.git-remotes-panel h4 {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.git-status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.git-status-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.git-status-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.git-status-value {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
}
.git-status-commit {
font-family: monospace;
font-size: 12px;
color: var(--text-muted);
}
.git-status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.git-status-badge.clean {
background: rgba(63, 185, 80, 0.15);
color: var(--color-success);
}
.git-status-badge.dirty {
background: rgba(248, 81, 73, 0.15);
color: var(--color-error);
}
.git-ahead,
.git-behind,
.git-in-sync {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 500;
}
.git-ahead {
color: var(--color-success);
}
.git-behind {
color: var(--triage);
}
.git-in-sync {
color: var(--text-muted);
}
/* Git Commits */
.git-commits-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.git-commit-item {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.git-commit-header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
background: var(--card);
width: 44px;
height: 44px;
background: none;
border: none;
color: var(--text);
font-size: 13px;
text-align: left;
border-left: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast);
transition: color var(--transition-fast), background var(--transition-fast);
}
.git-commit-header:hover {
.terminal-close:hover {
color: var(--text);
background: var(--card-hover);
}
.git-commit-hash {
font-family: monospace;
font-size: 11px;
color: var(--text-muted);
background: var(--surface);
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
}
.git-commit-message {
.terminal-content {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.git-commit-meta {
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
}
.git-commit-diff {
padding: 12px;
background: var(--bg);
border-top: 1px solid var(--border);
}
.git-diff-loading {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.terminal-empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px;
justify-content: center;
gap: 12px;
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 40px;
}
.git-diff-stat {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.git-diff-patch {
font-family: monospace;
font-size: 11px;
.terminal-empty-state p {
margin: 0;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
}
.terminal-empty-state p:first-child {
font-size: 16px;
font-weight: 500;
color: var(--text);
}
.git-diff-error {
padding: 20px;
color: var(--color-error);
font-size: 12px;
.terminal-empty-state p:last-child {
font-size: 13px;
}
.git-load-more {
width: 100%;
padding: 10px;
margin-top: 12px;
.terminal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.terminal-task-info {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.terminal-task-id {
font-family: "SF Mono", Monaco, Consolas, monospace;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
flex-shrink: 0;
}
.terminal-task-title {
font-size: 13px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.terminal-clear-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -2741,234 +2716,80 @@ body {
font-size: 12px;
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.git-load-more:hover {
background: var(--card-hover);
.terminal-clear-btn:hover {
color: var(--text);
}
/* Git Branches */
.git-create-branch-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.git-create-branch-form input {
flex: 1;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
}
.git-create-branch-form input::placeholder {
color: var(--text-dim);
}
.git-branches-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.git-branch-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
transition: background var(--transition-fast);
}
.git-branch-item:hover {
border-color: var(--text-muted);
background: var(--card-hover);
}
.git-branch-item.current {
background: rgba(88, 166, 255, 0.1);
border-color: var(--todo);
}
.git-branch-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.git-branch-current-icon {
color: var(--todo);
}
.git-branch-remote {
font-size: 11px;
color: var(--text-muted);
font-weight: normal;
}
.git-branch-actions {
display: flex;
gap: 4px;
}
/* Git Worktrees */
.git-worktrees-stats {
display: flex;
gap: 16px;
margin-bottom: 16px;
padding: 8px 12px;
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
.git-worktrees-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.git-worktree-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.git-worktree-item.main {
border-color: var(--todo);
background: rgba(88, 166, 255, 0.05);
}
.git-worktree-info {
display: flex;
flex-direction: column;
gap: 4px;
.terminal-log-container {
flex: 1;
min-width: 0;
}
.git-worktree-path {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-family: monospace;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-worktree-badge {
display: inline-flex;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
}
.git-worktree-badge.main {
background: var(--todo);
color: var(--bg);
}
.git-worktree-badge.bare {
background: var(--text-muted);
color: var(--bg);
}
.git-worktree-branch {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-muted);
}
.git-worktree-task {
display: inline-flex;
padding: 2px 8px;
background: var(--triage);
color: var(--bg);
border-radius: 10px;
font-size: 10px;
font-weight: 600;
flex-shrink: 0;
}
/* Git Remotes */
.git-remote-status {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.git-remote-ahead,
.git-remote-behind {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius);
font-size: 12px;
font-weight: 500;
.terminal-log-container .agent-log-viewer {
flex: 1;
max-height: none;
background: var(--card);
border-radius: 0;
}
.git-remote-ahead {
background: rgba(63, 185, 80, 0.1);
color: var(--color-success);
/* === Terminal Modal Mobile Responsive === */
@media (max-width: 768px) {
.terminal-modal {
width: 100%;
max-width: 100%;
height: 100vh;
height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
border-radius: 0;
border: none;
}
.terminal-header {
padding-top: env(safe-area-inset-top, 0);
}
.terminal-tab {
min-height: 48px;
padding: 12px 14px;
font-size: 14px;
}
.terminal-tab-label {
font-size: 13px;
}
.terminal-close {
width: 48px;
height: 48px;
}
.terminal-toolbar {
padding: 10px 12px;
padding-bottom: max(10px, env(safe-area-inset-bottom, 0));
}
.terminal-task-title {
font-size: 12px;
max-width: 200px;
}
.terminal-clear-btn {
padding: 8px 12px;
min-height: 36px;
}
.terminal-empty-state {
padding: 24px;
padding-bottom: max(24px, env(safe-area-inset-bottom, 0));
}
}
.git-remote-behind {
background: rgba(210, 153, 34, 0.1);
color: var(--triage);
}
.git-remote-actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.git-remote-result {
padding: 12px;
border-radius: var(--radius);
font-size: 12px;
}
.git-remote-result.fetch,
.git-remote-result.success {
background: rgba(63, 185, 80, 0.1);
color: var(--color-success);
}
.git-remote-result.conflict {
background: rgba(248, 81, 73, 0.1);
color: var(--color-error);
}
.git-remote-result.error {
background: rgba(248, 81, 73, 0.1);
color: var(--color-error);
}