Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions desktop/frontend/src/__tests__/project-tree-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Run: tsx src/__tests__/project-tree-runtime.test.ts

import {
projectTreeFolderDisclosure,
defaultExpandedProjectTreeKeys,
projectTreeTopicOpenRequest,
} from "../components/ProjectTree";
Expand Down Expand Up @@ -83,5 +84,38 @@ eq(
"regular project topic still opens by topic",
);

eq(
projectTreeFolderDisclosure(false, true),
{
canExpand: false,
isOpen: false,
ariaExpanded: undefined,
iconStackClassName: "project-tree__icon-stack",
},
"empty project folders are not exposed as expandable disclosure rows",
);

eq(
projectTreeFolderDisclosure(true, false),
{
canExpand: true,
isOpen: false,
ariaExpanded: false,
iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
},
"collapsed project folders keep disclosure semantics when children exist",
);

eq(
projectTreeFolderDisclosure(true, true),
{
canExpand: true,
isOpen: true,
ariaExpanded: true,
iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
},
"expanded project folders can show the open-folder state only when children exist",
);

console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
89 changes: 55 additions & 34 deletions desktop/frontend/src/components/ProjectTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// new topic.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
import { Archive, ArrowDown, ChevronRight, Pencil, Plus, Folder, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, History, Check, ListCollapse, ListRestart, MessageSquare, Clock, Pin, MoreHorizontal, SquarePen, Minimize2, Maximize2 } from "lucide-react";
import { Archive, ArrowDown, ChevronRight, Pencil, Plus, Folder, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, History, Check, ListCollapse, ListRestart, MessageSquare, Clock, Pin, MoreHorizontal, Minimize2, Maximize2 } from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import type { ProjectNode, ProjectTopicStatus } from "../lib/types";
Expand Down Expand Up @@ -69,6 +69,24 @@ export function projectTreeTopicOpenRequest(node: ProjectNode): ProjectTreeTopic
};
}

export type ProjectTreeFolderDisclosure = {
canExpand: boolean;
isOpen: boolean;
ariaExpanded?: boolean;
iconStackClassName: string;
};

export function projectTreeFolderDisclosure(hasChildren: boolean, isExpanded: boolean): ProjectTreeFolderDisclosure {
const canExpand = hasChildren;
const isOpen = canExpand && isExpanded;
return {
canExpand,
isOpen,
ariaExpanded: canExpand ? isExpanded : undefined,
iconStackClassName: `project-tree__icon-stack${canExpand ? " project-tree__icon-stack--expandable" : ""}`,
};
}

