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
This commit is contained in:
Bohdan Triapitsyn
2026-02-07 03:57:46 +02:00
committed by GitHub
parent d5a2e49ae8
commit 7d99aea6cc
46 changed files with 1711 additions and 288 deletions
+7
View File
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,10 @@ const sizeConfig = {
lg: { container: 'gap-1.5', dot: 'h-2 w-2' },
};
const getPulseDelayMs = (index: number): number => {
return ((index % 3) + Math.floor(index / 3)) * 150;
};
const GridLoader: React.FC<GridLoaderProps> = ({ className, size = 'md' }) => {
const config = sizeConfig[size];
@@ -25,7 +29,7 @@ const GridLoader: React.FC<GridLoaderProps> = ({ className, size = 'md' }) => {
<div
key={i}
className={cn('rounded-full bg-current animate-grid-pulse', config.dot)}
style={{ animationDelay: `${((i % 3) + Math.floor(i / 3)) * 150}ms` }}
style={{ animationDelay: `${getPulseDelayMs(i)}ms` }}
/>
))}
</div>
+18
View File
@@ -802,6 +802,19 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
animation: grid-pulse 1.2s ease-in-out infinite;
}
@keyframes attention-diamond-pulse {
0%, 100% {
opacity: 0.52;
}
50% {
opacity: 0.92;
}
}
.animate-attention-diamond-pulse {
animation: attention-diamond-pulse 2.3s ease-in-out infinite;
}
@keyframes marquee-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
@@ -828,4 +841,9 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
.marquee-text--active:hover {
animation: none;
}
.animate-grid-pulse,
.animate-attention-diamond-pulse {
animation: none;
}
}
+12
View File
@@ -49,6 +49,7 @@ export class CSSVariableGenerator {
cssVars.push(...this.generateSurfaceColors(theme.colors.surface));
cssVars.push(...this.generateInteractiveColors(theme.colors.interactive));
cssVars.push(...this.generateStatusColors(theme.colors.status));
cssVars.push(...this.generatePullRequestColors(theme));
cssVars.push(...this.generateSyntaxColors(theme.colors.syntax));
@@ -256,6 +257,17 @@ export class CSSVariableGenerator {
return vars;
}
private generatePullRequestColors(theme: Theme): string[] {
const vars: string[] = [];
const pr = theme.colors.pr;
vars.push(` --pr-open: ${pr?.open || theme.colors.status.success};`);
vars.push(` --pr-draft: ${pr?.draft || theme.colors.surface.mutedForeground};`);
vars.push(` --pr-blocked: ${pr?.blocked || theme.colors.status.warning};`);
vars.push(` --pr-merged: ${pr?.merged || (theme.metadata.variant === 'dark' ? '#8957e5' : '#8250df')};`);
vars.push(` --pr-closed: ${pr?.closed || theme.colors.status.error};`);
return vars;
}
private generateSyntaxColors(syntax: Theme['colors']['syntax']): string[] {
const vars: string[] = [];
@@ -56,6 +56,13 @@
"infoBackground": "#82E2FF20",
"infoBorder": "#82E2FF50"
},
"pr": {
"open": "#61FFCA",
"draft": "#6D6D6D",
"blocked": "#FFCA85",
"merged": "#A277FF",
"closed": "#FF6767"
},
"syntax": {
"base": {
"background": "#1A1921",
@@ -56,6 +56,13 @@
"infoBackground": "#5BB8D920",
"infoBorder": "#5BB8D950"
},
"pr": {
"open": "#40BF7A",
"draft": "#6D6D6D",
"blocked": "#D9A24A",
"merged": "#A277FF",
"closed": "#D94F4F"
},
"syntax": {
"base": {
"background": "#EFE8FC",
@@ -56,6 +56,13 @@
"infoBackground": "#66C6F120",
"infoBorder": "#66C6F150"
},
"pr": {
"open": "#78D05C",
"draft": "#A3ADBA",
"blocked": "#E4A75C",
"merged": "#8a44f2",
"closed": "#F58572"
},
"syntax": {
"base": {
"background": "#18222C",
@@ -56,6 +56,13 @@
"infoBackground": "#2F9BCE20",
"infoBorder": "#2F9BCE50"
},
"pr": {
"open": "#5FB978",
"draft": "#77818D",
"blocked": "#EA9F41",
"merged": "#7b3cda",
"closed": "#E6656A"
},
"syntax": {
"base": {
"background": "#FCF9F3",
@@ -56,6 +56,13 @@
"infoBackground": "#78A9FF20",
"infoBorder": "#78A9FF50"
},
"pr": {
"open": "#42BE65",
"draft": "#8D8D8D",
"blocked": "#F1C21B",
"merged": "#BE95FF",
"closed": "#FF8389"
},
"syntax": {
"base": {
"background": "#262626",
@@ -56,6 +56,13 @@
"infoBackground": "#0043CE20",
"infoBorder": "#0043CE50"
},
"pr": {
"open": "#42BE65",
"draft": "#8D8D8D",
"blocked": "#F1C21B",
"merged": "#BE95FF",
"closed": "#FF8389"
},
"syntax": {
"base": {
"background": "#F4F4F4",
@@ -56,6 +56,13 @@
"infoBackground": "#89DCEB20",
"infoBorder": "#89DCEB50"
},
"pr": {
"open": "#A6D189",
"draft": "#A6ADC8",
"blocked": "#F9E2AF",
"merged": "#d8b4fe",
"closed": "#F38BA8"
},
"syntax": {
"base": {
"background": "#211F31",
@@ -56,6 +56,13 @@
"infoBackground": "#04A5E520",
"infoBorder": "#04A5E550"
},
"pr": {
"open": "#40A02B",
"draft": "#6C6F85",
"blocked": "#DF8E1D",
"merged": "#9572fd",
"closed": "#D20F39"
},
"syntax": {
"base": {
"background": "#F2D8D4",
@@ -56,6 +56,13 @@
"infoBackground": "#8BE9FD20",
"infoBorder": "#8BE9FD50"
},
"pr": {
"open": "#50FA7B",
"draft": "#B6B9E4",
"blocked": "#FFB86C",
"merged": "#BD93F9",
"closed": "#FF5555"
},
"syntax": {
"base": {
"background": "#181926",
@@ -56,6 +56,13 @@
"infoBackground": "#1D7FC520",
"infoBorder": "#1D7FC550"
},
"pr": {
"open": "#2FBF71",
"draft": "#52526B",
"blocked": "#F7A14D",
"merged": "#7C6BF5",
"closed": "#D9536F"
},
"syntax": {
"base": {
"background": "#F1F2ED",
@@ -56,6 +56,13 @@
"infoBackground": "#205EA620",
"infoBorder": "#205EA650"
},
"pr": {
"open": "#A0AF54",
"draft": "#878580",
"blocked": "#DA702C",
"merged": "#8B7EC8",
"closed": "#D14D41"
},
"syntax": {
"base": {
"background": "#1C1B1A",
@@ -56,6 +56,13 @@
"infoBackground": "#4385BE20",
"infoBorder": "#4385BE50"
},
"pr": {
"open": "#66800B",
"draft": "#6F6E69",
"blocked": "#BC5215",
"merged": "#5E409D",
"closed": "#AF3029"
},
"syntax": {
"base": {
"background": "#F2F0E5",
@@ -56,6 +56,13 @@
"infoBackground": "#D3869B20",
"infoBorder": "#D3869B50"
},
"pr": {
"open": "#B8BB26",
"draft": "#A89984",
"blocked": "#FABD2F",
"merged": "#8973a8",
"closed": "#FB4934"
},
"syntax": {
"base": {
"background": "#32302F",
@@ -56,6 +56,13 @@
"infoBackground": "#8F3F7120",
"infoBorder": "#8F3F7150"
},
"pr": {
"open": "#79740E",
"draft": "#7C6F64",
"blocked": "#B57614",
"merged": "#5e4c76",
"closed": "#9D0006"
},
"syntax": {
"base": {
"background": "#F2E5BC",
+3 -2
View File
@@ -1,10 +1,11 @@
import type { Theme } from '@/types/theme';
import { presetThemes } from './presets';
import { withPrColors } from './prColors';
import flexokiLightRaw from './flexoki-light.json';
import flexokiDarkRaw from './flexoki-dark.json';
export const flexokiLightTheme = flexokiLightRaw as Theme;
export const flexokiDarkTheme = flexokiDarkRaw as Theme;
export const flexokiLightTheme = withPrColors(flexokiLightRaw as Theme);
export const flexokiDarkTheme = withPrColors(flexokiDarkRaw as Theme);
export const DEFAULT_LIGHT_THEME_ID = 'flexoki-light' as const;
export const DEFAULT_DARK_THEME_ID = 'flexoki-dark' as const;
@@ -56,6 +56,13 @@
"infoBackground": "#7E9CD820",
"infoBorder": "#7E9CD850"
},
"pr": {
"open": "#98BB6C",
"draft": "#54546D",
"blocked": "#FF9E3B",
"merged": "#957FB8",
"closed": "#E82424"
},
"syntax": {
"base": {
"background": "#16161D",
@@ -56,6 +56,13 @@
"infoBackground": "#6693BF20",
"infoBorder": "#4D699B50"
},
"pr": {
"open": "#6F894E",
"draft": "#716E61",
"blocked": "#DE9800",
"merged": "#624C83",
"closed": "#E82424"
},
"syntax": {
"base": {
"background": "#DCD5AC",
@@ -56,6 +56,13 @@
"infoBackground": "#ffffff14",
"infoBorder": "#ffffff28"
},
"pr": {
"open": "#E5E5E5",
"draft": "#B3B3B3",
"blocked": "#999999",
"merged": "#CCCCCC",
"closed": "#666666"
},
"syntax": {
"base": {
"background": "#0A0A0A",
@@ -56,6 +56,13 @@
"infoBackground": "#0000000c",
"infoBorder": "#0000001a"
},
"pr": {
"open": "#1A1A1A",
"draft": "#4D4D4D",
"blocked": "#999999",
"merged": "#333333",
"closed": "#666666"
},
"syntax": {
"base": {
"background": "#F5F5F5",
@@ -56,6 +56,13 @@
"infoBackground": "#6a7e9e20",
"infoBorder": "#6a7e9e50"
},
"pr": {
"open": "#6a8e6a",
"draft": "#8c8c8c",
"blocked": "#9e8a6a",
"merged": "#bda2e8",
"closed": "#9e6a6a"
},
"syntax": {
"base": {
"background": "#0a0a0a",
@@ -56,6 +56,13 @@
"infoBackground": "#5a6a8a18",
"infoBorder": "#5a6a8a40"
},
"pr": {
"open": "#6a8e6a",
"draft": "#8c8c8c",
"blocked": "#8a7a5a",
"merged": "#8769b6",
"closed": "#8a5a5a"
},
"syntax": {
"base": {
"background": "#f5f5f5",
@@ -56,6 +56,13 @@
"infoBackground": "#66D9EF20",
"infoBorder": "#66D9EF50"
},
"pr": {
"open": "#A6E22E",
"draft": "#C5C5C0",
"blocked": "#FD971F",
"merged": "#AE81FF",
"closed": "#F92672"
},
"syntax": {
"base": {
"background": "#27281F",
@@ -56,6 +56,13 @@
"infoBackground": "#2D9AD720",
"infoBorder": "#2D9AD750"
},
"pr": {
"open": "#4FB54B",
"draft": "#6D5C40",
"blocked": "#F1A948",
"merged": "#BF7BFF",
"closed": "#E54B4B"
},
"syntax": {
"base": {
"background": "#F8F2E6",
@@ -56,6 +56,13 @@
"infoBackground": "#82AAFF20",
"infoBorder": "#82AAFF50"
},
"pr": {
"open": "#C5E478",
"draft": "#5F7E97",
"blocked": "#ECC48D",
"merged": "#BC82FF",
"closed": "#EF5350"
},
"syntax": {
"base": {
"background": "#0B253A",
@@ -56,6 +56,13 @@
"infoBackground": "#4876D620",
"infoBorder": "#4876D650"
},
"pr": {
"open": "#2AA298",
"draft": "#7A8181",
"blocked": "#c98f65",
"merged": "#994CC3",
"closed": "#DE3D3B"
},
"syntax": {
"base": {
"background": "#F0F0F0",
@@ -56,6 +56,13 @@
"infoBackground": "#81A1C120",
"infoBorder": "#81A1C150"
},
"pr": {
"open": "#A3BE8C",
"draft": "#A4ADBF",
"blocked": "#D08770",
"merged": "#b788d0",
"closed": "#BF616A"
},
"syntax": {
"base": {
"background": "#222938",
@@ -56,6 +56,13 @@
"infoBackground": "#81A1C120",
"infoBorder": "#81A1C150"
},
"pr": {
"open": "#8FBCBB",
"draft": "#4C566A",
"blocked": "#D08770",
"merged": "#9b5eac",
"closed": "#BF616A"
},
"syntax": {
"base": {
"background": "#E4E8F0",
@@ -56,6 +56,13 @@
"infoBackground": "#56B6C220",
"infoBorder": "#56B6C250"
},
"pr": {
"open": "#98C379",
"draft": "#818899",
"blocked": "#E5C07B",
"merged": "#bb61ef",
"closed": "#E06C75"
},
"syntax": {
"base": {
"background": "#212631",
@@ -56,6 +56,13 @@
"infoBackground": "#61AFEF20",
"infoBorder": "#61AFEF50"
},
"pr": {
"open": "#4FA66D",
"draft": "#6B717F",
"blocked": "#D19A66",
"merged": "#a052ff",
"closed": "#E06C75"
},
"syntax": {
"base": {
"background": "#EEF0F4",
@@ -0,0 +1,39 @@
import type { Theme } from '@/types/theme';
const DEFAULT_MERGED_DARK = '#8957e5';
const DEFAULT_MERGED_LIGHT = '#8250df';
const pickMergedColor = (theme: Theme): string => {
const tokens = theme.colors.syntax?.tokens ?? {};
const candidates = [
tokens.className,
tokens.enum,
tokens.variableGlobal,
theme.colors.syntax?.base?.keyword,
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
if (candidates.length > 0) {
return candidates[0];
}
return theme.metadata.variant === 'dark' ? DEFAULT_MERGED_DARK : DEFAULT_MERGED_LIGHT;
};
export const withPrColors = (theme: Theme): Theme => {
if (theme.colors.pr) {
return theme;
}
return {
...theme,
colors: {
...theme.colors,
pr: {
open: theme.colors.status.success,
draft: theme.colors.surface.mutedForeground,
blocked: theme.colors.status.warning,
merged: pickMergedColor(theme),
closed: theme.colors.status.error,
},
},
};
};
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Theme } from '@/types/theme';
import { withPrColors } from './prColors';
import aura_dark_Raw from './aura-dark.json';
import aura_light_Raw from './aura-light.json';
@@ -70,4 +71,4 @@ export const presetThemes: Theme[] = [
mono_light_Raw as Theme,
vitesse_dark_dark_Raw as Theme,
vitesse_light_light_Raw as Theme,
];
].map((theme) => withPrColors(theme));
@@ -56,6 +56,13 @@
"infoBackground": "#2AA19820",
"infoBorder": "#2AA19850"
},
"pr": {
"open": "#859900",
"draft": "#6C7F80",
"blocked": "#B58900",
"merged": "#7f6cc4",
"closed": "#DC322F"
},
"syntax": {
"base": {
"background": "#022733",
@@ -56,6 +56,13 @@
"infoBackground": "#2AA19820",
"infoBorder": "#2AA19850"
},
"pr": {
"open": "#859900",
"draft": "#7A8C8E",
"blocked": "#B58900",
"merged": "#6e26d2",
"closed": "#DC322F"
},
"syntax": {
"base": {
"background": "#F6EFDA",
@@ -56,6 +56,13 @@
"infoBackground": "#7DCFFF20",
"infoBorder": "#7DCFFF50"
},
"pr": {
"open": "#9ECE6A",
"draft": "#7A88CF",
"blocked": "#E0AF68",
"merged": "#BB9AF7",
"closed": "#F7768E"
},
"syntax": {
"base": {
"background": "#111428",
@@ -56,6 +56,13 @@
"infoBackground": "#00719720",
"infoBorder": "#00719750"
},
"pr": {
"open": "#587539",
"draft": "#5C6390",
"blocked": "#8C6C3E",
"merged": "#9854F1",
"closed": "#C94060"
},
"syntax": {
"base": {
"background": "#DEE0EA",
@@ -56,6 +56,13 @@
"infoBackground": "#FFC79920",
"infoBorder": "#FFC79950"
},
"pr": {
"open": "#99FFE4",
"draft": "#A0A0A0",
"blocked": "#FFC799",
"merged": "#ca99ff",
"closed": "#FF8080"
},
"syntax": {
"base": {
"background": "#141414",
@@ -56,6 +56,13 @@
"infoBackground": "#FFC79920",
"infoBorder": "#FFC79950"
},
"pr": {
"open": "#12b489",
"draft": "#A0A0A0",
"blocked": "#f4994e",
"merged": "#bf74e8",
"closed": "#FF8080"
},
"syntax": {
"base": {
"background": "#F8F8F8",
@@ -60,6 +60,13 @@
"infoBackground": "#6394bf20",
"infoBorder": "#6394bf50"
},
"pr": {
"open": "#4d9375",
"draft": "#747f74",
"blocked": "#d4976c",
"merged": "#6c43be",
"closed": "#cb7676"
},
"syntax": {
"base": {
"background": "#121212",
@@ -60,6 +60,13 @@
"infoBackground": "#296aa320",
"infoBorder": "#296aa350"
},
"pr": {
"open": "#1e754f",
"draft": "#a0ada0",
"blocked": "#a65e2b",
"merged": "#5843be",
"closed": "#ab5959"
},
"syntax": {
"base": {
"background": "#ffffff",
@@ -229,6 +229,70 @@ export function isCreatingWorktree(): boolean {
return isCreatingWorktreeSession;
}
export async function createWorktreeOnly(): Promise<string | null> {
if (isCreatingWorktreeSession) {
return null;
}
const activeProject = useProjectsStore.getState().getActiveProject();
if (!activeProject?.path) {
toast.error('No active project', {
description: 'Please select a project first.',
});
return null;
}
const projectDirectory = activeProject.path;
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectDirectory);
} catch {
// ignored
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
isCreatingWorktreeSession = true;
startConfigUpdate('Creating new worktree...');
try {
const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
const preferredName = generateBranchName();
const setupCommands = await getWorktreeSetupCommands(projectRef);
const metadata = await createSdkWorktree(projectRef, {
preferredName,
setupCommands,
});
const rootBranch = await getRootBranch(projectRef.path).catch(() => undefined);
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
const branchLabel = metadata.branch || metadata.label || metadata.name;
toast.success('Worktree created', {
description: branchLabel
? `${branchLabel}${rootBranch ? ` from ${rootBranch}` : ''}`
: status?.isDirty ? 'Created (dirty)' : 'Ready',
});
await useSessionStore.getState().loadSessions();
return metadata.path;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create worktree';
toast.error('Failed to create worktree', {
description: message,
});
return null;
} finally {
finishConfigUpdate();
isCreatingWorktreeSession = false;
}
}
/**
* Create a new session with a worktree for a specific branch.
* Unlike createWorktreeSession(), this allows specifying the project and branch explicitly.
+9
View File
@@ -67,6 +67,14 @@ export interface StatusColors {
infoBorder: string;
}
export interface PullRequestColors {
open: string;
draft: string;
blocked: string;
merged: string;
closed: string;
}
export interface SyntaxBaseColors {
background: string;
foreground: string;
@@ -189,6 +197,7 @@ export interface Theme {
surface: SurfaceColors;
interactive: InteractiveColors;
status: StatusColors;
pr?: PullRequestColors;
syntax: SyntaxColors;