From 7d99aea6cc0b0a82542154e68b95d7b8e4479203 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 7 Feb 2026 03:57:46 +0200 Subject: [PATCH] Enhance session sidebar with PR worktree support (#342) * feat: refactored sessions sidebar to be workspaces centric - Add createWorktreeOnly helper and status checks - Add createWorktreeOnly to create a standalone worktree for the active project - Show error toasts when no active project or not a Git repository - Refresh session list and show success toast with created worktree details * feat: show attention indicator and status marker in SessionSidebar - Show unread attention indicator for sessions with pending updates - Display status marker combining streaming and unread indicators in the session row - Introduce attention diamond pulse animation and associated CSS for the new indicator * fix(SessionSidebar): reserve header actions space when repo state is known * chore(doc): add PR status colors to custom themes --- docs/CUSTOM_THEMES.md | 7 + .../src/components/session/SessionSidebar.tsx | 1584 ++++++++++++++--- packages/ui/src/components/ui/grid-loader.tsx | 6 +- packages/ui/src/index.css | 18 + packages/ui/src/lib/theme/cssGenerator.ts | 12 + .../ui/src/lib/theme/themes/aura-dark.json | 7 + .../ui/src/lib/theme/themes/aura-light.json | 7 + .../ui/src/lib/theme/themes/ayu-dark.json | 7 + .../ui/src/lib/theme/themes/ayu-light.json | 7 + .../src/lib/theme/themes/carbonfox-dark.json | 7 + .../src/lib/theme/themes/carbonfox-light.json | 7 + .../src/lib/theme/themes/catppuccin-dark.json | 7 + .../lib/theme/themes/catppuccin-light.json | 7 + .../ui/src/lib/theme/themes/dracula-dark.json | 7 + .../src/lib/theme/themes/dracula-light.json | 7 + .../ui/src/lib/theme/themes/flexoki-dark.json | 7 + .../src/lib/theme/themes/flexoki-light.json | 7 + .../ui/src/lib/theme/themes/gruvbox-dark.json | 7 + .../src/lib/theme/themes/gruvbox-light.json | 7 + packages/ui/src/lib/theme/themes/index.ts | 5 +- .../src/lib/theme/themes/kanagawa-dark.json | 7 + .../src/lib/theme/themes/kanagawa-light.json | 7 + .../ui/src/lib/theme/themes/mono-dark.json | 7 + .../ui/src/lib/theme/themes/mono-light.json | 7 + .../src/lib/theme/themes/mono-plus-dark.json | 7 + .../src/lib/theme/themes/mono-plus-light.json | 7 + .../ui/src/lib/theme/themes/monokai-dark.json | 7 + .../src/lib/theme/themes/monokai-light.json | 7 + .../src/lib/theme/themes/nightowl-dark.json | 7 + .../src/lib/theme/themes/nightowl-light.json | 7 + .../ui/src/lib/theme/themes/nord-dark.json | 7 + .../ui/src/lib/theme/themes/nord-light.json | 7 + .../src/lib/theme/themes/onedarkpro-dark.json | 7 + .../lib/theme/themes/onedarkpro-light.json | 7 + packages/ui/src/lib/theme/themes/prColors.ts | 39 + packages/ui/src/lib/theme/themes/presets.ts | 3 +- .../src/lib/theme/themes/solarized-dark.json | 7 + .../src/lib/theme/themes/solarized-light.json | 7 + .../src/lib/theme/themes/tokyonight-dark.json | 7 + .../lib/theme/themes/tokyonight-light.json | 7 + .../ui/src/lib/theme/themes/vesper-dark.json | 7 + .../ui/src/lib/theme/themes/vesper-light.json | 7 + .../lib/theme/themes/vitesse-dark-dark.json | 7 + .../lib/theme/themes/vitesse-light-light.json | 7 + packages/ui/src/lib/worktreeSessionCreator.ts | 64 + packages/ui/src/types/theme.ts | 9 + 46 files changed, 1711 insertions(+), 288 deletions(-) create mode 100644 packages/ui/src/lib/theme/themes/prColors.ts diff --git a/docs/CUSTOM_THEMES.md b/docs/CUSTOM_THEMES.md index 905a6af5..db04adec 100644 --- a/docs/CUSTOM_THEMES.md +++ b/docs/CUSTOM_THEMES.md @@ -82,6 +82,13 @@ OpenChamber supports user-defined themes. Drop a JSON file into the themes direc "infoBackground": "#205EA620", "infoBorder": "#205EA650" }, + "pr": { + "open": "#A0AF54", + "draft": "#878580", + "blocked": "#DA702C", + "merged": "#8B7EC8", + "closed": "#D14D41" + }, "syntax": { "base": { "background": "#1C1B1A", diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 6eef58e4..13e22bbf 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -10,15 +10,16 @@ import { PointerSensor, useSensor, useSensors, - type DragEndEvent, - type DragStartEvent, + type Modifier, } from '@dnd-kit/core'; import { SortableContext, + arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { DropdownMenu, DropdownMenuContent, @@ -40,6 +41,9 @@ import { RiFileCopyLine, RiFolderAddLine, RiGitBranchLine, + RiGitClosePullRequestLine, + RiGitMergeLine, + RiGitPrDraftLine, RiGitPullRequestLine, RiLinkUnlinkM, @@ -62,14 +66,29 @@ import type { WorktreeMetadata } from '@/types/worktree'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { getSafeStorage } from '@/stores/utils/safeStorage'; -import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; +import { createWorktreeOnly, createWorktreeSession } from '@/lib/worktreeSessionCreator'; +import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { isVSCodeRuntime } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import type { GitHubPullRequestStatus } from '@/lib/api/types'; import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog'; import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog'; +const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); + +const getAttentionDiamondDelay = (index: number): string => { + return index === 4 ? '0ms' : '130ms'; +}; + const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; +const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; +const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; +const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; +const PR_REVALIDATE_TTL_MS = 90_000; +const PR_REVALIDATE_INTERVAL_MS = 90_000; +const PR_REVALIDATE_CONCURRENCY = 3; const formatDateLabel = (value: string | number) => { const targetDate = new Date(value); @@ -104,6 +123,84 @@ const normalizePath = (value?: string | null) => { return normalized.length === 0 ? '/' : normalized; }; +const toFiniteNumber = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +}; + +const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'open' | 'blocked' | 'merged' | 'closed' | null => { + const pr = status?.pr; + if (!pr) { + return null; + } + if (pr.state === 'merged') { + return 'merged'; + } + if (pr.state === 'closed') { + return 'closed'; + } + if (pr.draft) { + return 'draft'; + } + const checksFailed = status?.checks?.state === 'failure'; + const notMergeable = status?.canMerge === false || pr.mergeable === false; + if (checksFailed || notMergeable) { + return 'blocked'; + } + return 'open'; +}; + +const getPrTooltipLabel = (status: GitHubPullRequestStatus | null): string => { + const pr = status?.pr; + if (!pr) { + return 'Open pull request'; + } + const parts: string[] = [`PR #${pr.number}`]; + if (pr.state === 'merged') { + parts.push('Merged'); + } else if (pr.state === 'closed') { + parts.push('Closed'); + } else if (pr.draft) { + parts.push('Draft'); + } else { + parts.push('Open'); + } + if (status?.checks?.state === 'failure') { + parts.push('Checks failing'); + } + if (status?.canMerge === false || pr.mergeable === false) { + parts.push('Merge blocked'); + } + return parts.join(' · '); +}; + +type TauriShell = { + shell?: { + open?: (url: string) => Promise; + }; +}; + +const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => { + if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) { + return transform; + } + const overlayHeight = 32; + const pointerLiftY = 16; + return { + ...transform, + x: transform.x, + y: transform.y - overlayHeight / 2 - pointerLiftY, + }; +}; + // Format project label: kebab-case/snake_case → Title Case const formatProjectLabel = (label: string): string => { return label @@ -156,6 +253,8 @@ interface SortableProjectItemProps { sentinelRef: (el: HTMLDivElement | null) => void; children?: React.ReactNode; settingsAutoCreateWorktree: boolean; + showCreateButtons?: boolean; + hideHeader?: boolean; } const SortableProjectItem: React.FC = ({ @@ -187,6 +286,8 @@ const SortableProjectItem: React.FC = ({ sentinelRef, children, settingsAutoCreateWorktree, + showCreateButtons = true, + hideHeader = false, }) => { const { attributes, @@ -199,41 +300,43 @@ const SortableProjectItem: React.FC = ({ return (
- {/* Sentinel for sticky detection */} - {isDesktopShell && ( -