function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string, activeSessionPath?: string): boolean {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
if (node.sessionPath) return Boolean(activeSessionPath && activeSessionPath === node.sessionPath);
Expand Down Expand Up @@ -921,6 +939,7 @@ export function ProjectTree({
const children = asArray(node.children);
const isExpanded = query.trim() ? true : expanded.has(key);
const hasChildren = children.length > 0;
const folderDisclosure = projectTreeFolderDisclosure(hasChildren, isExpanded);

if (isTopicNode(node) || isRuntimeSessionNode(node)) {
const isSessionNode = isRuntimeSessionNode(node);
Expand Down Expand Up @@ -1046,8 +1065,8 @@ export function ProjectTree({
</span>
)}
</span>
{compactTopics && (timeLabel || showStatusInSide) && (
<span className="project-tree__topic-side" aria-hidden="true">
{compactTopics && (
<span className={`project-tree__topic-side${!timeLabel && !showStatusInSide ? " project-tree__topic-side--empty" : ""}`} aria-hidden="true">
{showStatusInSide && <span className={`project-tree__topic-state project-tree__topic-state--${status}`} title={statusLabel} />}
{timeLabel && <span className="project-tree__topic-time">{timeLabel}</span>}
</span>
Expand Down Expand Up @@ -1325,7 +1344,7 @@ export function ProjectTree({

if (editingProject?.key === key) {
return (
<div key={key}>
<div key={key} className="project-tree__project-wrapper">
<div
className={`project-tree__folder project-tree__folder--editing${projectActive ? " project-tree__folder--active" : ""}`}
style={{ paddingLeft: 8 + depth * 16 }}
Expand Down Expand Up @@ -1354,7 +1373,7 @@ export function ProjectTree({
}

return (
<div key={key}>
<div key={key} className="project-tree__project-wrapper">
<div
className={`project-tree__folder${scopeClass}${pinnedClass}${draggableProject ? " project-tree__folder--draggable" : ""}${projectActive ? " project-tree__folder--active" : ""}${projectMenuOpen ? " project-tree__folder--menu-open" : ""}${dragProjectRoot === projectDragKey ? " project-tree__folder--dragging" : ""}${projectDropPosition ? ` project-tree__folder--drop-${projectDropPosition}` : ""}`}
style={accentStyle}
Expand All @@ -1374,25 +1393,25 @@ export function ProjectTree({
className="project-tree__folder-main"
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => {
if (hasChildren) toggleExpand(key);
if (folderDisclosure.canExpand) toggleExpand(key);
}}
onKeyDown={(event) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
openProjectMenu(event);
}
}}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-expanded={folderDisclosure.ariaExpanded}
>
{hasChildren ? (
<span className={`project-tree__chevron${isExpanded ? " project-tree__chevron--open" : ""}`}>
<ChevronRight size={12} />
</span>
) : (
<span style={{ width: 12 }} />
)}
<Folder size={12} />
<span className={folderDisclosure.iconStackClassName}>
{folderDisclosure.canExpand && (
<span className={`project-tree__chevron project-tree__chevron--on-hover${folderDisclosure.isOpen ? " project-tree__chevron--open" : ""}`}>
<ChevronRight size={16} strokeWidth={2} />
</span>
)}
{folderDisclosure.isOpen ? <FolderOpen size={14} className="project-tree__folder-icon" /> : <Folder size={14} className="project-tree__folder-icon" />}
</span>
<span className="project-tree__folder-color" aria-hidden="true" />
<span className="project-tree__folder-label">{projectLabel}</span>
<span className={`project-tree__folder-label${!hasChildren ? " project-tree__folder-label--empty" : ""}`}>{projectLabel}</span>
</button>
{compactTopics && (
<Tooltip label={t("projectTree.projectActions")} className="project-tree__folder-action-slot">
Expand Down Expand Up @@ -1423,7 +1442,7 @@ export function ProjectTree({
void handleCreateTopic(scope, projectRoot, key);
}}
>
{compactTopics ? <SquarePen size={15} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
{compactTopics ? <Plus size={15} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
</button>
</Tooltip>
<ContextMenu
Expand Down Expand Up @@ -1801,24 +1820,26 @@ export function ProjectTree({
/>
</label>
{compactTopics ? (
<div className="project-tree__list project-tree__list--workbench">
{!hasWorkbenchRows ? (
renderEmptyState()
) : (
<>
{workbenchTreeSections.pinned.length > 0 && (
<div className="project-tree__section project-tree__section--pinned">
<div className="project-tree__section-title">{t("projectTree.pinnedTitle")}</div>
{workbenchTreeSections.pinned.map((node) => renderNode(node, 0, "pinned"))}
<>
{renderProjectHeader("workbench")}
<div className="project-tree__list project-tree__list--workbench">
{!hasWorkbenchRows ? (
renderEmptyState()
) : (
<>
{workbenchTreeSections.pinned.length > 0 && (
<div className="project-tree__section project-tree__section--pinned">
<div className="project-tree__section-title">{t("projectTree.pinnedTitle")}</div>
{workbenchTreeSections.pinned.map((node) => renderNode(node, 0, "pinned"))}
</div>
)}
<div className="project-tree__section project-tree__section--projects">
{workbenchTreeSections.projects.map((node) => renderNode(node, 0, "projects"))}
</div>
)}
<div className="project-tree__section project-tree__section--projects">
{renderProjectHeader("workbench")}
{workbenchTreeSections.projects.map((node) => renderNode(node, 0, "projects"))}
</div>
</>
)}
</div>
</>
)}
</div>
</>
) : (
<>
{renderProjectHeader("classic")}
Expand Down
169 changes: 168 additions & 1 deletion desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -17110,7 +17110,7 @@ a[href] {
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: grid-template-rows 0.2s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.16s ease, transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
transition: opacity 0.16s ease, transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}

.project-tree__children--expanded {
Expand Down Expand Up @@ -17139,6 +17139,173 @@ a[href] {
transform: rotate(90deg);
}

/* Project tree: icon stack holds chevron + folder icon at same position */
.project-tree__icon-stack {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex: 0 0 auto;
position: relative;
}
.project-tree__icon-stack > .project-tree__folder-icon {
width: 14px;
height: 14px;
color: var(--fg-faint);
}
.project-tree__icon-stack > .project-tree__chevron {
position: absolute;
color: var(--fg-faint);
}
/* Override workbench width constraint on chevron */
.sidebar--workbench .project-tree__icon-stack > .project-tree__chevron {
width: auto;
height: auto;
}
.project-tree__icon-stack > .project-tree__chevron > svg {
display: block;
}
/* Default: hide chevron, show folder icon */
.project-tree__chevron--on-hover {
display: none;
}
/* Hover: show chevron, hide folder icon */
:root[data-theme-style] .project-tree__folder-main:hover .project-tree__icon-stack--expandable .project-tree__chevron--on-hover,
.project-tree__folder-main:hover .project-tree__icon-stack--expandable .project-tree__chevron--on-hover {
display: inline-flex;
}
:root[data-theme-style] .project-tree__folder-main:hover .project-tree__icon-stack--expandable > .project-tree__folder-icon,
.project-tree__folder-main:hover .project-tree__icon-stack--expandable > .project-tree__folder-icon {
display: none;
}
/* Topic hover/active effect: full row width (remove left margin) */
:root[data-theme-style] .sidebar--workbench .project-tree__topic {
margin-left: 0;
}

/* Project name de-emphasis: empty projects (no children) use lighter color */
:root[data-theme-style] .project-tree__folder-label--empty {
color: var(--fg-faint);
}
:root[data-theme-style] .project-tree__folder-label:not(.project-tree__folder-label--empty) {
color: color-mix(in srgb, var(--fg-dim) 58%, var(--fg-faint) 42%);
}
:root[data-theme-style] .project-tree__topic-label {
color: var(--fg);
font-weight: 550;
}

/* Extra spacing after expanded project section */
/* Extra spacing only after expanded project that actually has children */
.project-tree__project-wrapper {
contain: layout;
}
.project-tree__project-wrapper:has(.project-tree__folder-main[aria-expanded="true"]):has(.project-tree__children) {
margin-bottom: 6px;
}

/* Weaken topic time display */
:root[data-theme-style] .sidebar--workbench .project-tree__topic-side {
font-size: 11px;
color: color-mix(in srgb, var(--fg-faint) 72%, transparent);
}
:root[data-theme-style] .sidebar--workbench .project-tree__topic-side--empty {
visibility: hidden;
}
/* Ensure consistent topic height regardless of time/status presence */
:root[data-theme-style] .sidebar--workbench .project-tree__topic {
min-height: 34px;
height: 34px;
box-sizing: border-box;
}
:root[data-theme-style] .sidebar--workbench .project-tree__topic-main {
min-height: 34px;
height: 34px;
box-sizing: border-box;
}

/* Sidebar: keep search & header fixed, only list scrolls */
:root[data-theme-style] .sidebar--workbench .project-tree {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
:root[data-theme-style] .sidebar--workbench .project-tree__search,
:root[data-theme-style] .sidebar--workbench .project-tree__header {
flex: 0 0 auto;
}
:root[data-theme-style] .sidebar--workbench .project-tree__list,
:root[data-theme-style] .sidebar--workbench .project-tree__list--workbench {
flex: 1;
min-height: 0;
overflow-y: auto;
}

/* Folder: no active background, but very weak hover hint */
:root[data-theme-style] .sidebar--workbench .project-tree__folder:hover {
background: color-mix(in srgb, var(--sidebar-hover) 60%, transparent);
}
:root[data-theme-style] .sidebar--workbench .project-tree__folder--active,
:root[data-theme-style] .sidebar--workbench .project-tree__folder--menu-open,
:root[data-theme-style] .sidebar--workbench .project-tree__folder--active:hover,
:root[data-theme-style] .sidebar--workbench .project-tree__folder--menu-open:hover {
background: transparent;
}

/* Topic name left-aligned with project name */
:root[data-theme-style] .sidebar--workbench .project-tree__topic-main {
padding-left: 30px !important;
}

/* Active topic: keep text style unchanged (no color/font-weight change) */
:root[data-theme-style] .sidebar--workbench .project-tree__topic--active .project-tree__topic-label {
color: var(--fg);
font-weight: 550;
}

/* Topic action icons (pin, archive): clean, no background, no border */
:root[data-theme-style] .sidebar--workbench .project-tree__topic-action {
background: transparent;
border-color: transparent;
box-shadow: none;
}
:root[data-theme-style] .sidebar--workbench .project-tree__topic-action:hover {
background: transparent;
border-color: transparent;
box-shadow: none;
transform: none;
}

/* Folder action icons: hidden by default, show on folder hover */
:root[data-theme-style] .sidebar--workbench .project-tree__folder-action-slot {
opacity: 0;
pointer-events: none;
transition: opacity var(--dur-fast);
}
:root[data-theme-style] .sidebar--workbench .project-tree__folder:hover .project-tree__folder-action-slot,
:root[data-theme-style] .sidebar--workbench .project-tree__folder:focus-within .project-tree__folder-action-slot,
:root[data-theme-style] .sidebar--workbench .project-tree__folder-action-slot:focus-within {
opacity: 1;
pointer-events: auto;
}

/* Remove right-side slide animations on topic hover */
:root[data-theme-style] .sidebar--workbench .project-tree__topic-actions {
transform: translateY(-50%) translateX(0);
}
:root[data-theme-style] .sidebar--workbench .project-tree__topic:hover .project-tree__topic-actions,
:root[data-theme-style] .sidebar--workbench .project-tree__topic:focus-within .project-tree__topic-actions,
:root[data-theme-style] .sidebar--workbench .project-tree__topic--menu-open .project-tree__topic-actions {
transform: translateY(-50%) translateX(0);
}
:root[data-theme-style] .sidebar--workbench .project-tree__topic:hover .project-tree__topic-side,
:root[data-theme-style] .sidebar--workbench .project-tree__topic:focus-within .project-tree__topic-side,
:root[data-theme-style] .sidebar--workbench .project-tree__topic--menu-open .project-tree__topic-side {
transform: translateX(0);
}

@media (prefers-reduced-motion: reduce) {
.project-tree__children,
.project-tree__chevron {
Expand Down