diff --git a/package-lock.json b/package-lock.json index 8943044e8..5e8a8aa9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1849,7 +1848,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1910,7 +1908,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2117,8 +2114,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/acorn": { "version": "8.15.0", @@ -2126,7 +2122,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2438,7 +2433,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3120,7 +3114,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6002,7 +5995,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6126,7 +6118,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6136,7 +6127,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7088,7 +7078,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7278,7 +7267,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 724628d91..10b5aba28 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -1,13 +1,23 @@ import type { RateLimitSnapshot, ThreadSummary, WorkspaceInfo } from "../../../types"; -import { FolderKanban, Layers, ScrollText, Settings } from "lucide-react"; import { createPortal } from "react-dom"; -import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; -import { Menu, MenuItem } from "@tauri-apps/api/menu"; -import { LogicalPosition } from "@tauri-apps/api/dpi"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { formatRelativeTime, formatRelativeTimeShort } from "../../../utils/time"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { SidebarCornerActions } from "./SidebarCornerActions"; +import { SidebarFooter } from "./SidebarFooter"; +import { SidebarHeader } from "./SidebarHeader"; +import { ThreadList } from "./ThreadList"; +import { ThreadLoading } from "./ThreadLoading"; +import { WorktreeSection } from "./WorktreeSection"; +import { WorkspaceCard } from "./WorkspaceCard"; +import { WorkspaceGroup } from "./WorkspaceGroup"; +import { useCollapsedGroups } from "../hooks/useCollapsedGroups"; +import { useSidebarMenus } from "../hooks/useSidebarMenus"; +import { useSidebarScrollFade } from "../hooks/useSidebarScrollFade"; +import { useThreadRows } from "../hooks/useThreadRows"; +import { getUsageLabels } from "../utils/usageLabels"; +import { formatRelativeTimeShort } from "../../../utils/time"; const COLLAPSED_GROUPS_STORAGE_KEY = "codexmonitor.collapsedGroups"; +const ADD_MENU_WIDTH = 200; type WorkspaceGroupSection = { id: string | null; @@ -84,24 +94,6 @@ export function Sidebar({ const [expandedWorkspaces, setExpandedWorkspaces] = useState( new Set(), ); - const [collapsedGroups, setCollapsedGroups] = useState>(() => { - if (typeof window === "undefined") { - return new Set(); - } - const raw = window.localStorage.getItem(COLLAPSED_GROUPS_STORAGE_KEY); - if (!raw) { - return new Set(); - } - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return new Set(parsed.filter((value) => typeof value === "string")); - } - } catch { - // Ignore invalid stored data. - } - return new Set(); - }); const [addMenuAnchor, setAddMenuAnchor] = useState<{ workspaceId: string; top: number; @@ -109,88 +101,60 @@ export function Sidebar({ width: number; } | null>(null); const addMenuRef = useRef(null); - const sidebarBodyRef = useRef(null); - const [scrollFade, setScrollFade] = useState({ top: false, bottom: false }); - - const getThreadRows = useCallback( - (threads: ThreadSummary[], isExpanded: boolean) => { - const threadIds = new Set(threads.map((thread) => thread.id)); - const childrenByParent = new Map(); - const roots: ThreadSummary[] = []; - - threads.forEach((thread) => { - const parentId = threadParentById[thread.id]; - if (parentId && parentId !== thread.id && threadIds.has(parentId)) { - const list = childrenByParent.get(parentId) ?? []; - list.push(thread); - childrenByParent.set(parentId, list); - } else { - roots.push(thread); - } - }); - - const visibleRootCount = isExpanded ? roots.length : 3; - const visibleRoots = roots.slice(0, visibleRootCount); - const rows: Array<{ thread: ThreadSummary; depth: number }> = []; - const appendThread = (thread: ThreadSummary, depth: number) => { - rows.push({ thread, depth }); - const children = childrenByParent.get(thread.id) ?? []; - children.forEach((child) => appendThread(child, depth + 1)); - }; - - visibleRoots.forEach((thread) => appendThread(thread, 0)); - - return { - rows, - totalRoots: roots.length, - hasMoreRoots: roots.length > visibleRootCount, - }; - }, - [threadParentById], + const { collapsedGroups, toggleGroupCollapse } = useCollapsedGroups( + COLLAPSED_GROUPS_STORAGE_KEY, + ); + const scrollFadeDeps = useMemo( + () => [groupedWorkspaces, threadsByWorkspace, expandedWorkspaces], + [groupedWorkspaces, threadsByWorkspace, expandedWorkspaces], ); + const { sidebarBodyRef, scrollFade, updateScrollFade } = + useSidebarScrollFade(scrollFadeDeps); + const { getThreadRows } = useThreadRows(threadParentById); + const { showThreadMenu, showWorkspaceMenu, showWorktreeMenu } = + useSidebarMenus({ + onDeleteThread, + onReloadWorkspaceThreads, + onDeleteWorkspace, + onDeleteWorktree, + }); + const { + sessionPercent, + weeklyPercent, + sessionResetLabel, + weeklyResetLabel, + creditsLabel, + showWeekly, + } = getUsageLabels(accountRateLimits); - const updateScrollFade = useCallback(() => { - const node = sidebarBodyRef.current; - if (!node) { - return; - } - const { scrollTop, scrollHeight, clientHeight } = node; - const canScroll = scrollHeight > clientHeight; - const next = { - top: canScroll && scrollTop > 0, - bottom: canScroll && scrollTop + clientHeight < scrollHeight - 1, - }; - setScrollFade((prev) => - prev.top === next.top && prev.bottom === next.bottom ? prev : next, - ); - }, []); + const worktreesByParent = useMemo(() => { + const worktrees = new Map(); + workspaces + .filter((entry) => (entry.kind ?? "main") === "worktree" && entry.parentId) + .forEach((entry) => { + const parentId = entry.parentId as string; + const list = worktrees.get(parentId) ?? []; + list.push(entry); + worktrees.set(parentId, list); + }); + worktrees.forEach((entries) => { + entries.sort((a, b) => a.name.localeCompare(b.name)); + }); + return worktrees; + }, [workspaces]); - const persistCollapsedGroups = useCallback((next: Set) => { - if (typeof window === "undefined") { - return; - } - window.localStorage.setItem( - COLLAPSED_GROUPS_STORAGE_KEY, - JSON.stringify(Array.from(next)), - ); + const handleToggleExpanded = useCallback((workspaceId: string) => { + setExpandedWorkspaces((prev) => { + const next = new Set(prev); + if (next.has(workspaceId)) { + next.delete(workspaceId); + } else { + next.add(workspaceId); + } + return next; + }); }, []); - const toggleGroupCollapse = useCallback( - (groupId: string) => { - setCollapsedGroups((prev) => { - const next = new Set(prev); - if (next.has(groupId)) { - next.delete(groupId); - } else { - next.add(groupId); - } - persistCollapsedGroups(next); - return next; - }); - }, - [persistCollapsedGroups], - ); - const getThreadTime = useCallback( (thread: ThreadSummary) => { const lastMessage = lastAgentMessageByThread[thread.id]; @@ -219,163 +183,9 @@ export function Sidebar({ }; }, [addMenuAnchor]); - useEffect(() => { - const frame = requestAnimationFrame(updateScrollFade); - return () => cancelAnimationFrame(frame); - }, [updateScrollFade, groupedWorkspaces, threadsByWorkspace, expandedWorkspaces]); - - async function showThreadMenu( - event: React.MouseEvent, - workspaceId: string, - threadId: string, - ) { - event.preventDefault(); - event.stopPropagation(); - const archiveItem = await MenuItem.new({ - text: "Archive", - action: () => onDeleteThread(workspaceId, threadId), - }); - const copyItem = await MenuItem.new({ - text: "Copy ID", - action: async () => { - await navigator.clipboard.writeText(threadId); - }, - }); - const menu = await Menu.new({ items: [copyItem, archiveItem] }); - const window = getCurrentWindow(); - const position = new LogicalPosition(event.clientX, event.clientY); - await menu.popup(position, window); - } - - async function showWorkspaceMenu( - event: React.MouseEvent, - workspaceId: string, - ) { - event.preventDefault(); - event.stopPropagation(); - const reloadItem = await MenuItem.new({ - text: "Reload threads", - action: () => onReloadWorkspaceThreads(workspaceId), - }); - const deleteItem = await MenuItem.new({ - text: "Delete", - action: () => onDeleteWorkspace(workspaceId), - }); - const menu = await Menu.new({ items: [reloadItem, deleteItem] }); - const window = getCurrentWindow(); - const position = new LogicalPosition(event.clientX, event.clientY); - await menu.popup(position, window); - } - - async function showWorktreeMenu( - event: React.MouseEvent, - workspaceId: string, - ) { - event.preventDefault(); - event.stopPropagation(); - const reloadItem = await MenuItem.new({ - text: "Reload threads", - action: () => onReloadWorkspaceThreads(workspaceId), - }); - const deleteItem = await MenuItem.new({ - text: "Delete worktree", - action: () => onDeleteWorktree(workspaceId), - }); - const menu = await Menu.new({ items: [reloadItem, deleteItem] }); - const window = getCurrentWindow(); - const position = new LogicalPosition(event.clientX, event.clientY); - await menu.popup(position, window); - } - - const usagePercent = accountRateLimits?.primary?.usedPercent; - const globalUsagePercent = accountRateLimits?.secondary?.usedPercent; - const credits = accountRateLimits?.credits ?? null; - const creditsLabel = (() => { - if (!credits?.hasCredits) { - return null; - } - if (credits.unlimited) { - return "Credits: Unlimited"; - } - const balance = credits.balance?.trim() ?? ""; - if (!balance) { - return null; - } - const intValue = Number.parseInt(balance, 10); - if (Number.isFinite(intValue) && intValue > 0) { - return `Credits: ${intValue} credits`; - } - const floatValue = Number.parseFloat(balance); - if (Number.isFinite(floatValue) && floatValue > 0) { - const rounded = Math.round(floatValue); - return rounded > 0 ? `Credits: ${rounded} credits` : null; - } - return null; - })(); - - const clampPercent = (value: number) => - Math.min(Math.max(Math.round(value), 0), 100); - const sessionPercent = - typeof usagePercent === "number" ? clampPercent(usagePercent) : null; - const weeklyPercent = - typeof globalUsagePercent === "number" ? clampPercent(globalUsagePercent) : null; - const sessionLabel = "Session"; - const weeklyLabel = "Weekly"; - const sessionResetLabel = (() => { - const resetValue = accountRateLimits?.primary?.resetsAt; - if (typeof resetValue !== "number" || !Number.isFinite(resetValue)) { - return null; - } - const resetMs = resetValue > 1_000_000_000_000 ? resetValue : resetValue * 1000; - const relative = formatRelativeTime(resetMs).replace(/^in\s+/i, ""); - return `Resets ${relative}`; - })(); - const weeklyResetLabel = (() => { - const resetValue = accountRateLimits?.secondary?.resetsAt; - if (typeof resetValue !== "number" || !Number.isFinite(resetValue)) { - return null; - } - const resetMs = resetValue > 1_000_000_000_000 ? resetValue : resetValue * 1000; - const relative = formatRelativeTime(resetMs).replace(/^in\s+/i, ""); - return `Resets ${relative}`; - })(); - - const worktreesByParent = new Map(); - workspaces - .filter((entry) => (entry.kind ?? "main") === "worktree" && entry.parentId) - .forEach((entry) => { - const parentId = entry.parentId as string; - const list = worktreesByParent.get(parentId) ?? []; - list.push(entry); - worktreesByParent.set(parentId, list); - }); - worktreesByParent.forEach((entries) => { - entries.sort((a, b) => a.name.localeCompare(b.name)); - }); - return ( ); } diff --git a/src/features/app/components/SidebarCornerActions.tsx b/src/features/app/components/SidebarCornerActions.tsx new file mode 100644 index 000000000..1ecad611b --- /dev/null +++ b/src/features/app/components/SidebarCornerActions.tsx @@ -0,0 +1,38 @@ +import { ScrollText, Settings } from "lucide-react"; + +type SidebarCornerActionsProps = { + onOpenSettings: () => void; + onOpenDebug: () => void; + showDebugButton: boolean; +}; + +export function SidebarCornerActions({ + onOpenSettings, + onOpenDebug, + showDebugButton, +}: SidebarCornerActionsProps) { + return ( +
+ + {showDebugButton && ( + + )} +
+ ); +} diff --git a/src/features/app/components/SidebarFooter.tsx b/src/features/app/components/SidebarFooter.tsx new file mode 100644 index 000000000..ffd7be413 --- /dev/null +++ b/src/features/app/components/SidebarFooter.tsx @@ -0,0 +1,65 @@ +type SidebarFooterProps = { + sessionPercent: number | null; + weeklyPercent: number | null; + sessionResetLabel: string | null; + weeklyResetLabel: string | null; + creditsLabel: string | null; + showWeekly: boolean; +}; + +export function SidebarFooter({ + sessionPercent, + weeklyPercent, + sessionResetLabel, + weeklyResetLabel, + creditsLabel, + showWeekly, +}: SidebarFooterProps) { + return ( +
+
+
+
+ + Session + {sessionResetLabel && ( + · {sessionResetLabel} + )} + + + {sessionPercent === null ? "--" : `${sessionPercent}%`} + +
+
+ +
+
+ {showWeekly && ( +
+
+ + Weekly + {weeklyResetLabel && ( + · {weeklyResetLabel} + )} + + + {weeklyPercent === null ? "--" : `${weeklyPercent}%`} + +
+
+ +
+
+ )} +
+ {creditsLabel &&
{creditsLabel}
} +
+ ); +} diff --git a/src/features/app/components/SidebarHeader.tsx b/src/features/app/components/SidebarHeader.tsx new file mode 100644 index 000000000..e73cc691c --- /dev/null +++ b/src/features/app/components/SidebarHeader.tsx @@ -0,0 +1,32 @@ +import { FolderKanban } from "lucide-react"; + +type SidebarHeaderProps = { + onSelectHome: () => void; + onAddWorkspace: () => void; +}; + +export function SidebarHeader({ onSelectHome, onAddWorkspace }: SidebarHeaderProps) { + return ( +
+
+ +
+ +
+ ); +} diff --git a/src/features/app/components/ThreadList.tsx b/src/features/app/components/ThreadList.tsx new file mode 100644 index 000000000..1a0987867 --- /dev/null +++ b/src/features/app/components/ThreadList.tsx @@ -0,0 +1,127 @@ +import type { CSSProperties, MouseEvent } from "react"; + +import type { ThreadSummary } from "../../../types"; + +type ThreadStatusMap = Record< + string, + { isProcessing: boolean; hasUnread: boolean; isReviewing: boolean } +>; + +type ThreadRow = { + thread: ThreadSummary; + depth: number; +}; + +type ThreadListProps = { + workspaceId: string; + threadRows: ThreadRow[]; + totalThreadRoots: number; + isExpanded: boolean; + nextCursor: string | null; + isPaging: boolean; + nested?: boolean; + activeWorkspaceId: string | null; + activeThreadId: string | null; + threadStatusById: ThreadStatusMap; + getThreadTime: (thread: ThreadSummary) => string | null; + onToggleExpanded: (workspaceId: string) => void; + onLoadOlderThreads: (workspaceId: string) => void; + onSelectThread: (workspaceId: string, threadId: string) => void; + onShowThreadMenu: ( + event: MouseEvent, + workspaceId: string, + threadId: string, + ) => void; +}; + +export function ThreadList({ + workspaceId, + threadRows, + totalThreadRoots, + isExpanded, + nextCursor, + isPaging, + nested, + activeWorkspaceId, + activeThreadId, + threadStatusById, + getThreadTime, + onToggleExpanded, + onLoadOlderThreads, + onSelectThread, + onShowThreadMenu, +}: ThreadListProps) { + return ( +
+ {threadRows.map(({ thread, depth }) => { + const relativeTime = getThreadTime(thread); + const indentStyle = + depth > 0 + ? ({ "--thread-indent": `${depth * 14}px` } as CSSProperties) + : undefined; + const status = threadStatusById[thread.id]; + const statusClass = status?.isReviewing + ? "reviewing" + : status?.isProcessing + ? "processing" + : status?.hasUnread + ? "unread" + : "ready"; + + return ( +
onSelectThread(workspaceId, thread.id)} + onContextMenu={(event) => onShowThreadMenu(event, workspaceId, thread.id)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectThread(workspaceId, thread.id); + } + }} + > + + {thread.name} +
+ {relativeTime && {relativeTime}} +
+ +
+
+ ); + })} + {totalThreadRoots > 3 && ( + + )} + {isExpanded && nextCursor && ( + + )} +
+ ); +} diff --git a/src/features/app/components/ThreadLoading.tsx b/src/features/app/components/ThreadLoading.tsx new file mode 100644 index 000000000..8d37c0804 --- /dev/null +++ b/src/features/app/components/ThreadLoading.tsx @@ -0,0 +1,16 @@ +type ThreadLoadingProps = { + nested?: boolean; +}; + +export function ThreadLoading({ nested }: ThreadLoadingProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/features/app/components/WorkspaceCard.tsx b/src/features/app/components/WorkspaceCard.tsx new file mode 100644 index 000000000..01c21aa55 --- /dev/null +++ b/src/features/app/components/WorkspaceCard.tsx @@ -0,0 +1,113 @@ +import type { MouseEvent } from "react"; + +import type { WorkspaceInfo } from "../../../types"; + +type WorkspaceCardProps = { + workspace: WorkspaceInfo; + isActive: boolean; + isCollapsed: boolean; + addMenuOpen: boolean; + addMenuWidth: number; + onSelectWorkspace: (id: string) => void; + onShowWorkspaceMenu: (event: MouseEvent, workspaceId: string) => void; + onToggleWorkspaceCollapse: (workspaceId: string, collapsed: boolean) => void; + onConnectWorkspace: (workspace: WorkspaceInfo) => void; + onToggleAddMenu: (anchor: { + workspaceId: string; + top: number; + left: number; + width: number; + } | null) => void; + children?: React.ReactNode; +}; + +export function WorkspaceCard({ + workspace, + isActive, + isCollapsed, + addMenuOpen, + addMenuWidth, + onSelectWorkspace, + onShowWorkspaceMenu, + onToggleWorkspaceCollapse, + onConnectWorkspace, + onToggleAddMenu, + children, +}: WorkspaceCardProps) { + return ( +
+
onSelectWorkspace(workspace.id)} + onContextMenu={(event) => onShowWorkspaceMenu(event, workspace.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectWorkspace(workspace.id); + } + }} + > +
+
+
+ {workspace.name} + +
+ +
+
+ {!workspace.connected && ( + { + event.stopPropagation(); + onConnectWorkspace(workspace); + }} + > + connect + + )} +
+ {children} +
+ ); +} diff --git a/src/features/app/components/WorkspaceGroup.tsx b/src/features/app/components/WorkspaceGroup.tsx new file mode 100644 index 000000000..f1a15bfcc --- /dev/null +++ b/src/features/app/components/WorkspaceGroup.tsx @@ -0,0 +1,44 @@ +type WorkspaceGroupProps = { + groupId: string | null; + name: string; + showHeader: boolean; + isCollapsed: boolean; + onToggleCollapse: (groupId: string) => void; + children: React.ReactNode; +}; + +export function WorkspaceGroup({ + groupId, + name, + showHeader, + isCollapsed, + onToggleCollapse, + children, +}: WorkspaceGroupProps) { + return ( +
+ {showHeader && ( +
+
{name}
+ {groupId && ( + + )} +
+ )} +
+ {children} +
+
+ ); +} diff --git a/src/features/app/components/WorktreeCard.tsx b/src/features/app/components/WorktreeCard.tsx new file mode 100644 index 000000000..0dcf9fe6b --- /dev/null +++ b/src/features/app/components/WorktreeCard.tsx @@ -0,0 +1,72 @@ +import type { MouseEvent } from "react"; + +import type { WorkspaceInfo } from "../../../types"; + +type WorktreeCardProps = { + worktree: WorkspaceInfo; + isActive: boolean; + onSelectWorkspace: (id: string) => void; + onShowWorktreeMenu: (event: MouseEvent, workspaceId: string) => void; + onToggleWorkspaceCollapse: (workspaceId: string, collapsed: boolean) => void; + onConnectWorkspace: (workspace: WorkspaceInfo) => void; + children?: React.ReactNode; +}; + +export function WorktreeCard({ + worktree, + isActive, + onSelectWorkspace, + onShowWorktreeMenu, + onToggleWorkspaceCollapse, + onConnectWorkspace, + children, +}: WorktreeCardProps) { + const worktreeCollapsed = worktree.settings.sidebarCollapsed; + const worktreeBranch = worktree.worktree?.branch ?? ""; + + return ( +
+
onSelectWorkspace(worktree.id)} + onContextMenu={(event) => onShowWorktreeMenu(event, worktree.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectWorkspace(worktree.id); + } + }} + > +
{worktreeBranch || worktree.name}
+
+ + {!worktree.connected && ( + { + event.stopPropagation(); + onConnectWorkspace(worktree); + }} + > + connect + + )} +
+
+ {children} +
+ ); +} diff --git a/src/features/app/components/WorktreeSection.tsx b/src/features/app/components/WorktreeSection.tsx new file mode 100644 index 000000000..6ce375613 --- /dev/null +++ b/src/features/app/components/WorktreeSection.tsx @@ -0,0 +1,133 @@ +import { Layers } from "lucide-react"; +import type { MouseEvent } from "react"; + +import type { ThreadSummary, WorkspaceInfo } from "../../../types"; +import { ThreadList } from "./ThreadList"; +import { ThreadLoading } from "./ThreadLoading"; +import { WorktreeCard } from "./WorktreeCard"; + +type ThreadStatusMap = Record< + string, + { isProcessing: boolean; hasUnread: boolean; isReviewing: boolean } +>; + +type ThreadRowsResult = { + rows: Array<{ thread: ThreadSummary; depth: number }>; + totalRoots: number; + hasMoreRoots: boolean; +}; + +type WorktreeSectionProps = { + worktrees: WorkspaceInfo[]; + threadsByWorkspace: Record; + threadStatusById: ThreadStatusMap; + threadListLoadingByWorkspace: Record; + threadListPagingByWorkspace: Record; + threadListCursorByWorkspace: Record; + expandedWorkspaces: Set; + activeWorkspaceId: string | null; + activeThreadId: string | null; + getThreadRows: (threads: ThreadSummary[], isExpanded: boolean) => ThreadRowsResult; + getThreadTime: (thread: ThreadSummary) => string | null; + onSelectWorkspace: (id: string) => void; + onConnectWorkspace: (workspace: WorkspaceInfo) => void; + onToggleWorkspaceCollapse: (workspaceId: string, collapsed: boolean) => void; + onSelectThread: (workspaceId: string, threadId: string) => void; + onShowThreadMenu: ( + event: MouseEvent, + workspaceId: string, + threadId: string, + ) => void; + onShowWorktreeMenu: (event: MouseEvent, workspaceId: string) => void; + onToggleExpanded: (workspaceId: string) => void; + onLoadOlderThreads: (workspaceId: string) => void; +}; + +export function WorktreeSection({ + worktrees, + threadsByWorkspace, + threadStatusById, + threadListLoadingByWorkspace, + threadListPagingByWorkspace, + threadListCursorByWorkspace, + expandedWorkspaces, + activeWorkspaceId, + activeThreadId, + getThreadRows, + getThreadTime, + onSelectWorkspace, + onConnectWorkspace, + onToggleWorkspaceCollapse, + onSelectThread, + onShowThreadMenu, + onShowWorktreeMenu, + onToggleExpanded, + onLoadOlderThreads, +}: WorktreeSectionProps) { + if (!worktrees.length) { + return null; + } + + return ( +
+
+ + Worktrees +
+
+ {worktrees.map((worktree) => { + const worktreeThreads = threadsByWorkspace[worktree.id] ?? []; + const worktreeCollapsed = worktree.settings.sidebarCollapsed; + const showWorktreeThreads = + !worktreeCollapsed && worktreeThreads.length > 0; + const isLoadingWorktreeThreads = + threadListLoadingByWorkspace[worktree.id] ?? false; + const showWorktreeLoader = + !worktreeCollapsed && + isLoadingWorktreeThreads && + worktreeThreads.length === 0; + const worktreeNextCursor = + threadListCursorByWorkspace[worktree.id] ?? null; + const isWorktreePaging = + threadListPagingByWorkspace[worktree.id] ?? false; + const isWorktreeExpanded = expandedWorkspaces.has(worktree.id); + const { rows: worktreeThreadRows, totalRoots: totalWorktreeRoots } = + getThreadRows(worktreeThreads, isWorktreeExpanded); + + return ( + + {showWorktreeThreads && ( + + )} + {showWorktreeLoader && } + + ); + })} +
+
+ ); +} diff --git a/src/features/app/hooks/useCollapsedGroups.ts b/src/features/app/hooks/useCollapsedGroups.ts new file mode 100644 index 000000000..98183ad61 --- /dev/null +++ b/src/features/app/hooks/useCollapsedGroups.ts @@ -0,0 +1,53 @@ +import { useCallback, useState } from "react"; + +export function useCollapsedGroups(storageKey: string) { + const [collapsedGroups, setCollapsedGroups] = useState>(() => { + if (typeof window === "undefined") { + return new Set(); + } + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return new Set(); + } + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return new Set(parsed.filter((value) => typeof value === "string")); + } + } catch { + // Ignore invalid stored data. + } + return new Set(); + }); + + const persistCollapsedGroups = useCallback( + (next: Set) => { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem( + storageKey, + JSON.stringify(Array.from(next)), + ); + }, + [storageKey], + ); + + const toggleGroupCollapse = useCallback( + (groupId: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + persistCollapsedGroups(next); + return next; + }); + }, + [persistCollapsedGroups], + ); + + return { collapsedGroups, toggleGroupCollapse }; +} diff --git a/src/features/app/hooks/useSidebarMenus.ts b/src/features/app/hooks/useSidebarMenus.ts new file mode 100644 index 000000000..3eff26c34 --- /dev/null +++ b/src/features/app/hooks/useSidebarMenus.ts @@ -0,0 +1,82 @@ +import { useCallback, type MouseEvent } from "react"; +import { Menu, MenuItem } from "@tauri-apps/api/menu"; +import { LogicalPosition } from "@tauri-apps/api/dpi"; +import { getCurrentWindow } from "@tauri-apps/api/window"; + +type SidebarMenuHandlers = { + onDeleteThread: (workspaceId: string, threadId: string) => void; + onReloadWorkspaceThreads: (workspaceId: string) => void; + onDeleteWorkspace: (workspaceId: string) => void; + onDeleteWorktree: (workspaceId: string) => void; +}; + +export function useSidebarMenus({ + onDeleteThread, + onReloadWorkspaceThreads, + onDeleteWorkspace, + onDeleteWorktree, +}: SidebarMenuHandlers) { + const showThreadMenu = useCallback( + async (event: MouseEvent, workspaceId: string, threadId: string) => { + event.preventDefault(); + event.stopPropagation(); + const archiveItem = await MenuItem.new({ + text: "Archive", + action: () => onDeleteThread(workspaceId, threadId), + }); + const copyItem = await MenuItem.new({ + text: "Copy ID", + action: async () => { + await navigator.clipboard.writeText(threadId); + }, + }); + const menu = await Menu.new({ items: [copyItem, archiveItem] }); + const window = getCurrentWindow(); + const position = new LogicalPosition(event.clientX, event.clientY); + await menu.popup(position, window); + }, + [onDeleteThread], + ); + + const showWorkspaceMenu = useCallback( + async (event: MouseEvent, workspaceId: string) => { + event.preventDefault(); + event.stopPropagation(); + const reloadItem = await MenuItem.new({ + text: "Reload threads", + action: () => onReloadWorkspaceThreads(workspaceId), + }); + const deleteItem = await MenuItem.new({ + text: "Delete", + action: () => onDeleteWorkspace(workspaceId), + }); + const menu = await Menu.new({ items: [reloadItem, deleteItem] }); + const window = getCurrentWindow(); + const position = new LogicalPosition(event.clientX, event.clientY); + await menu.popup(position, window); + }, + [onReloadWorkspaceThreads, onDeleteWorkspace], + ); + + const showWorktreeMenu = useCallback( + async (event: MouseEvent, workspaceId: string) => { + event.preventDefault(); + event.stopPropagation(); + const reloadItem = await MenuItem.new({ + text: "Reload threads", + action: () => onReloadWorkspaceThreads(workspaceId), + }); + const deleteItem = await MenuItem.new({ + text: "Delete worktree", + action: () => onDeleteWorktree(workspaceId), + }); + const menu = await Menu.new({ items: [reloadItem, deleteItem] }); + const window = getCurrentWindow(); + const position = new LogicalPosition(event.clientX, event.clientY); + await menu.popup(position, window); + }, + [onReloadWorkspaceThreads, onDeleteWorktree], + ); + + return { showThreadMenu, showWorkspaceMenu, showWorktreeMenu }; +} diff --git a/src/features/app/hooks/useSidebarScrollFade.ts b/src/features/app/hooks/useSidebarScrollFade.ts new file mode 100644 index 000000000..46f5b4bde --- /dev/null +++ b/src/features/app/hooks/useSidebarScrollFade.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +type ScrollFadeState = { + top: boolean; + bottom: boolean; +}; + +export function useSidebarScrollFade(deps: ReadonlyArray) { + const sidebarBodyRef = useRef(null); + const [scrollFade, setScrollFade] = useState({ + top: false, + bottom: false, + }); + + const updateScrollFade = useCallback(() => { + const node = sidebarBodyRef.current; + if (!node) { + return; + } + const { scrollTop, scrollHeight, clientHeight } = node; + const canScroll = scrollHeight > clientHeight; + const next = { + top: canScroll && scrollTop > 0, + bottom: canScroll && scrollTop + clientHeight < scrollHeight - 1, + }; + setScrollFade((prev) => + prev.top === next.top && prev.bottom === next.bottom ? prev : next, + ); + }, []); + + useEffect(() => { + const frame = requestAnimationFrame(updateScrollFade); + return () => cancelAnimationFrame(frame); + }, [updateScrollFade, deps]); + + return { sidebarBodyRef, scrollFade, updateScrollFade }; +} diff --git a/src/features/app/hooks/useThreadRows.ts b/src/features/app/hooks/useThreadRows.ts new file mode 100644 index 000000000..d8f59d71b --- /dev/null +++ b/src/features/app/hooks/useThreadRows.ts @@ -0,0 +1,55 @@ +import { useCallback } from "react"; + +import type { ThreadSummary } from "../../../types"; + +type ThreadRow = { + thread: ThreadSummary; + depth: number; +}; + +type ThreadRowResult = { + rows: ThreadRow[]; + totalRoots: number; + hasMoreRoots: boolean; +}; + +export function useThreadRows(threadParentById: Record) { + const getThreadRows = useCallback( + (threads: ThreadSummary[], isExpanded: boolean): ThreadRowResult => { + const threadIds = new Set(threads.map((thread) => thread.id)); + const childrenByParent = new Map(); + const roots: ThreadSummary[] = []; + + threads.forEach((thread) => { + const parentId = threadParentById[thread.id]; + if (parentId && parentId !== thread.id && threadIds.has(parentId)) { + const list = childrenByParent.get(parentId) ?? []; + list.push(thread); + childrenByParent.set(parentId, list); + } else { + roots.push(thread); + } + }); + + const visibleRootCount = isExpanded ? roots.length : 3; + const visibleRoots = roots.slice(0, visibleRootCount); + const rows: ThreadRow[] = []; + const appendThread = (thread: ThreadSummary, depth: number) => { + rows.push({ thread, depth }); + const children = childrenByParent.get(thread.id) ?? []; + children.forEach((child) => appendThread(child, depth + 1)); + }; + + visibleRoots.forEach((thread) => appendThread(thread, 0)); + + return { + rows, + totalRoots: roots.length, + hasMoreRoots: roots.length > visibleRootCount, + }; + }, + [threadParentById], + ); + + return { getThreadRows }; +} diff --git a/src/features/app/utils/usageLabels.ts b/src/features/app/utils/usageLabels.ts new file mode 100644 index 000000000..9650e98a3 --- /dev/null +++ b/src/features/app/utils/usageLabels.ts @@ -0,0 +1,69 @@ +import type { RateLimitSnapshot } from "../../../types"; +import { formatRelativeTime } from "../../../utils/time"; + +type UsageLabels = { + sessionPercent: number | null; + weeklyPercent: number | null; + sessionResetLabel: string | null; + weeklyResetLabel: string | null; + creditsLabel: string | null; + showWeekly: boolean; +}; + +const clampPercent = (value: number) => + Math.min(Math.max(Math.round(value), 0), 100); + +function formatResetLabel(resetsAt?: number | null) { + if (typeof resetsAt !== "number" || !Number.isFinite(resetsAt)) { + return null; + } + const resetMs = resetsAt > 1_000_000_000_000 ? resetsAt : resetsAt * 1000; + const relative = formatRelativeTime(resetMs).replace(/^in\s+/i, ""); + return `Resets ${relative}`; +} + +function formatCreditsLabel(accountRateLimits: RateLimitSnapshot | null) { + const credits = accountRateLimits?.credits ?? null; + if (!credits?.hasCredits) { + return null; + } + if (credits.unlimited) { + return "Credits: Unlimited"; + } + const balance = credits.balance?.trim() ?? ""; + if (!balance) { + return null; + } + const intValue = Number.parseInt(balance, 10); + if (Number.isFinite(intValue) && intValue > 0) { + return `Credits: ${intValue} credits`; + } + const floatValue = Number.parseFloat(balance); + if (Number.isFinite(floatValue) && floatValue > 0) { + const rounded = Math.round(floatValue); + return rounded > 0 ? `Credits: ${rounded} credits` : null; + } + return null; +} + +export function getUsageLabels( + accountRateLimits: RateLimitSnapshot | null, +): UsageLabels { + const usagePercent = accountRateLimits?.primary?.usedPercent; + const globalUsagePercent = accountRateLimits?.secondary?.usedPercent; + const sessionPercent = + typeof usagePercent === "number" ? clampPercent(usagePercent) : null; + const weeklyPercent = + typeof globalUsagePercent === "number" + ? clampPercent(globalUsagePercent) + : null; + + return { + sessionPercent, + weeklyPercent, + sessionResetLabel: formatResetLabel(accountRateLimits?.primary?.resetsAt), + weeklyResetLabel: formatResetLabel(accountRateLimits?.secondary?.resetsAt), + creditsLabel: formatCreditsLabel(accountRateLimits), + showWeekly: Boolean(accountRateLimits?.secondary), + }; +}