Merge branch 'feat/tool-definition-manifest' into dev

Conflicts resolved:
- models/__init__.py: kept both TerminalSessionModel (from dev) and
  ToolDefinitionManifest (from feature branch)
- alembic migration: kept full migration (already applied to DB)
- openspec/config.yaml: kept full config with SDD settings
This commit is contained in:
Alex Blank
2026-05-28 15:49:56 +02:00
35 changed files with 8186 additions and 3065 deletions
@@ -0,0 +1,161 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
const mockSessions: TerminalSessionInfo[] = [
{ id: "s1", name: "Session 1", status: "connected" },
{ id: "s2", name: "Session 2", status: "connecting" },
{ id: "s3", name: "Session 3", status: "disconnected" },
];
afterEach(() => {
cleanup();
});
describe("TerminalSessionTabs", () => {
it("renders all tabs", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
expect(screen.getByText("Session 1")).toBeInTheDocument();
expect(screen.getByText("Session 2")).toBeInTheDocument();
expect(screen.getByText("Session 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new session/i })).toBeInTheDocument();
});
it("clicking a tab calls onSelect", () => {
const onSelect = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={onSelect}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
fireEvent.click(screen.getAllByText("Session 2")[0]);
expect(onSelect).toHaveBeenCalledWith("s2");
});
it("close button calls onClose after confirmation", () => {
const onClose = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={onClose}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const closeButton = screen.getByLabelText("Close session Session 1");
// First click shows confirm
fireEvent.click(closeButton);
expect(screen.getByText("Close?")).toBeInTheDocument();
// Click confirm text
fireEvent.click(screen.getByText("Close?"));
expect(onClose).toHaveBeenCalledWith("s1");
});
it("double-click enables rename and Enter commits", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
});
it("double-click enables rename and Escape cancels", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByText("Session 1")).toBeInTheDocument();
});
it("plus button is disabled at 5 sessions", () => {
const fiveSessions: TerminalSessionInfo[] = Array.from({ length: 5 }, (_, i) => ({
id: `s${i + 1}`,
name: `Session ${i + 1}`,
status: "connected",
}));
render(
<TerminalSessionTabs
sessions={fiveSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const newButton = screen.getByRole("button", { name: /new session/i });
expect(newButton).toBeDisabled();
});
it("status dot reflects connection state", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(3);
expect(tabs[0].querySelector(".connected")).toBeInTheDocument();
expect(tabs[1].querySelector(".connecting")).toBeInTheDocument();
expect(tabs[2].querySelector(".disconnected")).toBeInTheDocument();
});
});
@@ -0,0 +1,167 @@
import React, { useState, useRef, useCallback } from "react";
export interface TerminalSessionInfo {
id: string;
name: string;
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export interface TerminalSessionTabsProps {
sessions: TerminalSessionInfo[];
activeSessionId: string;
onSelect: (sessionId: string) => void;
onClose: (sessionId: string) => void;
onCreate: () => void;
onRename: (sessionId: string, newName: string) => void;
isMobile?: boolean;
}
export const TerminalSessionTabs: React.FC<TerminalSessionTabsProps> = ({
sessions,
activeSessionId,
onSelect,
onClose,
onCreate,
onRename,
isMobile = false,
}) => {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const handleDoubleClick = useCallback((session: TerminalSessionInfo) => {
setRenamingId(session.id);
setRenameValue(session.name);
requestAnimationFrame(() => {
renameInputRef.current?.focus();
renameInputRef.current?.select();
});
}, []);
const commitRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
setRenameValue("");
}, [renamingId, renameValue, onRename]);
const cancelRename = useCallback(() => {
setRenamingId(null);
setRenameValue("");
}, []);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
commitRename();
} else if (e.key === "Escape") {
cancelRename();
}
},
[commitRename, cancelRename],
);
const handleCloseClick = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
if (confirmCloseId === sessionId) {
setConfirmCloseId(null);
onClose(sessionId);
} else {
setConfirmCloseId(sessionId);
// Auto-dismiss confirm after 3s
setTimeout(() => {
setConfirmCloseId((prev) => (prev === sessionId ? null : prev));
}, 3000);
}
},
[confirmCloseId, onClose],
);
const isMaxSessions = sessions.length >= 5;
return (
<div
className={`terminal-session-tabs ${isMobile ? "mobile" : ""}`}
role="tablist"
aria-label="Terminal sessions"
>
<div className="terminal-session-tabs-scroll" ref={scrollRef}>
{sessions.map((session) => {
const isActive = session.id === activeSessionId;
const isRenaming = renamingId === session.id;
const isConfirmingClose = confirmCloseId === session.id;
return (
<div
key={session.id}
className={`terminal-session-tab ${isActive ? "active" : ""}`}
role="tab"
aria-selected={isActive}
onClick={() => onSelect(session.id)}
onDoubleClick={() => handleDoubleClick(session)}
title={isRenaming ? "" : `${session.name} (${session.status})`}
>
<span
className={`terminal-session-tab-status ${session.status}`}
aria-hidden="true"
/>
{isRenaming ? (
<input
ref={renameInputRef}
className="terminal-session-tab-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
aria-label="Rename session"
/>
) : (
<span className="terminal-session-tab-name">
{session.name}
</span>
)}
{isConfirmingClose ? (
<button
className="terminal-session-tab-confirm"
onClick={(e) => {
e.stopPropagation();
setConfirmCloseId(null);
onClose(session.id);
}}
type="button"
>
Close?
</button>
) : (
<button
className="terminal-session-tab-close"
onClick={(e) => handleCloseClick(e, session.id)}
type="button"
aria-label={`Close session ${session.name}`}
tabIndex={-1}
>
×
</button>
)}
</div>
);
})}
<button
className="terminal-session-tab new-session"
onClick={onCreate}
disabled={isMaxSessions}
type="button"
aria-label="New session"
title={isMaxSessions ? "Maximum 5 sessions reached" : "New session"}
>
+
</button>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff