diff --git a/.opencode/skills/theme-system/SKILL.md b/.opencode/skills/theme-system/SKILL.md
index 9304a6ec..8fb7ecec 100644
--- a/.opencode/skills/theme-system/SKILL.md
+++ b/.opencode/skills/theme-system/SKILL.md
@@ -33,6 +33,43 @@ OpenChamber uses a JSON-based theme system. Themes are defined in `packages/ui/s
- Status colors = **ONLY for actual feedback** (errors, warnings, success)
- Input footers = `bg-transparent` on elevated background
+## Button Rules (MANDATORY)
+
+Use only the shared `Button` component from `packages/ui/src/components/ui/button.tsx`.
+
+- Do not create wrapper button components (for example `ButtonLarge`, `ButtonSmall`).
+- Do not hardcode button height/padding classes when a `size` variant exists.
+- Use semantic button variants consistently; avoid ad-hoc one-off button styling.
+
+### Allowed Button Variants
+
+| Variant | Use for | Token direction |
+|-------|-------|-------|
+| `default` | Primary action in a local section/dialog | `primary.*` |
+| `outline` | Secondary visible action | `surface.elevated` + `interactive.*` |
+| `secondary` | Soft secondary action | `interactive.hover` / `interactive.active` |
+| `ghost` | Low-emphasis row/toolbar action | transparent + `interactive.hover` |
+| `destructive` | Destructive actions (`Delete`, `Revert all`) | `status.error*` |
+| `link` | Rare inline text action only | text-link style |
+
+### Allowed Button Sizes
+
+| Size | Use for |
+|------|---------|
+| `xs` | Dense controls in rows/lists |
+| `sm` | Default compact action buttons |
+| `default` | Standard form/page actions |
+| `lg` | Prominent large actions |
+| `icon` | Icon-only square button |
+
+### Button Selection Quick Guide
+
+1. Main CTA in section/dialog -> `default`
+2. Side action next to CTA -> `outline`
+3. Quiet auxiliary action -> `ghost`
+4. Dangerous action -> `destructive`
+5. Tiny row action -> keep same variant, set `size="xs"`
+
### Never Use
- Hardcoded hex colors (`#FF0000`)
diff --git a/AGENTS.md b/AGENTS.md
index 53c09084..c601cacb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -115,6 +115,7 @@ All scripts are in `package.json`.
- React: prefer function components + hooks; class only when needed (e.g. error boundaries).
- Control flow: avoid nested ternaries; prefer early returns + `if/else`/`switch`.
- Styling: Tailwind v4; typography via `packages/ui/src/lib/typography.ts`; theme vars via `packages/ui/src/lib/theme/`.
+- Shared UI patterns: for "series of items + divider + series of items" layouts, use shared UI primitives instead of duplicating ad-hoc markup in feature components.
- Toasts: use custom toast wrapper from `@/components/ui` (backed by `packages/ui/src/components/ui/toast.ts`); do not import `sonner` directly in feature code.
- No new deps unless asked.
- Never add secrets (`.env`, keys) or log sensitive data.
diff --git a/bun.lock b/bun.lock
index e8e349c0..93c0d94e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -32,7 +32,7 @@
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
- "@opencode-ai/sdk": "^1.2.26",
+ "@opencode-ai/sdk": "^1.2.27",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -98,7 +98,7 @@
},
"packages/desktop": {
"name": "@openchamber/desktop",
- "version": "1.8.5",
+ "version": "1.8.7",
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/node": "^24.3.1",
@@ -107,7 +107,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
- "version": "1.8.5",
+ "version": "1.8.7",
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.1",
@@ -137,7 +137,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
- "@opencode-ai/sdk": "^1.2.26",
+ "@opencode-ai/sdk": "^1.2.27",
"@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -211,10 +211,10 @@
},
"packages/vscode": {
"name": "openchamber",
- "version": "1.8.5",
+ "version": "1.8.7",
"dependencies": {
"@openchamber/ui": "workspace:*",
- "@opencode-ai/sdk": "^1.2.26",
+ "@opencode-ai/sdk": "^1.2.27",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
@@ -234,7 +234,7 @@
},
"packages/web": {
"name": "@openchamber/web",
- "version": "1.8.5",
+ "version": "1.8.7",
"bin": {
"openchamber": "./bin/cli.js",
},
@@ -246,7 +246,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@octokit/rest": "^22.0.1",
- "@opencode-ai/sdk": "^1.2.26",
+ "@opencode-ai/sdk": "^1.2.27",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -841,7 +841,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
- "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.20", "", {}, "sha512-U5ROpG21D8jg9rkc1IgKAk1g5dn6X/rkOBfveupd0peSDO9n6VM9aikYccVLaMObxVqdjtG08IeQOFTPVS8ySQ=="],
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="],
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
diff --git a/docs/CUSTOM_THEMES.md b/docs/CUSTOM_THEMES.md
index db04adec..fbaf1c2f 100644
--- a/docs/CUSTOM_THEMES.md
+++ b/docs/CUSTOM_THEMES.md
@@ -45,9 +45,9 @@ OpenChamber supports user-defined themes. Drop a JSON file into the themes direc
"surface": {
"background": "#100F0F",
"foreground": "#CECDC3",
- "muted": "#1C1B1A",
+ "muted": "#1C1B1A90",
"mutedForeground": "#878580",
- "elevated": "#1C1A19",
+ "elevated": "#1C1A1990",
"elevatedForeground": "#CECDC3",
"overlay": "#00000080",
"subtle": "#1e1d1c"
@@ -194,10 +194,10 @@ OpenChamber supports user-defined themes. Drop a JSON file into the themes direc
},
"radius": {
"none": "0",
- "sm": "0.125rem",
- "md": "0.375rem",
- "lg": "0.5rem",
- "xl": "0.75rem",
+ "sm": "0.325rem",
+ "md": "0.75rem",
+ "lg": "1.125rem",
+ "xl": "1.5rem",
"full": "9999px"
},
"transitions": {
@@ -209,6 +209,10 @@ OpenChamber supports user-defined themes. Drop a JSON file into the themes direc
}
```
+## Surface Alpha Requirement
+
+- `colors.surface.muted` and `colors.surface.elevated` must always use 90 alpha (`...90` in 8-digit hex, e.g. `#1C1B1A90`).
+
## Validation
Themes are validated on load. Invalid themes are skipped with a console warning.
diff --git a/docs/references/chat_example.png b/docs/references/chat_example.png
index 20b22a10..6937f8f1 100644
Binary files a/docs/references/chat_example.png and b/docs/references/chat_example.png differ
diff --git a/docs/references/diff_example.png b/docs/references/diff_example.png
index e3d0eac4..38a1fc10 100644
Binary files a/docs/references/diff_example.png and b/docs/references/diff_example.png differ
diff --git a/docs/references/tool_output_example.png b/docs/references/tool_output_example.png
index fb93131b..20e2464b 100644
Binary files a/docs/references/tool_output_example.png and b/docs/references/tool_output_example.png differ
diff --git a/docs/references/web_version_example.png b/docs/references/web_version_example.png
index c4307e87..28b2ef59 100644
Binary files a/docs/references/web_version_example.png and b/docs/references/web_version_example.png differ
diff --git a/package.json b/package.json
index 1d30f652..5e45e354 100644
--- a/package.json
+++ b/package.json
@@ -89,7 +89,7 @@
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
- "@opencode-ai/sdk": "^1.2.26",
+ "@opencode-ai/sdk": "^1.2.27",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock
index 3410b2c2..d8390d2c 100644
--- a/packages/desktop/src-tauri/Cargo.lock
+++ b/packages/desktop/src-tauri/Cargo.lock
@@ -2687,6 +2687,7 @@ dependencies = [
"tauri-plugin-updater",
"tokio",
"url",
+ "window-vibrancy 0.7.1",
]
[[package]]
@@ -4269,7 +4270,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
- "window-vibrancy",
+ "window-vibrancy 0.6.0",
"windows",
]
@@ -5461,6 +5462,21 @@ dependencies = [
"windows-version",
]
+[[package]]
+name = "window-vibrancy"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6"
+dependencies = [
+ "objc2 0.6.3",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "raw-window-handle",
+ "windows-sys 0.60.2",
+ "windows-version",
+]
+
[[package]]
name = "windows"
version = "0.61.3"
diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml
index aafc1163..53833aad 100644
--- a/packages/desktop/src-tauri/Cargo.toml
+++ b/packages/desktop/src-tauri/Cargo.toml
@@ -30,3 +30,6 @@ url = "2.5"
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
+
+[target.'cfg(target_os = "macos")'.dependencies]
+window-vibrancy = "0.7.1"
diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs
index 9c1a9eb0..db3edd60 100644
--- a/packages/desktop/src-tauri/src/main.rs
+++ b/packages/desktop/src-tauri/src/main.rs
@@ -24,6 +24,10 @@ use std::{
};
use tauri::utils::config::BackgroundThrottlingPolicy;
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
+#[cfg(target_os = "macos")]
+use window_vibrancy::{
+ apply_vibrancy, clear_vibrancy, NSVisualEffectMaterial,
+};
/// Global counter for generating unique window labels.
static WINDOW_COUNTER: AtomicU64 = AtomicU64::new(1);
@@ -2358,6 +2362,78 @@ fn build_init_script(local_origin: &str) -> String {
init_script
}
+fn parse_theme_override(theme_mode: Option<&str>, theme_variant: Option<&str>) -> Option {
+ match theme_mode.map(str::trim) {
+ Some("system") => None,
+ Some("dark") => Some(tauri::Theme::Dark),
+ Some("light") => Some(tauri::Theme::Light),
+ _ => match theme_variant.map(str::trim) {
+ Some("dark") => Some(tauri::Theme::Dark),
+ Some("light") => Some(tauri::Theme::Light),
+ _ => None,
+ },
+ }
+}
+
+fn read_desktop_theme_override() -> Option {
+ let settings = fs::read_to_string(settings_file_path())
+ .ok()
+ .and_then(|raw| serde_json::from_str::(&raw).ok());
+
+ let use_system_theme = settings
+ .as_ref()
+ .and_then(|value| value.get("useSystemTheme"))
+ .and_then(|value| value.as_bool());
+
+ if matches!(use_system_theme, Some(true)) {
+ return None;
+ }
+
+ let theme_mode = settings
+ .as_ref()
+ .and_then(|value| value.get("themeMode"))
+ .and_then(|value| value.as_str());
+
+ let theme_variant = settings
+ .as_ref()
+ .and_then(|value| value.get("themeVariant"))
+ .and_then(|value| value.as_str());
+
+ parse_theme_override(theme_mode, theme_variant)
+}
+
+#[cfg(target_os = "macos")]
+fn apply_macos_window_vibrancy(window: &tauri::WebviewWindow) {
+ let _ = clear_vibrancy(window);
+
+ if let Err(error) = apply_vibrancy(
+ window,
+ NSVisualEffectMaterial::Sidebar,
+ None,
+ None,
+ ) {
+ log::warn!("[desktop:vibrancy] Failed to apply macOS vibrancy: {error}");
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+fn apply_macos_window_vibrancy(_window: &tauri::WebviewWindow) {}
+
+#[tauri::command]
+fn desktop_set_window_theme(
+ window: tauri::WebviewWindow,
+ theme_mode: Option,
+ theme_variant: Option,
+) -> Result<(), String> {
+ let override_theme = parse_theme_override(theme_mode.as_deref(), theme_variant.as_deref());
+
+ window
+ .set_theme(override_theme)
+ .map_err(|error| format!("failed to set window theme: {error}"))?;
+
+ Ok(())
+}
+
fn is_window_state_visible(app: &tauri::AppHandle, state: &DesktopWindowState) -> bool {
if state.width == 0 || state.height == 0 {
return false;
@@ -2523,6 +2599,7 @@ fn create_window(
#[cfg(target_os = "macos")]
{
builder = builder
+ .transparent(true)
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay)
.traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition {
@@ -2532,6 +2609,8 @@ fn create_window(
}
let window = builder.build()?;
+ let _ = window.set_theme(read_desktop_theme_override());
+ apply_macos_window_vibrancy(&window);
if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) {
if state.maximized || state.fullscreen {
@@ -2583,6 +2662,7 @@ fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Resu
#[cfg(target_os = "macos")]
{
builder = builder
+ .transparent(true)
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay)
.traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition {
@@ -2592,6 +2672,8 @@ fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Resu
}
let window = builder.build()?;
+ let _ = window.set_theme(read_desktop_theme_override());
+ apply_macos_window_vibrancy(&window);
if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) {
if state.maximized || state.fullscreen {
@@ -3025,6 +3107,7 @@ fn main() {
desktop_hosts_get,
desktop_hosts_set,
desktop_host_probe,
+ desktop_set_window_theme,
remote_ssh::desktop_ssh_instances_get,
remote_ssh::desktop_ssh_instances_set,
remote_ssh::desktop_ssh_import_hosts,
diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json
index ba5187e0..b80aad79 100644
--- a/packages/desktop/src-tauri/tauri.conf.json
+++ b/packages/desktop/src-tauri/tauri.conf.json
@@ -15,7 +15,7 @@
"label": "main",
"create": false,
"title": "OpenChamber",
- "transparent": false,
+ "transparent": true,
"width": 1280,
"height": 800,
"resizable": true,
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 944f5a67..9cf53a5b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -39,7 +39,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
- "@opencode-ai/sdk": "^1.2.26",
+ "@opencode-ai/sdk": "^1.2.27",
"@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 2e349a40..13875df0 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -113,6 +113,7 @@ function App({ apis }: AppProps) {
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode);
const [showCliOnboarding, setShowCliOnboarding] = React.useState(false);
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
+ const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []);
const appReadyDispatchedRef = React.useRef(false);
const embeddedSessionChat = React.useMemo(() => readEmbeddedSessionChatConfig(), []);
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
@@ -534,7 +535,7 @@ function App({ apis }: AppProps) {
-
+
diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx
index e713ae7f..36a5c995 100644
--- a/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/packages/ui/src/components/chat/ChatContainer.tsx
@@ -15,7 +15,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
import { useDeviceInfo } from '@/lib/device';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { TimelineDialog } from './TimelineDialog';
import type { PermissionRequest } from '@/types/permission';
@@ -30,6 +30,7 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
const EMPTY_QUESTIONS: QuestionRequest[] = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
+const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected';
type HydratingToolSkeletonRow = {
id: string;
@@ -193,7 +194,7 @@ export const ChatContainer: React.FC = () => {
}, [parentSession, setCurrentSession]);
const returnToParentButton = parentSession ? (
-
{
>
Parent
-
+
) : null;
React.useEffect(() => {
@@ -255,6 +256,7 @@ export const ChatContainer: React.FC = () => {
isPinned,
isOverflowing,
});
+ const { resumeToBottomInstant } = timelineController;
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
@@ -269,6 +271,26 @@ export const ChatContainer: React.FC = () => {
resumeToBottom: timelineController.resumeToBottom,
});
+ React.useEffect(() => {
+ if (typeof window === 'undefined' || !currentSessionId) {
+ return;
+ }
+
+ const handleSessionReselected = (event: Event) => {
+ const customEvent = event as CustomEvent
;
+ if (customEvent.detail !== currentSessionId) {
+ return;
+ }
+
+ resumeToBottomInstant();
+ };
+
+ window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
+ return () => {
+ window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
+ };
+ }, [currentSessionId, resumeToBottomInstant]);
+
React.useLayoutEffect(() => {
const container = scrollRef.current;
if (!container) {
@@ -366,7 +388,7 @@ export const ChatContainer: React.FC = () => {
>
{!isDesktopExpandedInput ? (
-
+
) : null}
{
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
- : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
+ : 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
@@ -444,7 +466,7 @@ export const ChatContainer: React.FC = () => {
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
- : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
+ : 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
@@ -507,7 +529,7 @@ export const ChatContainer: React.FC = () => {
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
- : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
+ : 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
diff --git a/packages/ui/src/components/chat/ChatEmptyState.tsx b/packages/ui/src/components/chat/ChatEmptyState.tsx
index 0dbd93f9..00c30eef 100644
--- a/packages/ui/src/components/chat/ChatEmptyState.tsx
+++ b/packages/ui/src/components/chat/ChatEmptyState.tsx
@@ -1,12 +1,7 @@
import React from 'react';
-import { RiGitBranchLine } from '@remixicon/react';
-
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { TextLoop } from '@/components/ui/TextLoop';
import { useThemeSystem } from '@/contexts/useThemeSystem';
-import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
-import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
-import { useGitStatus, useGitStore } from '@/stores/useGitStore';
const phrases = [
"Fix the failing tests",
@@ -27,51 +22,15 @@ const phrases = [
"Add type definitions",
];
-interface ChatEmptyStateProps {
- showDraftContext?: boolean;
-}
-
-const ChatEmptyState: React.FC
= ({
- showDraftContext = false,
-}) => {
+const ChatEmptyState: React.FC = () => {
const { currentTheme } = useThemeSystem();
- const { git } = useRuntimeAPIs();
- const effectiveDirectory = useEffectiveDirectory();
- const { setActiveDirectory, fetchStatus } = useGitStore();
- const gitStatus = useGitStatus(effectiveDirectory ?? null);
// Use theme's muted foreground for secondary text
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
- const branchName = typeof gitStatus?.current === 'string' && gitStatus.current.trim().length > 0
- ? gitStatus.current.trim()
- : null;
-
- React.useEffect(() => {
- if (!showDraftContext || !effectiveDirectory) {
- return;
- }
-
- setActiveDirectory(effectiveDirectory);
-
- const state = useGitStore.getState().directories.get(effectiveDirectory);
- if (!state?.status && state?.isGitRepo !== false) {
- void fetchStatus(effectiveDirectory, git, { silent: true });
- }
- }, [effectiveDirectory, fetchStatus, git, setActiveDirectory, showDraftContext]);
return (
- {showDraftContext && (
-
- {branchName && (
-
-
- {branchName}
-
- )}
-
- )}
{
+ if (typeof value !== 'string') {
+ return null;
+ }
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return null;
+ }
+ const normalized = trimmed.replace(/\\/g, '/');
+ if (normalized === '/') {
+ return '/';
+ }
+ return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
+};
+
+const getProjectDisplayLabel = (project: { label?: string; path: string }): string => {
+ const label = project.label?.trim();
+ if (label) {
+ return label;
+ }
+ return formatDirectoryName(project.path);
+};
+
+const getProjectIconColor = (projectColor?: string | null): string | undefined => {
+ if (!projectColor) {
+ return undefined;
+ }
+ return PROJECT_COLOR_MAP[projectColor] ?? undefined;
+};
const appendWithLineBreaks = (base: string, next: string): string => {
const separator = !base
@@ -154,10 +195,17 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
const skillRef = React.useRef(null);
// Ref to track current message value without triggering re-renders in effects
const messageRef = React.useRef(message);
+ const draftPersistTimerRef = React.useRef | null>(null);
+ const skipNextDraftPersistRef = React.useRef(false);
+ const lastPersistedDraftRef = React.useRef
- {
className="!font-normal"
>
{isRunning ? 'Cleaning up...' : 'Run cleanup now'}
-
+
diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx
index d09c82f0..2186ae39 100644
--- a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx
@@ -18,7 +18,7 @@ import {
RiRestartLine,
} from '@remixicon/react';
import { toast } from '@/components/ui';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { GridLoader } from '@/components/ui/grid-loader';
import { Input } from '@/components/ui/input';
@@ -1121,7 +1121,7 @@ export const TunnelSettings: React.FC = () => {
{TUNNEL_MODE_OPTIONS.map((option) => (
- {
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
>
{option.label}
-
+
{option.tooltip}
@@ -1217,7 +1217,7 @@ export const TunnelSettings: React.FC = () => {
Saved managed remote tunnels
-
{
>
Add
-
+
{managedRemoteTunnelPresets.length > 0 ? (
@@ -1263,7 +1263,7 @@ export const TunnelSettings: React.FC = () => {
{preset.name}
- {
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
>
-
+
@@ -1305,7 +1305,7 @@ export const TunnelSettings: React.FC = () => {
disabled={state === 'starting' || state === 'stopping'}
/>
- {
}}
>
Save token
-
+
@@ -1363,7 +1363,7 @@ export const TunnelSettings: React.FC = () => {
)}
- {
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
>
Save
-
-
+
+
)}
@@ -1442,7 +1442,7 @@ export const TunnelSettings: React.FC = () => {
className="h-7"
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
/>
- {
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
>
-
+
{managedLocalConfigPath && (
- {
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
>
-
+
)}
@@ -1566,7 +1566,7 @@ export const TunnelSettings: React.FC = () => {
)}
- {
{state === 'starting'
? <> Starting tunnel...>
: 'Start Tunnel'}
-
+
)}
@@ -1610,12 +1610,12 @@ export const TunnelSettings: React.FC = () => {
{tunnelInfo.connectUrl}
-
+
+
Expires: {tunnelInfo.bootstrapExpiresAt ? remainingText : 'Never'}
@@ -1634,7 +1634,7 @@ export const TunnelSettings: React.FC = () => {
- {
>
New connect link
-
+
- {
{state === 'stopping'
? <> Stopping...>
: 'Stop Tunnel'}
-
+
@@ -1662,7 +1662,7 @@ export const TunnelSettings: React.FC = () => {
{state === 'error' && errorMessage && (
{errorMessage}
- Retry
+
)}
diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
index 41924746..56b77df8 100644
--- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
@@ -11,7 +11,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { NumberInput } from '@/components/ui/number-input';
import { RiPlayLine, RiStopLine, RiCloseLine, RiAppleLine, RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -363,7 +363,7 @@ export const VoiceSettings: React.FC = () => {
- setVoiceProvider('browser')}
@@ -375,8 +375,8 @@ export const VoiceSettings: React.FC = () => {
)}
>
Browser
-
-
+
+
{isSayAvailable && (
- setVoiceProvider('say')}
@@ -403,7 +403,7 @@ export const VoiceSettings: React.FC = () => {
>
Say
-
+
)}
@@ -455,9 +455,9 @@ export const VoiceSettings: React.FC = () => {
))}
-
+
+
>
)}
@@ -473,9 +473,9 @@ export const VoiceSettings: React.FC = () => {
))}
-
+
+
>
)}
@@ -492,9 +492,9 @@ export const VoiceSettings: React.FC = () => {
))}
-
+
+
>
)}
diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
index a502e2a5..881a47ad 100644
--- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
+++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -306,7 +306,7 @@ export const WorktreeSectionContent: React.FC = ({
))}
- = ({
>
Add command
-
+
)}
diff --git a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx
index 592f631d..3546c65a 100644
--- a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx
+++ b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx
@@ -7,7 +7,7 @@ import {
RiInformationLine,
RiPlayLine,
} from '@remixicon/react';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
@@ -198,10 +198,10 @@ export const ProjectActionsSection: React.FC = ({ pr
Actions
Per-project commands shown in header next to project name.
-
+
+
@@ -248,7 +248,7 @@ export const ProjectActionsSection: React.FC = ({ pr
- = ({ pr
onClick={() => handleRemoveAction(action.id)}
>
-
+
@@ -418,7 +418,7 @@ export const ProjectActionsSection: React.FC = ({ pr
{validationError ? (
{validationError}
) : null}
- = ({ pr
disabled={!canSave}
>
{isSaving ? 'Saving...' : 'Save Actions'}
-
+
diff --git a/packages/ui/src/components/sections/projects/ProjectsPage.tsx b/packages/ui/src/components/sections/projects/ProjectsPage.tsx
index 607d7415..7b2fa97e 100644
--- a/packages/ui/src/components/sections/projects/ProjectsPage.tsx
+++ b/packages/ui/src/components/sections/projects/ProjectsPage.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { Input } from '@/components/ui/input';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
@@ -399,7 +399,7 @@ export const ProjectsPage: React.FC = () => {
placeholder="#000000"
className="h-7 w-[8rem]"
/>
- {
disabled={!iconBackground}
>
-
+
)}
{!hasCustomIcon && (
<>
- fileInputRef.current?.click()}
disabled={isUploadingIcon}
>
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
-
-
+
+
>
)}
{hasRemovableImageIcon && (
- {
disabled={isRemovingCustomIcon}
>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
-
+
)}
{pendingRemoveImageIcon && (
- {
disabled={isRemovingCustomIcon}
>
Undo Remove
-
+
)}
@@ -463,14 +463,14 @@ export const ProjectsPage: React.FC = () => {
-
Save Changes
-
+
diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx
index 6a413d2a..c65b78cb 100644
--- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx
+++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx
@@ -3,7 +3,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
@@ -653,14 +653,14 @@ export const ProvidersPage: React.FC = () => {
placeholder="sk-..."
className="flex-1 font-mono text-xs"
/>
- handleSaveApiKey(candidateProviderId)}
disabled={authBusyKey === `api:${candidateProviderId}`}
>
{authBusyKey === `api:${candidateProviderId}` ? 'Saving...' : 'Save Key'}
-
+
@@ -693,7 +693,7 @@ export const ProvidersPage: React.FC = () => {
)}
- {
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
>
Connect
-
+
{oauthDetails[codeKey]?.instructions && (
@@ -713,7 +713,7 @@ export const ProvidersPage: React.FC = () => {
{oauthDetails[codeKey]?.userCode && (
- handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code
+
)}
@@ -721,8 +721,8 @@ export const ProvidersPage: React.FC = () => {
)}
@@ -740,14 +740,14 @@ export const ProvidersPage: React.FC = () => {
placeholder="Paste authorization code"
className="font-mono text-xs"
/>
- handleOAuthComplete(candidateProviderId, index)}
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
>
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? 'Saving...' : 'Complete'}
-
+
)}
@@ -810,14 +810,14 @@ export const ProvidersPage: React.FC = () => {
Authentication
- setShowAuthPanel((prev) => !prev)}
>
{showAuthPanel ? 'Hide' : 'Reconnect'}
-
+
@@ -856,14 +856,14 @@ export const ProvidersPage: React.FC = () => {
placeholder="sk-..."
className="flex-1 font-mono text-xs"
/>
- handleSaveApiKey(selectedProvider.id)}
disabled={authBusyKey === `api:${selectedProvider.id}`}
>
{authBusyKey === `api:${selectedProvider.id}` ? 'Saving...' : 'Save Key'}
-
+
@@ -886,7 +886,7 @@ export const ProvidersPage: React.FC = () => {
)}
- {
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
>
Connect
-
+
{oauthDetails[codeKey]?.instructions && (
@@ -906,7 +906,7 @@ export const ProvidersPage: React.FC = () => {
{oauthDetails[codeKey]?.userCode && (
- handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code
+
)}
@@ -914,8 +914,8 @@ export const ProvidersPage: React.FC = () => {
)}
@@ -933,14 +933,14 @@ export const ProvidersPage: React.FC = () => {
placeholder="Paste authorization code"
className="font-mono text-xs"
/>
- handleOAuthComplete(selectedProvider.id, index)}
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
>
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? 'Saving...' : 'Complete'}
-
+
)}
@@ -976,7 +976,7 @@ export const ProvidersPage: React.FC = () => {
)}
- {
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
>
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting...' : 'Disconnect'}
-
+
@@ -1001,7 +1001,7 @@ export const ProvidersPage: React.FC = () => {
)}
- {
}}
>
Hide all
-
-
+
+
diff --git a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
index 7a6eac98..8e21ba5a 100644
--- a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
+++ b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { RiAddLine, RiStackLine } from '@remixicon/react';
@@ -110,7 +110,7 @@ export const ProvidersSidebar: React.FC = ({ onItemSelect
Total {providers.length}
-
{
@@ -121,7 +121,7 @@ export const ProvidersSidebar: React.FC = ({ onItemSelect
title="Connect provider"
>
-
+
diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
index 78ae57e4..223ca3e1 100644
--- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
+++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { Switch } from '@/components/ui/switch';
@@ -701,7 +701,7 @@ export const RemoteInstancesPage: React.FC = () => {
{candidate.source} config
- {
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
Create
-
+
))}
@@ -746,12 +746,12 @@ export const RemoteInstancesPage: React.FC = () => {
autoFocus
/>
-
+
-
+
+
+
@@ -782,7 +782,7 @@ export const RemoteInstancesPage: React.FC = () => {
- {
>
{canDisconnect ? : }
{primaryButtonLabel}
-
-
+
-
+
-
+
+
{status?.localUrl ? (
@@ -1114,7 +1114,7 @@ export const RemoteInstancesPage: React.FC = () => {
}}
emptyLabel="Auto"
/>
- {
}
>
-
+
@@ -1254,7 +1254,7 @@ export const RemoteInstancesPage: React.FC = () => {
updateForward((item) => ({ ...item, enabled: checked }))} aria-label="Enable forward" />
- {
}
>
-
+
@@ -1416,7 +1416,7 @@ export const RemoteInstancesPage: React.FC = () => {
{canOpenLocalEndpoint ? (
- {
>
Open local
-
+
) : null}
@@ -1440,7 +1440,7 @@ export const RemoteInstancesPage: React.FC = () => {
);
})}
- {
>
Add forward
-
+
@@ -1486,7 +1486,7 @@ export const RemoteInstancesPage: React.FC = () => {
{candidate.sshCommand}
- {
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
Import
-
+
))}
@@ -1504,12 +1504,12 @@ export const RemoteInstancesPage: React.FC = () => {
-
void handleSave()} disabled={!hasChanges || isSaving}>
+
+
{status?.localUrl ? (
<>
-
{
>
Copy local URL
-
-
+
+
>
) : null}
{error ?
{error}
: null}
@@ -1552,14 +1552,14 @@ export const RemoteInstancesPage: React.FC = () => {
-
+
- void handleClearLogs()} disabled={logDialogLoading}>
+
+
+
{logDialogLoading ? (
Loading logs...
@@ -1602,12 +1602,12 @@ export const RemoteInstancesPage: React.FC = () => {
autoFocus
/>
-
+
-
+
+
+
diff --git a/packages/ui/src/components/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx
index 20ce25a6..eaaab6e6 100644
--- a/packages/ui/src/components/sections/skills/SkillsPage.tsx
+++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
@@ -20,7 +20,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { ButtonLarge } from '@/components/ui/button-large';
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
import {
SKILL_LOCATION_OPTIONS,
@@ -459,9 +458,9 @@ const SkillsInstalledPage: React.FC = () => {
Supporting Files
-
+
+
@@ -491,7 +490,7 @@ const SkillsInstalledPage: React.FC = () => {
pending
)}
- {
@@ -500,7 +499,7 @@ const SkillsInstalledPage: React.FC = () => {
}}
>
-
+
))}
@@ -511,14 +510,14 @@ const SkillsInstalledPage: React.FC = () => {
{/* Save action */}
-
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
-
+
@@ -540,16 +539,17 @@ const SkillsInstalledPage: React.FC = () => {
- setDeleteFilePath(null)}
disabled={isDeletingFile}
>
Cancel
-
-
+
+
+
@@ -603,7 +603,8 @@ const SkillsInstalledPage: React.FC = () => {
)}
- {
setIsFileDialogOpen(false);
@@ -611,10 +612,10 @@ const SkillsInstalledPage: React.FC = () => {
}}
>
Cancel
-
-
+
+
+
diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx
index 01564c44..505adeae 100644
--- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx
+++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx
@@ -1,6 +1,5 @@
import React, { useMemo } from 'react';
-import { ButtonSmall } from '@/components/ui/button-small';
-import { ButtonLarge } from '@/components/ui/button-large';
+import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
@@ -207,13 +206,13 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
Total {skills.length}
-
-
+
@@ -347,17 +346,18 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
- setDeleteDialogSkill(null)}
disabled={isDeletePending}
- className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
-
-
+
+
+
@@ -383,16 +383,17 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
}}
/>
- setRenameDialogSkill(null)}
- className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
-
-
+
+
+
@@ -461,12 +462,12 @@ const SkillListItem: React.FC = ({
-
-
+
= ({ open, onOpen
- onOpenChange(false)}>
+
-
+
-
+
+
diff --git a/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx
index 2c5fd79a..65364520 100644
--- a/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx
+++ b/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx
@@ -8,8 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { ButtonLarge } from '@/components/ui/button-large';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -73,8 +72,8 @@ export const InstallConflictsDialog: React.FC = ({
{conflicts.length} conflict(s)
- setAll('skip')}>Skip all
- setAll('overwrite')}>Overwrite all
+
+
@@ -113,15 +112,16 @@ export const InstallConflictsDialog: React.FC = ({
- onOpenChange(false)}>
+
-
+
+
diff --git a/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx
index cf5f9917..079009f6 100644
--- a/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx
+++ b/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx
@@ -10,7 +10,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
-import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Checkbox } from '@/components/ui/checkbox';
@@ -487,15 +486,16 @@ export const InstallFromRepoDialog: React.FC = ({ op
- onOpenChange(false)}>
+
-
+
+
diff --git a/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx
index 01250ad6..c2b48fba 100644
--- a/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx
+++ b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx
@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { ButtonLarge } from '@/components/ui/button-large';
+import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -231,13 +231,15 @@ export const InstallSkillDialog: React.FC = ({ open, on
- onOpenChange(false)}
>
Cancel
-
-
+
+
diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx
index 757c6241..edd0971b 100644
--- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx
+++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx
@@ -1,6 +1,6 @@
import React from 'react';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
@@ -12,7 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { ButtonLarge } from '@/components/ui/button-large';
import {
Select,
SelectContent,
@@ -195,7 +194,7 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo
- = ({ mode, onMo
title="Refresh"
>
-
+
{isCustomSource && (
- = ({ mode, onMo
title="Remove Catalog"
>
-
+
)}
- setAddCatalogOpen(true)}
>
Add Catalog
-
+
@@ -328,7 +327,7 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo
) : null}
- = ({ mode, onMo
}}
>
Install
-
+
);
@@ -350,7 +349,7 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
- = ({ mode, onMo
disabled={isLoadingMore}
>
{isLoadingMore ? 'Loading...' : 'Load More Skills'}
-
+
)}
@@ -381,16 +380,17 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo
Are you sure you want to remove this catalog?
- setIsRemoveCatalogDialogOpen(false)}
disabled={isRemovingCatalog}
>
Cancel
-
- void removeSelectedCatalog()} disabled={isRemovingCatalog}>
+
+
+
diff --git a/packages/ui/src/components/sections/usage/UsageSidebar.tsx b/packages/ui/src/components/sections/usage/UsageSidebar.tsx
index 962c77bd..a93cf8ad 100644
--- a/packages/ui/src/components/sections/usage/UsageSidebar.tsx
+++ b/packages/ui/src/components/sections/usage/UsageSidebar.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
-import { ButtonSmall } from '@/components/ui/button-small';
+import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -111,7 +111,7 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => {
5m
- fetchAllQuotas()}
@@ -120,7 +120,7 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => {
disabled={isLoading}
>
-
+
diff --git a/packages/ui/src/components/session/SessionFolderItem.tsx b/packages/ui/src/components/session/SessionFolderItem.tsx
index fa7f624b..94e2d345 100644
--- a/packages/ui/src/components/session/SessionFolderItem.tsx
+++ b/packages/ui/src/components/session/SessionFolderItem.tsx
@@ -148,8 +148,8 @@ const SessionFolderItemBase =
({
({
}
aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`}
>
-
+
{renaming ? (
@@ -243,15 +248,16 @@ const SessionFolderItemBase = ({
{/* Action buttons */}
- {!renaming && !hideActions ? (
+ {!renaming && (!hideActions || archivedBucket) ? (
- {onNewSession ? (
+ {!archivedBucket && onNewSession ? (
) : null}
{/* Only allow sub-folders at depth 0 (one level deep max) */}
- {onNewSubFolder && depth === 0 ? (
+ {!archivedBucket && onNewSubFolder && depth === 0 ? (
) : null}
-
+ {!archivedBucket ? (
+
+ ) : null}
diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx
index b3f25303..d4910c70 100644
--- a/packages/ui/src/components/session/SessionSidebar.tsx
+++ b/packages/ui/src/components/session/SessionSidebar.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
+import { RiLayoutLeftLine } from '@remixicon/react';
import { toast } from '@/components/ui';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -9,10 +11,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
-import { useConfigStore } from '@/stores/useConfigStore';
import type { GitHubPullRequestStatus } from '@/lib/api/types';
import { getSafeStorage } from '@/stores/utils/safeStorage';
-import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { useGitStore } from '@/stores/useGitStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -35,10 +35,14 @@ import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
+import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { SessionGroupSection } from './sidebar/SessionGroupSection';
import { SidebarHeader } from './sidebar/SidebarHeader';
+import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
+import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
+import type { SortableDragHandleProps } from './sidebar/sortableItems';
import {
FolderDeleteConfirmDialog,
SessionDeleteConfirmDialog,
@@ -46,6 +50,13 @@ import {
type DeleteSessionConfirmState,
} from './sidebar/ConfirmDialogs';
import { type SessionGroup, type SessionNode } from './sidebar/types';
+import {
+ addActiveNowSession,
+ deriveActiveNowSessions,
+ persistActiveNowEntries,
+ pruneActiveNowEntries,
+ readActiveNowEntries,
+} from './sidebar/activitySections';
import {
compareSessionsByPinnedAndTime,
formatProjectLabel,
@@ -130,7 +141,6 @@ interface SessionSidebarProps {
onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean;
hideDirectoryControls?: boolean;
- hideProjectSelector?: boolean;
showOnlyMainWorkspace?: boolean;
}
@@ -139,7 +149,6 @@ export const SessionSidebar: React.FC = ({
onSessionSelected,
allowReselect = false,
hideDirectoryControls = false,
- hideProjectSelector = true,
showOnlyMainWorkspace = false,
}) => {
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
@@ -148,13 +157,13 @@ export const SessionSidebar: React.FC = ({
const sessionSearchInputRef = React.useRef(null);
const [editingId, setEditingId] = React.useState(null);
const [editTitle, setEditTitle] = React.useState('');
- const [editingProjectId, setEditingProjectId] = React.useState(null);
- const [editProjectTitle, setEditProjectTitle] = React.useState('');
+ const [editingProjectDialogId, setEditingProjectDialogId] = React.useState(null);
const [expandedParents, setExpandedParents] = React.useState>(new Set());
const [directoryStatus, setDirectoryStatus] = React.useState>(
() => new Map(),
);
const safeStorage = React.useMemo(() => getSafeStorage(), []);
+ const [activeNowEntries, setActiveNowEntries] = React.useState(() => readActiveNowEntries(safeStorage));
const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set());
const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map());
@@ -162,7 +171,7 @@ export const SessionSidebar: React.FC = ({
const [hoveredProjectId, setHoveredProjectId] = React.useState(null);
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
- const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null);
+ const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState(null);
const [renamingFolderId, setRenamingFolderId] = React.useState(null);
const [renameFolderDraft, setRenameFolderDraft] = React.useState('');
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState(null);
@@ -228,8 +237,6 @@ export const SessionSidebar: React.FC = ({
}
});
- const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
- const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
const [projectRootBranches, setProjectRootBranches] = React.useState>(new Map());
const projectHeaderSentinelRefs = React.useRef>(new Map());
const ignoreIntersectionUntil = React.useRef(0);
@@ -243,17 +250,21 @@ export const SessionSidebar: React.FC = ({
const addProject = useProjectsStore((state) => state.addProject);
const removeProject = useProjectsStore((state) => state.removeProject);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
- const renameProject = useProjectsStore((state) => state.renameProject);
+ const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
+ const reorderProjects = useProjectsStore((state) => state.reorderProjects);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
+ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
+ const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog);
+ const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
const deviceInfo = useDeviceInfo();
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
+ const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
- const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
const debouncedSessionSearchQuery = useDebouncedValue(sessionSearchQuery, 120);
const normalizedSessionSearchQuery = React.useMemo(
@@ -307,8 +318,89 @@ export const SessionSidebar: React.FC = ({
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
+ const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
+ const isMacPlatform = React.useMemo(() => {
+ if (typeof navigator === 'undefined') {
+ return false;
+ }
+ return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
+ }, []);
+ const showDesktopSidebarChrome = !mobileVariant && !isVSCode;
+ const desktopSidebarTopPaddingClass = isDesktopShellRuntime && isMacPlatform && !isDesktopWindowFullscreen ? 'pl-[5.5rem]' : 'pl-3';
+ const desktopSidebarToggleButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md typography-ui-label font-medium text-foreground transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50';
+
+ React.useEffect(() => {
+ if (!isDesktopShellRuntime || !isMacPlatform) {
+ setIsDesktopWindowFullscreen(false);
+ return;
+ }
+
+ let disposed = false;
+ let unlistenResize: (() => void) | null = null;
+
+ const syncFullscreenState = async () => {
+ try {
+ const { getCurrentWindow } = await import('@tauri-apps/api/window');
+ const currentWindow = getCurrentWindow();
+ const fullscreen = await currentWindow.isFullscreen();
+ if (!disposed) {
+ setIsDesktopWindowFullscreen(fullscreen);
+ }
+ } catch {
+ if (!disposed) {
+ setIsDesktopWindowFullscreen(false);
+ }
+ }
+ };
+
+ const attach = async () => {
+ try {
+ const { getCurrentWindow } = await import('@tauri-apps/api/window');
+ const currentWindow = getCurrentWindow();
+ unlistenResize = await currentWindow.onResized(() => {
+ void syncFullscreenState();
+ });
+ } catch {
+ // Ignore listener setup failures; fallback state remains false.
+ }
+ };
+
+ void syncFullscreenState();
+ void attach();
+
+ return () => {
+ disposed = true;
+ if (unlistenResize) {
+ unlistenResize();
+ }
+ };
+ }, [isDesktopShellRuntime, isMacPlatform]);
+
+ const handleDesktopSidebarDragStart = React.useCallback(async (event: React.MouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (target.closest('.app-region-no-drag')) {
+ return;
+ }
+ if (target.closest('button, a, input, select, textarea')) {
+ return;
+ }
+ if (event.button !== 0) {
+ return;
+ }
+ if (!isDesktopShellRuntime) {
+ return;
+ }
+
+ try {
+ const { getCurrentWindow } = await import('@tauri-apps/api/window');
+ const appWindow = getCurrentWindow();
+ await appWindow.startDragging();
+ } catch (error) {
+ console.error('Failed to start window dragging:', error);
+ }
+ }, [isDesktopShellRuntime]);
const {
buildGroupSearchText,
@@ -359,11 +451,93 @@ export const SessionSidebar: React.FC = ({
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
- useSessionPrefetch({
- currentSessionId,
- sortedSessions,
- loadMessages,
- });
+ const allKnownSessionsById = React.useMemo(() => {
+ const next = new Map();
+ [...sessions, ...archivedSessions].forEach((session) => {
+ next.set(session.id, session);
+ });
+ return next;
+ }, [sessions, archivedSessions]);
+
+ React.useEffect(() => {
+ const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById);
+ if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) {
+ return;
+ }
+ setActiveNowEntries(pruned);
+ persistActiveNowEntries(safeStorage, pruned);
+ }, [activeNowEntries, allKnownSessionsById, safeStorage]);
+
+ const previousStreamingIdsRef = React.useRef>(new Set());
+ React.useEffect(() => {
+ const nextStreamingIds = new Set();
+ sessionStatus?.forEach((status, sessionId) => {
+ if (status?.type === 'busy' || status?.type === 'retry') {
+ nextStreamingIds.add(sessionId);
+ }
+ });
+
+ const previousStreamingIds = previousStreamingIdsRef.current;
+ const startedStreamingIds = Array.from(nextStreamingIds).filter((sessionId) => !previousStreamingIds.has(sessionId));
+ if (startedStreamingIds.length > 0) {
+ setActiveNowEntries((prev) => {
+ const next = startedStreamingIds.reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev);
+ if (next === prev) {
+ return prev;
+ }
+ persistActiveNowEntries(safeStorage, next);
+ return next;
+ });
+ }
+
+ previousStreamingIdsRef.current = nextStreamingIds;
+ }, [sessionStatus, safeStorage]);
+
+ React.useEffect(() => {
+ const busyIds: string[] = [];
+ sessionStatus?.forEach((status, sessionId) => {
+ if (status?.type === 'busy' || status?.type === 'retry') {
+ busyIds.push(sessionId);
+ }
+ });
+
+ if (busyIds.length === 0) {
+ return;
+ }
+
+ setActiveNowEntries((prev) => {
+ const known = new Set(prev.map((entry) => entry.sessionId));
+ let next = prev;
+ let changed = false;
+
+ busyIds.forEach((sessionId) => {
+ if (known.has(sessionId)) {
+ return;
+ }
+
+ const session = allKnownSessionsById.get(sessionId);
+ if (!session || session.time?.archived) {
+ return;
+ }
+
+ const isSubtask = Boolean((session as Session & { parentID?: string | null }).parentID);
+ if (isSubtask) {
+ return;
+ }
+
+ next = addActiveNowSession(next, sessionId);
+ known.add(sessionId);
+ changed = true;
+ });
+
+ if (!changed) {
+ return prev;
+ }
+
+ persistActiveNowEntries(safeStorage, next);
+ return next;
+ });
+ }, [sessionStatus, allKnownSessionsById, safeStorage]);
const childrenMap = React.useMemo(() => {
const map = new Map();
@@ -394,17 +568,21 @@ export const SessionSidebar: React.FC = ({
);
- const handleSaveProjectEdit = React.useCallback(() => {
- if (editingProjectId && editProjectTitle.trim()) {
- renameProject(editingProjectId, editProjectTitle.trim());
- setEditingProjectId(null);
- setEditProjectTitle('');
- }
- }, [editingProjectId, editProjectTitle, renameProject]);
+ const editingProject = React.useMemo(
+ () => projects.find((project) => project.id === editingProjectDialogId) ?? null,
+ [projects, editingProjectDialogId],
+ );
- const handleCancelProjectEdit = React.useCallback(() => {
- setEditingProjectId(null);
- setEditProjectTitle('');
+ const handleSaveProjectEdit = React.useCallback((data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
+ if (!editingProjectDialogId) {
+ return;
+ }
+ updateProjectMeta(editingProjectDialogId, data);
+ setEditingProjectDialogId(null);
+ }, [editingProjectDialogId, updateProjectMeta]);
+
+ const openNewWorktreeDialog = React.useCallback(() => {
+ setNewWorktreeDialogOpen(true);
}, []);
const deleteSession = useSessionStore((state) => state.deleteSession);
@@ -569,6 +747,10 @@ export const SessionSidebar: React.FC
= ({
path: string;
label?: string;
normalizedPath: string;
+ icon?: string;
+ color?: string;
+ iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
+ iconBackground?: string;
}>;
}, [projects]);
@@ -627,7 +809,6 @@ export const SessionSidebar: React.FC = ({
searchMatchCount,
} = useSessionSidebarSections({
normalizedProjects,
- activeProjectId,
getSessionsForProject,
getArchivedSessionsForProject,
availableWorktreesByProject,
@@ -671,7 +852,7 @@ export const SessionSidebar: React.FC = ({
const stableActiveProjectIsRepo = activeProjectForHeader && projectRepoStatus.has(activeProjectForHeader.id)
? activeProjectIsRepo
: lastRepoStatusRef.current;
- const reserveHeaderActionsSpace = Boolean(activeProjectForHeader);
+ const reserveHeaderActionsSpace = true;
const useMobileNotesPanel = mobileVariant || deviceInfo.isMobile;
React.useEffect(() => {
@@ -687,7 +868,6 @@ export const SessionSidebar: React.FC = ({
setActiveSessionByProject,
currentSessionId,
handleSessionSelect,
- isVSCode,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
@@ -698,30 +878,141 @@ export const SessionSidebar: React.FC = ({
});
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
+ const hasInitializedArchivedCollapseRef = React.useRef(false);
- const handleStartInlineProjectRename = React.useCallback(() => {
- if (!activeProjectForHeader) {
+ React.useEffect(() => {
+ if (hasInitializedArchivedCollapseRef.current || projectSections.length === 0) {
return;
}
- setProjectRenameDraft(formatProjectLabel(
- activeProjectForHeader.label?.trim()
- || formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory)
- || activeProjectForHeader.normalizedPath,
- ));
- setIsProjectRenameInline(true);
- }, [activeProjectForHeader, homeDirectory]);
+ const archivedGroupKeys = projectSections.flatMap((section) =>
+ section.groups
+ .filter((group) => group.isArchivedBucket)
+ .map((group) => `${section.project.id}:${group.id}`),
+ );
+ if (archivedGroupKeys.length > 0) {
+ setCollapsedGroups((prev) => new Set([...prev, ...archivedGroupKeys]));
+ }
+ hasInitializedArchivedCollapseRef.current = true;
+ }, [projectSections]);
- const handleSaveInlineProjectRename = React.useCallback(() => {
- if (!activeProjectForHeader) {
- return;
+ const sessionSidebarMetaById = React.useMemo(() => {
+ const meta = new Map();
+
+ projectSections.forEach((section) => {
+ const projectLabel = formatProjectLabel(
+ section.project.label?.trim()
+ || formatDirectoryName(section.project.normalizedPath, homeDirectory)
+ || section.project.normalizedPath,
+ );
+ section.groups.forEach((group) => {
+ const secondaryMeta = group.branch && group.branch !== projectLabel
+ ? { projectLabel, branchLabel: group.branch }
+ : { projectLabel, branchLabel: null };
+
+ const visit = (nodes: SessionNode[]) => {
+ nodes.forEach((node) => {
+ meta.set(node.session.id, {
+ node,
+ projectId: section.project.id,
+ groupDirectory: group.directory,
+ secondaryMeta,
+ });
+ if (node.children.length > 0) {
+ visit(node.children);
+ }
+ });
+ };
+
+ visit(group.sessions);
+ });
+ });
+
+ return meta;
+ }, [projectSections, homeDirectory]);
+
+ const activeNowSessions = React.useMemo(
+ () => deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))),
+ [activeNowEntries, sessions],
+ );
+
+ useSessionPrefetch({
+ currentSessionId,
+ sortedSessions,
+ recentSessionIds: activeNowSessions.map((session) => session.id),
+ loadMessages,
+ });
+
+ const activitySections = React.useMemo(() => {
+ const toItem = (session: Session) => {
+ const existing = sessionSidebarMetaById.get(session.id);
+ const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
+ return {
+ node: existing?.node ?? { session, children: [], worktree: null },
+ projectId: existing?.projectId ?? null,
+ groupDirectory: existing?.groupDirectory ?? sessionDirectory,
+ secondaryMeta: existing?.secondaryMeta ?? null,
+ };
+ };
+
+ return [
+ { key: 'active-now' as const, title: 'recent', items: activeNowSessions.map(toItem) },
+ ];
+ }, [activeNowSessions, sessionSidebarMetaById]);
+
+ const activitySessionIds = React.useMemo(() => {
+ const next = new Set();
+ activitySections.forEach((section) => {
+ section.items.forEach((item) => {
+ next.add(item.node.session.id);
+ });
+ });
+ return next;
+ }, [activitySections]);
+
+ const filteredProjectSections = React.useMemo(() => {
+ if (hasSessionSearchQuery || activitySessionIds.size === 0) {
+ return projectSections;
}
- const trimmed = projectRenameDraft.trim();
- if (!trimmed) {
- return;
+
+ const filterNodes = (nodes: SessionNode[]): SessionNode[] => {
+ return nodes.flatMap((node) => {
+ if (activitySessionIds.has(node.session.id)) {
+ return [];
+ }
+ return [{
+ ...node,
+ children: filterNodes(node.children),
+ }];
+ });
+ };
+
+ return projectSections.map((section) => ({
+ ...section,
+ groups: section.groups.map((group) => ({
+ ...group,
+ sessions: filterNodes(group.sessions),
+ })),
+ }));
+ }, [hasSessionSearchQuery, activitySessionIds, projectSections]);
+
+ const filteredSectionsForRender = React.useMemo(() => {
+ if (hasSessionSearchQuery || activitySessionIds.size === 0) {
+ return sectionsForRender;
}
- renameProject(activeProjectForHeader.id, trimmed);
- setIsProjectRenameInline(false);
- }, [activeProjectForHeader, projectRenameDraft, renameProject]);
+
+ const sectionsByProjectId = new Map(filteredProjectSections.map((section) => [section.project.id, section]));
+ return sectionsForRender
+ .map((section) => sectionsByProjectId.get(section.project.id) ?? section)
+ .filter(Boolean);
+ }, [hasSessionSearchQuery, activitySessionIds, filteredProjectSections, sectionsForRender]);
const desktopHeaderActionButtonClass =
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
@@ -729,14 +1020,6 @@ export const SessionSidebar: React.FC = ({
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
const headerActionIconClass = 'h-4.5 w-4.5';
- const addProjectButtonClass = cn(
- 'inline-flex cursor-pointer items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed',
- mobileVariant
- ? 'h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50'
- : 'h-8 w-8 text-foreground hover:bg-interactive-hover',
- !isDesktopShellRuntime && 'bg-transparent hover:bg-sidebar/40',
- );
-
const stuckProjectHeaders = useStickyProjectHeaders({
isDesktopShellRuntime,
projectSections,
@@ -750,6 +1033,8 @@ export const SessionSidebar: React.FC = ({
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket = false,
+ secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
+ renderContext: 'project' | 'recent' = 'project',
): React.ReactNode => (
= ({
copiedSessionId={copiedSessionId}
handleCopyShareUrl={handleCopyShareUrl}
handleUnshareSession={handleUnshareSession}
- openMenuSessionId={openMenuSessionId}
- setOpenMenuSessionId={setOpenMenuSessionId}
+ openSidebarMenuKey={openSidebarMenuKey}
+ setOpenSidebarMenuKey={setOpenSidebarMenuKey}
renamingFolderId={renamingFolderId}
getFoldersForScope={getFoldersForScope}
getSessionFolderId={getSessionFolderId}
@@ -794,6 +1079,8 @@ export const SessionSidebar: React.FC = ({
handleDeleteSession={handleDeleteSession}
mobileVariant={mobileVariant}
renderSessionNode={renderSessionNode}
+ secondaryMeta={secondaryMeta}
+ renderContext={renderContext}
/>
),
[
@@ -822,8 +1109,8 @@ export const SessionSidebar: React.FC = ({
copiedSessionId,
handleCopyShareUrl,
handleUnshareSession,
- openMenuSessionId,
- setOpenMenuSessionId,
+ openSidebarMenuKey,
+ setOpenSidebarMenuKey,
renamingFolderId,
getFoldersForScope,
getSessionFolderId,
@@ -898,7 +1185,7 @@ export const SessionSidebar: React.FC = ({
}, [prStatusEntries]);
const renderGroupSessions = React.useCallback(
- (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => (
+ (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null) => (
= ({
pinnedSessionIds={pinnedSessionIds}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup}
+ dragHandleProps={dragHandleProps}
/>
),
[
@@ -972,44 +1260,62 @@ export const SessionSidebar: React.FC = ({
],
);
- const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectId);
+ const topContent = !hasSessionSearchQuery ? (
+
+ ) : null;
+ const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
+ const handleSidebarNewSession = React.useCallback(() => {
+ setActiveMainTab('chat');
+ if (mobileVariant) {
+ setSessionSwitcherOpen(false);
+ }
+ openNewSessionDraft();
+ }, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
return (
+ {showDesktopSidebarChrome ? (
+
+
+
+
+
+
+ Close sessions
+
+
+
+ ) : null}
+
= ({
/>
= ({
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraft}
- createWorktreeSession={createWorktreeSession}
+ openNewWorktreeDialog={openNewWorktreeDialog}
openMultiRunLauncher={openMultiRunLauncher}
- setEditingProjectId={setEditingProjectId}
- setEditProjectTitle={setEditProjectTitle}
- editingProjectId={editingProjectId}
- editProjectTitle={editProjectTitle}
- handleSaveProjectEdit={handleSaveProjectEdit}
- handleCancelProjectEdit={handleCancelProjectEdit}
+ openProjectEditDialog={setEditingProjectDialogId}
removeProject={removeProject}
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
- settingsAutoCreateWorktree={settingsAutoCreateWorktree}
+ reorderProjects={reorderProjects}
getOrderedGroups={getOrderedGroups}
setGroupOrderByProject={setGroupOrderByProject}
+ openSidebarMenuKey={openSidebarMenuKey}
+ setOpenSidebarMenuKey={setOpenSidebarMenuKey}
isInlineEditing={isInlineEditing}
/>
+ setSettingsDialogOpen(true)}
+ onOpenShortcuts={toggleHelpDialog}
+ onOpenAbout={() => setAboutDialogOpen(true)}
+ />
+
+ {editingProject ? (
+ {
+ if (!open) {
+ setEditingProjectDialogId(null);
+ }
+ }}
+ projectId={editingProject.id}
+ projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)}
+ projectPath={editingProject.path}
+ initialIcon={editingProject.icon}
+ initialColor={editingProject.color}
+ initialIconBackground={editingProject.iconBackground}
+ onSave={handleSaveProjectEdit}
+ />
+ ) : null}
+
void;
showDeletionDialog: boolean;
setDeleteFolderConfirm: React.Dispatch>;
- renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode;
+ renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null) => React.ReactNode;
currentSessionDirectory: string | null;
projectRepoStatus: Map;
lastRepoStatus: boolean;
@@ -87,6 +89,7 @@ type Props = {
} | null;
}>;
onToggleCollapsedGroup: (groupKey: string) => void;
+ dragHandleProps?: SortableDragHandleProps | null;
};
export function SessionGroupSection(props: Props): React.ReactNode {
@@ -109,7 +112,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
showDeletionDialog,
setDeleteFolderConfirm,
renderSessionNode,
- currentSessionDirectory,
projectRepoStatus,
lastRepoStatus,
toggleGroupSessionLimit,
@@ -128,9 +130,12 @@ export function SessionGroupSection(props: Props): React.ReactNode {
pinnedSessionIds,
prVisualStateByDirectoryBranch,
onToggleCollapsedGroup,
+ dragHandleProps,
} = props;
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
+ const displayMode = useSessionDisplayStore((state) => state.displayMode);
+ const isMinimalMode = displayMode === 'minimal';
const isExpanded = expandedSessionGroups.has(groupKey);
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
const maxVisible = hideDirectoryControls ? 10 : 5;
@@ -204,24 +209,17 @@ export function SessionGroupSection(props: Props): React.ReactNode {
};
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
- const normalizedGroupDirectory = normalizePath(group.directory ?? null);
const isGitProject = projectId && projectRepoStatus.has(projectId)
? Boolean(projectRepoStatus.get(projectId))
: lastRepoStatus;
- const isActiveGroup = Boolean(
- normalizedGroupDirectory
- && currentSessionDirectory
- && normalizedGroupDirectory === currentSessionDirectory,
- );
const groupDirectoryKey = normalizePath(group.directory ?? null);
const groupBranchKey = group.branch?.trim() ?? null;
const prIndicator = groupDirectoryKey && groupBranchKey
? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null)
: null;
const showInlinePrTitle = Boolean(prIndicator && group.branch);
- const showBranchSubtitle = !group.isMain && (isBranchDifferentFromLabel(group.branch, group.label) || Boolean(prIndicator));
+ const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch);
const prVisualState = prIndicator?.visualState ?? null;
- const branchIconColor = prVisualState ? `var(--pr-${prVisualState})` : undefined;
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
? `${prIndicator.checks.success}/${prIndicator.checks.total} checks passed`
: null;
@@ -241,6 +239,33 @@ export function SessionGroupSection(props: Props): React.ReactNode {
: null;
const baseBranchLabel = prIndicator?.base ?? null;
const headBranchLabel = prIndicator?.head ?? null;
+ const statusLine = (() => {
+ if (!prIndicator) {
+ return group.branch && isBranchDifferentFromLabel(group.branch, group.label)
+ ? { label: group.branch, color: null as string | null }
+ : null;
+ }
+ switch (prIndicator.visualState) {
+ case 'merged':
+ return { label: 'Merged', color: 'var(--pr-merged)' };
+ case 'open':
+ return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success')
+ ? { label: 'Ready to merge', color: 'var(--pr-open)' }
+ : { label: 'PR open', color: 'var(--pr-open)' };
+ case 'blocked':
+ return {
+ label: prIndicator.mergeableState === 'dirty' ? 'Merge conflicts' : 'Merge blocked',
+ color: 'var(--pr-blocked)',
+ };
+ case 'draft':
+ return { label: 'Draft PR', color: 'var(--pr-draft)' };
+ case 'closed':
+ return { label: 'Closed', color: 'var(--pr-closed)' };
+ default:
+ return null;
+ }
+ })();
+ const branchIconColor = statusLine?.color ?? (prVisualState ? `var(--pr-${prVisualState})` : undefined);
const handlePrLinkClick = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
@@ -263,6 +288,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const subFolderItems = directSubFolders.length > 0
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}>
: undefined;
+ const collectFolderSessions = (targetFolderId: string): Session[] => {
+ const directNodes = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId)?.nodes ?? [];
+ const childFolders = allFoldersForGroup.filter(({ folder: candidate }) => candidate.parentId === targetFolderId);
+ return [
+ ...collectGroupSessions(directNodes),
+ ...childFolders.flatMap(({ folder: child }) => collectFolderSessions(child.id)),
+ ];
+ };
+ const folderSessionsForDelete = group.isArchivedBucket ? collectFolderSessions(folder.id) : [];
return (
@@ -277,6 +311,13 @@ export function SessionGroupSection(props: Props): React.ReactNode {
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
}}
onDelete={() => {
+ if (group.isArchivedBucket) {
+ sessionEvents.requestDelete({
+ sessions: folderSessionsForDelete,
+ mode: 'session',
+ });
+ return;
+ }
if (!folderScopeKey) return;
if (!showDeletionDialog) {
deleteFolder(folderScopeKey, folder.id);
@@ -324,7 +365,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
if (!folderScopeKey) return;
createFolderAndStartRename(folderScopeKey, folder.id);
} : undefined}
- hideActions={group.isArchivedBucket === true}
+ hideActions={false}
archivedBucket={group.isArchivedBucket === true}
/>
)}
@@ -333,6 +374,16 @@ export function SessionGroupSection(props: Props): React.ReactNode {
};
const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0));
+ const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
+ const groupHeaderRightPadding = mobileVariant
+ ? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
+ : isMinimalMode
+ ? (hasWorktreeDeleteAction
+ ? 'pr-10 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
+ : 'pr-10')
+ : (hasWorktreeDeleteAction
+ ? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
+ : 'pr-5');
const body = (
{body}
;
+ return ;
}
return (
onToggleCollapsedGroup(groupKey)}
role="button"
tabIndex={0}
@@ -388,78 +439,43 @@ export function SessionGroupSection(props: Props): React.ReactNode {
}
}}
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
+ aria-expanded={!isCollapsed}
>
-
- {group.isArchivedBucket ? (
-
- ) : (!group.isMain || isGitProject) ? (
- showInlinePrTitle && prIndicator ? (
-
-
-
-
-
-
-
-
- {(baseBranchLabel || headBranchLabel) ? (
-
- {baseBranchLabel && headBranchLabel ? (
- <>
- {baseBranchLabel}
-
- {headBranchLabel}
- >
- ) : (
- {baseBranchLabel ?? headBranchLabel ?? ''}
- )}
-
- ) : null}
- {mergeStateLabel ?
{mergeStateLabel}
: null}
- {(mergeabilityLabel || checksSummary) ? (
-
- {mergeabilityLabel ?? ''}
- {mergeabilityLabel && checksSummary ? ' • ' : ''}
- {checksSummary ?? ''}
- {checksTail ? ` (${checksTail})` : ''}
-
- ) : null}
-
-
-
- ) : (
-
- )
- ) : null}
-
-
+
+
+
{showInlinePrTitle && prIndicator ? (
- <>
+
-
+
+
+
+
+ {isCollapsed ? : }
+
+
{prIndicator.url ? (
) : (
- #{prIndicator.number}
+ #{prIndicator.number}
)}
@@ -490,42 +506,117 @@ export function SessionGroupSection(props: Props): React.ReactNode {
-
{` ${group.branch}`}
- >
+
{group.branch}
+
+ ) : group.isArchivedBucket ? (
+
+
+
+
+ {isCollapsed ? : }
+
+
+ {renderHighlightedText(group.label, normalizedSessionSearchQuery)}
+
+ ) : (!group.isMain || group.worktree) ? (
+
+
+
+
+ {isCollapsed ? : }
+
+
+ {renderHighlightedText(group.label, normalizedSessionSearchQuery)}
+
) : (
renderHighlightedText(group.label, normalizedSessionSearchQuery)
)}
- {!showInlinePrTitle && showBranchSubtitle ? (
-
- {prIndicator ? (
- <>
- {prIndicator.url ? (
-
- ) : (
- #{prIndicator.number}
- )}
- {group.branch ? {` ${group.branch}`} : null}
- >
- ) : (
- group.branch
- )}
+ {showBranchSubtitle && statusLine ? (
+
+ {group.isArchivedBucket ? (
+
+ ) : (!group.isMain || isGitProject) ? (
+ showInlinePrTitle && prIndicator ? (
+
+
+
+
+
+
+
+
+ {(baseBranchLabel || headBranchLabel) ? (
+
+ {baseBranchLabel && headBranchLabel ? (
+ <>
+ {baseBranchLabel}
+
+ {headBranchLabel}
+ >
+ ) : (
+ {baseBranchLabel ?? headBranchLabel ?? ''}
+ )}
+
+ ) : null}
+ {mergeStateLabel ?
{mergeStateLabel}
: null}
+ {(mergeabilityLabel || checksSummary) ? (
+
+ {mergeabilityLabel ?? ''}
+ {mergeabilityLabel && checksSummary ? ' • ' : ''}
+ {checksSummary ?? ''}
+ {checksTail ? ` (${checksTail})` : ''}
+
+ ) : null}
+
+
+
+ ) : (
+
+ )
+ ) : null}
+
+ {statusLine.label}
+
) : null}
- {isCollapsed ? (
-
- ) : (
-
- )}
+ {group.isArchivedBucket && allGroupSessions.length > 0 ? (
+
+
+
+
+
+ Delete archived sessions
+
+
+ ) : null}
{group.directory && !group.isMain && group.worktree ? (
@@ -564,17 +655,17 @@ export function SessionGroupSection(props: Props): React.ReactNode {
openNewSessionDraft({ directoryOverride: group.directory });
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
- aria-label={`New session in ${group.label}`}
- >
-
-
-
- New session
-
-
- ) : null}
+ aria-label={`New draft session in ${group.label}`}
+ >
+
+
+
+
New draft session
+
+
+ ) : null}
- {!isCollapsed ?
{body}
: null}
+ {!isCollapsed ?
{body}
: null}
);
}
diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
index 0e582873..8e1d07b3 100644
--- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
+++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
@@ -22,22 +22,21 @@ import {
RiDeleteBinLine,
RiErrorWarningLine,
RiFileCopyLine,
- RiFileEditLine,
RiFolderLine,
RiLinkUnlinkM,
RiMore2Line,
RiPencilAiLine,
RiPushpinLine,
- RiRobot2Line,
RiShare2Line,
RiShieldLine,
RiUnpinLine,
+ RiGitBranchLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { isVSCodeRuntime } from '@/lib/desktop';
import { DraggableSessionRow } from './sessionFolderDnd';
import type { SessionNode, SessionSummaryMeta } from './types';
-import { formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
+import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
@@ -48,6 +47,11 @@ const getAttentionDiamondDelay = (index: number): string => {
type Folder = { id: string; name: string; sessionIds: string[] };
+type SecondaryMeta = {
+ projectLabel?: string | null;
+ branchLabel?: string | null;
+};
+
type Props = {
node: SessionNode;
depth?: number;
@@ -79,8 +83,8 @@ type Props = {
copiedSessionId: string | null;
handleCopyShareUrl: (url: string, sessionId: string) => void;
handleUnshareSession: (sessionId: string) => void;
- openMenuSessionId: string | null;
- setOpenMenuSessionId: (id: string | null) => void;
+ openSidebarMenuKey: string | null;
+ setOpenSidebarMenuKey: (key: string | null) => void;
renamingFolderId: string | null;
getFoldersForScope: (scopeKey: string) => Folder[];
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
@@ -90,7 +94,9 @@ type Props = {
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string }) => void;
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void;
mobileVariant: boolean;
- renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode;
+ renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: SecondaryMeta | null, renderContext?: 'project' | 'recent') => React.ReactNode;
+ secondaryMeta?: SecondaryMeta | null;
+ renderContext?: 'project' | 'recent';
};
export function SessionNodeItem(props: Props): React.ReactNode {
@@ -125,8 +131,8 @@ export function SessionNodeItem(props: Props): React.ReactNode {
copiedSessionId,
handleCopyShareUrl,
handleUnshareSession,
- openMenuSessionId,
- setOpenMenuSessionId,
+ openSidebarMenuKey,
+ setOpenSidebarMenuKey,
renamingFolderId,
getFoldersForScope,
getSessionFolderId,
@@ -137,13 +143,19 @@ export function SessionNodeItem(props: Props): React.ReactNode {
handleDeleteSession,
mobileVariant,
renderSessionNode,
+ secondaryMeta,
+ renderContext = 'project',
} = props;
+ const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
+ const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
const displayMode = useSessionDisplayStore((state) => state.displayMode);
const isMinimalMode = displayMode === 'minimal';
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
+ const suppressNextSelectRef = React.useRef(false);
const session = node.session;
+ const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(groupDirectory ?? null);
@@ -160,12 +172,16 @@ export function SessionNodeItem(props: Props): React.ReactNode {
const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks);
const sessionSummary = session.summary as SessionSummaryMeta | undefined;
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
+ const sessionTimestamp = session.time?.updated || session.time?.created || Date.now();
+ const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
+ const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
+ const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
if (editingId === session.id) {
return (
0 && 'pl-[20px]')}
+ className={cn('group relative flex items-center rounded-sm px-1.5 py-1', depth > 0 && 'pl-[20px]')}
>
{!isMinimalMode ? (
-
- {hasChildren ?
{isExpanded ? : } : null}
-
{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}
- {sessionDiffStats ?
+{sessionDiffStats.additions}/-{sessionDiffStats.deletions} : null}
- {session.share ?
: null}
- {(sessionSummary?.files ?? 0) > 0 || hasChildren ? (
-
- {(sessionSummary?.files ?? 0) > 0 ? {sessionSummary!.files}{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}
: null}
- {hasChildren ? {node.children.length}{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}
: null}
-
- ) : null}
+
+
+ {hasChildren ? {isExpanded ? : } : null}
+ {sessionUpdatedLabel}
+ {sessionDiffStats ? +{sessionDiffStats.additions}/-{sessionDiffStats.deletions} : null}
+ {hasSecondaryProjectLabel ? {secondaryMeta?.projectLabel} : null}
+ {hasSecondaryBranchLabel ? {secondaryMeta?.branchLabel} : null}
+
) : null}
@@ -220,21 +233,191 @@ export function SessionNodeItem(props: Props): React.ReactNode {
const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
const showStatusMarker = isStreaming || showUnreadStatus;
+ const statusMarkerContent = isStreaming
+ ?
+ : (
+
+ {Array.from({ length: 9 }, (_, i) => (
+ ATTENTION_DIAMOND_INDICES.has(i) ? (
+
+ ) : (
+
+ )
+ ))}
+
+ );
+ const inlineStatusMarker = !isMinimalMode && showStatusMarker ? (
+
+ {statusMarkerContent}
+
+ ) : null;
+ const minimalLeadingStatusMarker = isMinimalMode && showStatusMarker ? (
+
+ {statusMarkerContent}
+
+ ) : null;
+ const subsessionChevron = hasChildren ? (
+
{
+ event.stopPropagation();
+ toggleParent(session.id);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ event.stopPropagation();
+ toggleParent(session.id);
+ }
+ }}
+ className={cn(
+ 'absolute left-[-10px] top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
+ isMinimalMode && showStatusMarker
+ ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'
+ : '',
+ )}
+ aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}
+ >
+ {isExpanded ? : }
+
+ ) : null;
const streamingIndicator = memoryState?.isZombie
?
: null;
+ const handleMenuOpenChange = (open: boolean) => {
+ setOpenSidebarMenuKey(open ? menuInstanceKey : null);
+ };
+
+ const handleMenuTriggerClick = (event: React.MouseEvent
) => {
+ event.stopPropagation();
+ };
+
+ const handleRowSelect = () => {
+ if (suppressNextSelectRef.current) {
+ suppressNextSelectRef.current = false;
+ return;
+ }
+ handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId);
+ };
+
+ const handleRowMouseDown = (event: React.MouseEvent) => {
+ if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
+ suppressNextSelectRef.current = true;
+ }
+ };
+
+ const sessionMenuContent = (
+ { if (renamingFolderId) event.preventDefault(); }}>
+ {
+ setEditingId(session.id);
+ setEditTitle(sessionTitle);
+ }}
+ className="[&>svg]:mr-1"
+ >
+
+ Rename
+
+ togglePinnedSession(session.id)} className="[&>svg]:mr-1">
+ {isPinnedSession ? : }
+ {isPinnedSession ? 'Unpin session' : 'Pin session'}
+
+ {!session.share ? (
+ handleShareSession(session)} className="[&>svg]:mr-1">
+
+ Share
+
+ ) : (
+ <>
+ { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1">
+ {copiedSessionId === session.id ? <>Copied> : <>Copy link>}
+
+ handleUnshareSession(session.id)} className="[&>svg]:mr-1">
+
+ Unshare
+
+ >
+ )}
+
+ {sessionDirectory && !archivedBucket ? (() => {
+ const scopeFolders = getFoldersForScope(sessionDirectory);
+ const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
+ return (
+ <>
+
+
+ Move to folder
+
+ {scopeFolders.length === 0 ? (
+ No folders yet
+ ) : (
+ scopeFolders.map((folder) => (
+ { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
+ {folder.name}
+ {currentFolderId === folder.id ? : null}
+
+ ))
+ )}
+
+ { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
+
+ New folder...
+
+ {currentFolderId ? (
+ { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
+
+ Remove from folder
+
+ ) : null}
+
+
+ >
+ );
+ })() : null}
+
+ {!isVSCode ? (
+ {
+ if (!sessionDirectory) return;
+ openContextPanelTab(sessionDirectory, {
+ mode: 'chat',
+ dedupeKey: `session:${session.id}`,
+ label: sessionTitle,
+ });
+ }}
+ className="[&>svg]:mr-1"
+ >
+
+ Open in Side Panel
+ beta
+
+ ) : null}
+
+
+ handleDeleteSession(session, { archivedBucket })}>
+
+ {archivedBucket ? 'Delete' : 'Archive'}
+
+
+ );
+
return (
0 && 'pl-[20px]')}
- onContextMenu={(e) => {
- e.preventDefault();
- setOpenMenuSessionId(session.id);
- }}
+ className={cn('group relative flex items-center rounded-sm px-1.5 py-1', isMissingDirectory ? 'opacity-75' : '', depth > 0 && 'pl-[20px]')}
>
+ {minimalLeadingStatusMarker}
+ {subsessionChevron}
{isMinimalMode ? (
@@ -242,38 +425,52 @@ export function SessionNodeItem(props: Props): React.ReactNode {