feat(FN-1656): complete Step 1 — implement canonical node-project counting rules

This commit is contained in:
gsxdsm
2026-04-12 17:36:18 -07:00
parent b958f12d8f
commit bf5ce66a07
9 changed files with 333 additions and 346 deletions

View File

@@ -373,12 +373,13 @@ describe("tablet header controls", () => {
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
});
it("does not render back-to-projects button on tablet", () => {
it("does not render split project button on tablet", () => {
renderTabletHeader({
projects: [{ id: "1", name: "Project One", path: "/path/one", status: "active" as const }],
currentProject: { id: "1", name: "Project One", path: "/path/one", status: "active" as const },
onViewAllProjects: noop,
});
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
});
it("shows switch project in overflow menu when multiple projects on tablet", () => {

View File

@@ -860,24 +860,25 @@ export interface PtyTerminalSessionInfo {
export function createTerminalSession(
cwd?: string,
cols?: number,
rows?: number
rows?: number,
projectId?: string
): Promise<PtyTerminalSession> {
return api<PtyTerminalSession>("/terminal/sessions", {
return api<PtyTerminalSession>(withProjectId("/terminal/sessions", projectId), {
method: "POST",
body: JSON.stringify({ cwd, cols, rows }),
});
}
/** Kill a PTY terminal session */
export function killPtyTerminalSession(sessionId: string): Promise<{ killed: boolean }> {
return api<{ killed: boolean }>(`/terminal/sessions/${encodeURIComponent(sessionId)}`, {
export function killPtyTerminalSession(sessionId: string, projectId?: string): Promise<{ killed: boolean }> {
return api<{ killed: boolean }>(withProjectId(`/terminal/sessions/${encodeURIComponent(sessionId)}`, projectId), {
method: "DELETE",
});
}
/** List active PTY terminal sessions */
export function listTerminalSessions(): Promise<PtyTerminalSessionInfo[]> {
return api<PtyTerminalSessionInfo[]>("/terminal/sessions");
export function listTerminalSessions(projectId?: string): Promise<PtyTerminalSessionInfo[]> {
return api<PtyTerminalSessionInfo[]>(withProjectId("/terminal/sessions", projectId));
}
// --- Git Management API ---

View File

@@ -1,15 +1,140 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check } from "lucide-react";
import type { ProjectInfo } from "../api";
import type { NodeConfig } from "@fusion/core";
import type { NodeConfig, ProjectStatus } from "@fusion/core";
import { fetchScripts } from "../api";
import { ProjectSelector } from "./ProjectSelector";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
import { NodeStatusIndicator } from "./NodeStatusIndicator";
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
export { useViewportMode };
// Status icon config for project selector dropdown
const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = {
active: { color: "var(--success)" },
paused: { color: "var(--warning)" },
errored: { color: "var(--color-error)" },
initializing: { color: "var(--info)" },
};
/**
* SplitProjectButton - A two-stage button for project navigation.
* Left half: navigates to "all projects" view.
* Right half: opens a dropdown to select a specific project.
*/
function SplitProjectButton({
projects,
currentProject,
onViewAll,
onSelectProject,
}: {
projects: ProjectInfo[];
currentProject: ProjectInfo | null;
onViewAll: () => void;
onSelectProject?: (project: ProjectInfo) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
const handleSelectProject = useCallback(
(project: ProjectInfo) => {
onSelectProject?.(project);
setIsOpen(false);
},
[onSelectProject]
);
return (
<div className="split-project-btn" ref={dropdownRef}>
{/* Left half: projects view button */}
<button
className="split-project-btn__left"
onClick={onViewAll}
title="View all projects"
data-testid="header-projects-btn"
>
<Grid3X3 size={14} />
<span>{currentProject?.name || "Projects"}</span>
</button>
{/* Right half: dropdown arrow (only when multiple projects) */}
{projects.length > 1 && (
<>
<div className="split-project-btn__divider" />
<button
className={`split-project-btn__right${isOpen ? " split-project-btn__right--open" : ""}`}
onClick={() => setIsOpen((prev) => !prev)}
title="Switch project"
aria-label="Switch project"
aria-expanded={isOpen}
aria-haspopup="listbox"
data-testid="project-selector-trigger"
>
<ChevronDown size={12} className={`split-project-btn__chevron${isOpen ? " split-project-btn__chevron--open" : ""}`} />
</button>
{isOpen && (
<div
className="split-project-btn__dropdown"
role="listbox"
aria-label="Select project"
data-testid="project-selector-dropdown"
>
{projects.map((project) => {
const isCurrent = currentProject?.id === project.id;
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
return (
<button
key={project.id}
className={`split-project-btn__item${isCurrent ? " split-project-btn__item--current" : ""}`}
onClick={() => handleSelectProject(project)}
role="option"
aria-selected={isCurrent}
>
<span
className="split-project-btn__item-dot"
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
/>
<div className="split-project-btn__item-info">
<span className="split-project-btn__item-name">{project.name}</span>
<span className="split-project-btn__item-path">
{project.path.split("/").slice(-2).join("/")}
</span>
</div>
{isCurrent && <Check size={14} className="split-project-btn__item-check" />}
</button>
);
})}
</div>
)}
</>
)}
</div>
);
}
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
function GitHubLogo({ size = 16 }: { size?: number }) {
return (
@@ -311,44 +436,14 @@ export function Header({
<h1 className="logo">Fusion</h1>
</div>
{/* Project Selector - shown when 2+ projects on desktop only */}
{!isCompact && projects.length > 1 && (
<div className="header-project-selector">
<ProjectSelector
projects={projects}
currentProject={currentProject || null}
onSelect={(project) => {
onSelectProject?.(project);
}}
onViewAll={onViewAllProjects || (() => {})}
/>
</div>
)}
{/* Back to All Projects button when viewing a specific project (desktop only) */}
{!isCompact && currentProject && onViewAllProjects && (
<button
className="header-back-button"
onClick={onViewAllProjects}
title="Back to All Projects"
data-testid="back-to-projects-btn"
>
<ChevronLeft size={14} />
<span>All Projects</span>
</button>
)}
{/* Projects button - always visible when at least 1 project exists (desktop only) */}
{/* Split-button Project Selector - left: projects view, right: project dropdown (desktop only) */}
{!isCompact && projects.length >= 1 && onViewAllProjects && (
<button
className="header-projects-btn"
onClick={onViewAllProjects}
title="View all projects"
data-testid="header-projects-btn"
>
<Grid3X3 size={14} />
<span>Projects</span>
</button>
<SplitProjectButton
projects={projects}
currentProject={currentProject ?? null}
onViewAll={onViewAllProjects}
onSelectProject={onSelectProject}
/>
)}
{/* Node selector and status indicator */}

View File

@@ -825,7 +825,7 @@ describe("Header", () => {
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
});
it("does not render back-to-projects button on mobile", () => {
it("does not render split project button on mobile", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
@@ -837,7 +837,7 @@ describe("Header", () => {
onViewAllProjects={vi.fn()}
/>
);
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
});
it("shows switch project item in overflow menu on mobile when multiple projects", () => {
@@ -999,18 +999,17 @@ describe("Header", () => {
});
});
// ── Multi-Project Selector ────────────────────────────────────
// ── Split Project Button ────────────────────────────────────
it("shows ProjectSelector when 2+ projects provided", () => {
it("shows split project button when 1+ projects provided with onViewAllProjects", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(<Header projects={projects} />);
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
render(<Header projects={projects} onViewAllProjects={vi.fn()} />);
expect(screen.getByTestId("header-projects-btn")).toBeDefined();
});
it("renders ProjectSelector within header-left when multiple projects exist", () => {
it("renders split project button within header-left when projects exist", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
@@ -1023,43 +1022,45 @@ describe("Header", () => {
onViewAllProjects={vi.fn()}
/>
);
// ProjectSelector should be inside header-left, not a sibling
// SplitProjectButton should be inside header-left
const headerLeft = container.querySelector(".header-left");
expect(headerLeft).not.toBeNull();
const selectorInLeft = headerLeft!.querySelector(".header-project-selector");
expect(selectorInLeft).not.toBeNull();
expect(selectorInLeft!.querySelector("[data-testid='project-selector-trigger']")).not.toBeNull();
const splitBtn = headerLeft!.querySelector(".split-project-btn");
expect(splitBtn).not.toBeNull();
expect(splitBtn!.querySelector("[data-testid='header-projects-btn']")).not.toBeNull();
});
it("does not show ProjectSelector with single project", () => {
it("does not show split project button when no projects", () => {
const { container } = render(<Header projects={[]} />);
expect(container.querySelector(".split-project-btn")).toBeNull();
});
it("does not show split project button without onViewAllProjects", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
const { container } = render(<Header projects={projects} />);
expect(container.querySelector(".project-selector")).toBeNull();
expect(container.querySelector(".split-project-btn")).toBeNull();
});
it("does not show ProjectSelector when no projects", () => {
const { container } = render(<Header projects={[]} />);
expect(container.querySelector(".project-selector")).toBeNull();
});
it("shows 'Back to All Projects' button when currentProject is set", () => {
it("shows project dropdown arrow only when 2+ projects", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onViewAllProjects={vi.fn()}
/>
);
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
render(<Header projects={projects} onViewAllProjects={vi.fn()} />);
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
});
it("calls onViewAllProjects when 'Back to All Projects' clicked", () => {
it("does not show project dropdown arrow with single project", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(<Header projects={projects} onViewAllProjects={vi.fn()} />);
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
});
it("left button calls onViewAllProjects when clicked", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
@@ -1072,19 +1073,11 @@ describe("Header", () => {
onViewAllProjects={onViewAllProjects}
/>
);
fireEvent.click(screen.getByTestId("back-to-projects-btn"));
fireEvent.click(screen.getByTestId("header-projects-btn"));
expect(onViewAllProjects).toHaveBeenCalled();
});
it("does not show 'Back to All Projects' when no currentProject", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(<Header projects={projects} currentProject={null} />);
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("calls onSelectProject when project selected from selector", () => {
it("calls onSelectProject when project selected from dropdown", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
@@ -1099,14 +1092,14 @@ describe("Header", () => {
/>
);
// Open selector
// Open dropdown
fireEvent.click(screen.getByTestId("project-selector-trigger"));
// Click on a project in the dropdown
fireEvent.click(screen.getByText("Project Two"));
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
});
it("shows current project name in selector trigger", () => {
it("shows current project name in left button", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
@@ -1120,7 +1113,25 @@ describe("Header", () => {
/>
);
expect(screen.getByText("Project One")).toBeDefined();
// Left button should show current project name
const leftBtn = screen.getByTestId("header-projects-btn");
expect(leftBtn.textContent).toContain("Project One");
});
it("shows 'Projects' label when no current project", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={null}
onViewAllProjects={vi.fn()}
/>
);
const leftBtn = screen.getByTestId("header-projects-btn");
expect(leftBtn.textContent).toContain("Projects");
});
// ── Modal Overlay Visibility ──────────────────────────────────

View File

@@ -153,7 +153,7 @@ describe("MultiProjectFlow", () => {
expect(viewMode).toBe("overview");
});
it("shows Projects button alongside Back button when currentProject is set", () => {
it("shows Projects button with current project name when currentProject is set", () => {
mockDesktopMatchMedia();
render(
@@ -170,9 +170,10 @@ describe("MultiProjectFlow", () => {
/>
);
// Both buttons should be present
expect(screen.getByTestId("header-projects-btn")).toBeDefined();
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
// The split button should show the current project name
const projectsBtn = screen.getByTestId("header-projects-btn");
expect(projectsBtn).toBeDefined();
expect(projectsBtn.textContent).toContain("Solo Project");
});
});
});

View File

@@ -255,7 +255,7 @@ describe("useTerminalSessions", () => {
result.current.closeTab(tabId);
});
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith(sessionId);
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith(sessionId, TEST_PROJECT_ID);
expect(result.current.tabs.length).toBe(0);
});
@@ -424,7 +424,7 @@ describe("useTerminalSessions", () => {
});
// Old session should be killed
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("session-1");
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("session-1", TEST_PROJECT_ID);
// Tab should have new session
expect(result.current.activeTab?.sessionId).toBe("session-new");

View File

@@ -159,7 +159,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
try {
// Get active server sessions with bounded timeout
const serverSessions = await withTimeout(
listTerminalSessions(),
listTerminalSessions(projectId),
BOOTSTRAP_LIST_TIMEOUT_MS,
"listTerminalSessions"
);
@@ -232,7 +232,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
// Small delay to avoid race condition with the validation effect
const timeout = setTimeout(() => {
withTimeout(
createTerminalSession(),
createTerminalSession(undefined, undefined, undefined, projectId),
BOOTSTRAP_CREATE_TIMEOUT_MS,
"createTerminalSession"
)
@@ -278,7 +278,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
* Internal create tab function (used for auto-creation and user-initiated creation)
*/
const createTabInternal = useCallback(async (): Promise<TerminalTab> => {
const session = await createTerminalSession();
const session = await createTerminalSession(undefined, undefined, undefined, projectId);
const newTab: TerminalTab = {
id: generateTabId(),
sessionId: session.sessionId,
@@ -319,7 +319,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
if (!tabToClose) return currentTabs;
// Non-blocking server session kill
killPtyTerminalSession(tabToClose.sessionId).catch((err) => {
killPtyTerminalSession(tabToClose.sessionId, projectId).catch((err) => {
console.warn(`Failed to kill terminal session ${tabToClose.sessionId}:`, err);
});
@@ -389,7 +389,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
if (!activeTab) return currentTabs;
// Kill the old session (non-blocking)
killPtyTerminalSession(activeTab.sessionId).catch((err) => {
killPtyTerminalSession(activeTab.sessionId, projectId).catch((err) => {
console.warn(`Failed to kill old session ${activeTab.sessionId}:`, err);
});
@@ -403,7 +403,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
if (!currentActiveTab) return;
// Create new session and update the tab's sessionId
const session = await createTerminalSession();
const session = await createTerminalSession(undefined, undefined, undefined, projectId);
setTabs((currentTabs) =>
currentTabs.map((tab) =>
@@ -434,7 +434,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
if (!currentActiveTab) return;
try {
const session = await createTerminalSession();
const session = await createTerminalSession(undefined, undefined, undefined, projectId);
setTabs((currentTabs) =>
currentTabs.map((tab) =>

View File

@@ -6476,10 +6476,8 @@ body {
padding-top: max(var(--space-md), env(safe-area-inset-top, 0px));
}
/* Hide project selector and back button on mobile (belt-and-suspenders with conditional rendering) */
.header-project-selector,
.header-back-button,
.header-projects-btn {
/* Hide split project button on mobile (belt-and-suspenders with conditional rendering) */
.split-project-btn {
display: none;
}
@@ -6949,10 +6947,8 @@ body {
padding: var(--space-md) var(--space-lg);
}
/* Hide project selector and back button on tablet (controlled by React rendering too) */
.header-project-selector,
.header-back-button,
.header-projects-btn {
/* Hide split project button on tablet (controlled by React rendering too) */
.split-project-btn {
display: none;
}
@@ -18335,103 +18331,85 @@ html .column.drag-over * {
color: var(--text);
}
/* === Project Selector === */
.header-back-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.header-back-button:hover {
background: var(--card-hover);
border-color: var(--text-dim);
color: var(--text);
}
.header-projects-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.header-projects-btn:hover {
background: var(--card-hover);
border-color: var(--text-dim);
color: var(--text);
}
.project-selector {
/* === Split Project Button === */
.split-project-btn {
position: relative;
}
.project-selector__trigger {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 10px;
align-items: stretch;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 12px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
overflow: visible;
transition: border-color var(--transition-fast);
}
.project-selector__trigger:hover {
background: var(--card-hover);
.split-project-btn:hover {
border-color: var(--text-dim);
}
.project-selector__trigger.open {
border-color: var(--todo);
}
.project-selector__trigger-icon {
flex-shrink: 0;
.split-project-btn__left {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: transparent;
border: none;
color: var(--text-muted);
}
.project-selector__trigger-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
white-space: nowrap;
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
.project-selector__trigger-chevron {
flex-shrink: 0;
.split-project-btn__left:hover {
background: var(--card-hover);
color: var(--text);
}
.split-project-btn__divider {
width: 1px;
background: var(--border);
align-self: stretch;
margin: 4px 0;
}
.split-project-btn__right {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 6px;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.split-project-btn__right:hover {
background: var(--card-hover);
color: var(--text);
}
.split-project-btn__right--open {
color: var(--text);
}
.split-project-btn__chevron {
transition: transform var(--transition-fast);
}
.project-selector__trigger-chevron.rotate {
.split-project-btn__chevron--open {
transform: rotate(180deg);
}
.project-selector__dropdown {
.split-project-btn__dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
left: 0;
z-index: 100;
min-width: 280px;
min-width: 240px;
max-width: 360px;
padding: var(--space-sm);
border: 1px solid var(--border);
@@ -18440,76 +18418,7 @@ html .column.drag-over * {
box-shadow: var(--shadow-lg);
}
.project-selector__search {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 10px;
margin-bottom: var(--space-sm);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.project-selector__search-icon {
flex-shrink: 0;
color: var(--text-muted);
}
.project-selector__search-input {
flex: 1;
min-width: 0;
background: none;
border: none;
color: var(--text);
font-size: 13px;
outline: none;
}
.project-selector__search-input::placeholder {
color: var(--text-dim);
}
.project-selector__search-clear {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xs);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
}
.project-selector__search-clear:hover {
background: var(--border);
color: var(--text);
}
.project-selector__section {
margin-bottom: var(--space-sm);
}
.project-selector__section:last-of-type {
margin-bottom: 0;
}
.project-selector__section-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
margin-bottom: var(--space-xs);
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.project-selector__item {
.split-project-btn__item {
width: 100%;
display: flex;
align-items: center;
@@ -18525,30 +18434,22 @@ html .column.drag-over * {
transition: background var(--transition-fast);
}
.project-selector__item:hover {
.split-project-btn__item:hover {
background: var(--card-hover);
}
.project-selector__item.highlighted {
.split-project-btn__item--current {
background: var(--card-hover);
outline: 1px solid var(--todo);
}
.project-selector__item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.project-selector__item-check {
.split-project-btn__item-dot {
flex-shrink: 0;
color: var(--todo);
width: 8px;
height: 8px;
border-radius: 50%;
}
.project-selector__item-info {
.split-project-btn__item-info {
flex: 1;
min-width: 0;
display: flex;
@@ -18556,7 +18457,14 @@ html .column.drag-over * {
gap: 2px;
}
.project-selector__item-path {
.split-project-btn__item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.split-project-btn__item-path {
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
@@ -18564,80 +18472,36 @@ html .column.drag-over * {
white-space: nowrap;
}
.project-selector__no-results {
padding: 12px 10px;
font-size: 13px;
color: var(--text-dim);
text-align: center;
.split-project-btn__item-check {
flex-shrink: 0;
color: var(--todo);
}
.project-selector__footer {
margin-top: var(--space-sm);
padding-top: 8px;
border-top: 1px solid var(--border);
}
.project-selector__view-all {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: 8px 10px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.project-selector__view-all:hover {
background: var(--card-hover);
border-color: var(--text-dim);
color: var(--text);
}
.project-selector__view-all.highlighted {
background: var(--card-hover);
border-color: var(--todo);
color: var(--text);
}
/* Light theme overrides */
[data-theme="light"] .project-selector__trigger {
/* Light theme overrides for split project button */
[data-theme="light"] .split-project-btn {
background: var(--surface);
}
[data-theme="light"] .project-selector__trigger:hover {
[data-theme="light"] .split-project-btn__left:hover {
background: var(--card-hover);
}
[data-theme="light"] .project-selector__dropdown {
[data-theme="light"] .split-project-btn__right:hover {
background: var(--card-hover);
}
[data-theme="light"] .split-project-btn__dropdown {
background: var(--surface);
}
[data-theme="light"] .project-selector__search {
background: var(--card);
}
[data-theme="light"] .project-selector__item:hover {
[data-theme="light"] .split-project-btn__item:hover {
background: var(--card-hover);
}
[data-theme="light"] .project-selector__item.highlighted {
[data-theme="light"] .split-project-btn__item--current {
background: rgba(88, 166, 255, 0.1);
}
[data-theme="light"] .project-selector__view-all {
border-color: var(--border);
}
[data-theme="light"] .project-selector__view-all:hover {
background: var(--card-hover);
}
/* === Project Content Wrapper (footer-safe layout) === */
/**

View File

@@ -5773,7 +5773,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("command exceeds maximum length of 4096 characters");
}
const rootDir = store.getRootDir();
const scopedStore = await getScopedStore(req);
const rootDir = scopedStore.getRootDir();
const result = terminalSessionManager.createSession(command, rootDir);
if (result.error) {
@@ -5927,7 +5928,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.post("/terminal/sessions", async (req, res) => {
try {
const { cwd, cols, rows } = req.body;
const terminalService = getTerminalService(store.getRootDir());
const scopedStore = await getScopedStore(req);
const terminalService = getTerminalService(scopedStore.getRootDir());
const result = await terminalService.createSession({
cwd,
@@ -5964,9 +5966,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* List all active PTY terminal sessions.
* Returns: [{ id: string, cwd: string, shell: string, createdAt: string }]
*/
router.get("/terminal/sessions", async (_req, res) => {
router.get("/terminal/sessions", async (req, res) => {
try {
const terminalService = getTerminalService(store.getRootDir());
const scopedStore = await getScopedStore(req);
const terminalService = getTerminalService(scopedStore.getRootDir());
const sessions = terminalService.getAllSessions();
res.json(
@@ -5991,10 +5994,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* Kill a PTY terminal session.
* Returns: { killed: boolean }
*/
router.delete("/terminal/sessions/:id", (req, res) => {
router.delete("/terminal/sessions/:id", async (req, res) => {
try {
const { id } = req.params;
const terminalService = getTerminalService(store.getRootDir());
const scopedStore = await getScopedStore(req);
const terminalService = getTerminalService(scopedStore.getRootDir());
const killed = terminalService.killSession(id);
@@ -6118,11 +6122,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* List available file browser workspaces.
* Returns: { project: string; tasks: Array<{ id: string; title?: string; worktree: string }> }
*/
router.get("/workspaces", async (_req, res) => {
router.get("/workspaces", async (req, res) => {
try {
const tasks = await store.listTasks({ slim: true, includeArchived: false });
const scopedStore = await getScopedStore(req);
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
res.json({
project: store.getRootDir(),
project: scopedStore.getRootDir(),
tasks: tasks
.filter((task) => typeof task.worktree === "string" && task.worktree.length > 0 && existsSync(task.worktree))
.map((task) => ({
@@ -6147,9 +6152,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.get("/files", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { path: subPath, workspace } = req.query;
const workspaceId = typeof workspace === "string" && workspace.length > 0 ? workspace : "project";
const result = await listWorkspaceFiles(store, workspaceId, typeof subPath === "string" ? subPath : undefined);
const result = await listWorkspaceFiles(scopedStore, workspaceId, typeof subPath === "string" ? subPath : undefined);
res.json(result);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6175,11 +6181,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.get("/files/{*filepath}", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
const result = await readWorkspaceFile(store, workspace, filePath);
const result = await readWorkspaceFile(scopedStore, workspace, filePath);
res.json(result);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6208,17 +6215,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/files/{*filepath}", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const { content } = req.body;
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
if (typeof content !== "string") {
throw badRequest("content is required and must be a string");
}
const result = await writeWorkspaceFile(store, workspace, filePath, content);
const result = await writeWorkspaceFile(scopedStore, workspace, filePath, content);
res.json(result);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6259,6 +6267,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/files/{*filepath}/copy", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { filePath, workspace } = extractFileParams(req);
const { destination } = req.body;
@@ -6266,7 +6275,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("destination is required and must be a string");
}
const result = await copyWorkspaceFile(store, workspace, filePath, destination);
const result = await copyWorkspaceFile(scopedStore, workspace, filePath, destination);
res.json(result);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6294,6 +6303,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/files/{*filepath}/move", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { filePath, workspace } = extractFileParams(req);
const { destination } = req.body;
@@ -6301,7 +6311,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("destination is required and must be a string");
}
const result = await moveWorkspaceFile(store, workspace, filePath, destination);
const result = await moveWorkspaceFile(scopedStore, workspace, filePath, destination);
res.json(result);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6330,8 +6340,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/files/{*filepath}/delete", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { filePath, workspace } = extractFileParams(req);
const result = await deleteWorkspaceFile(store, workspace, filePath);
const result = await deleteWorkspaceFile(scopedStore, workspace, filePath);
res.json(result);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6358,6 +6369,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/files/{*filepath}/rename", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { filePath, workspace } = extractFileParams(req);
const { newName } = req.body;
@@ -6365,7 +6377,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("newName is required and must be a string");
}
const result = await renameWorkspaceFile(store, workspace, filePath, newName);
const result = await renameWorkspaceFile(scopedStore, workspace, filePath, newName);
res.json(result);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6392,8 +6404,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.get("/files/{*filepath}/download", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(store, workspace, filePath);
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(scopedStore, workspace, filePath);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
@@ -6427,8 +6440,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.get("/files/{*filepath}/download-zip", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, dirName } = await getWorkspaceFolderForZip(store, workspace, filePath);
const { absolutePath, dirName } = await getWorkspaceFolderForZip(scopedStore, workspace, filePath);
const archiver = await import("archiver");
const archive = archiver.default("zip", { zlib: { level: 6 } });