mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-03 05:14:52 +00:00
Some checks are pending
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
* fix(web-shell): constrain session details and add copy action * fix(web-shell): harden constrained session details * fix(web-shell): close session details review gaps * fix(web-shell): address session details follow-up
4493 lines
158 KiB
TypeScript
4493 lines
158 KiB
TypeScript
import {
|
|
Fragment,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type PointerEvent as ReactPointerEvent,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import {
|
|
useActions,
|
|
useConnection,
|
|
useSessions,
|
|
useWorkspace,
|
|
useWorkspaceActions,
|
|
} from '@qwen-code/webui/daemon-react-sdk';
|
|
import { DaemonHttpError } from '@qwen-code/sdk/daemon';
|
|
import type {
|
|
DaemonSessionGroup,
|
|
DaemonSessionGroupColor,
|
|
DaemonSessionGroupHexColor,
|
|
DaemonSessionGroupPresetColor,
|
|
DaemonSessionSummary,
|
|
DaemonWorkspaceCapability,
|
|
DaemonWorkspaceRemovalActivity,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import {
|
|
ActivityIcon,
|
|
BlocksIcon,
|
|
CalendarClockIcon,
|
|
ChevronDownIcon,
|
|
ChevronRightIcon,
|
|
Columns2Icon,
|
|
LayoutGridIcon,
|
|
EllipsisVerticalIcon,
|
|
ArchiveIcon,
|
|
ArchiveRestoreIcon,
|
|
DownloadIcon,
|
|
FolderInputIcon,
|
|
GitBranchIcon,
|
|
GitForkIcon,
|
|
PencilIcon,
|
|
PinIcon,
|
|
Trash2Icon,
|
|
MoonIcon,
|
|
PanelLeftCloseIcon,
|
|
PanelLeftOpenIcon,
|
|
PlusIcon,
|
|
RadioTowerIcon,
|
|
SearchIcon,
|
|
SettingsIcon,
|
|
SquarePenIcon,
|
|
SunIcon,
|
|
TargetIcon,
|
|
} from 'lucide-react';
|
|
import { WebShellThemeId, type WebShellTheme } from '../../themeContext';
|
|
import { useI18n } from '../../i18n';
|
|
import { Input } from '../ui/input';
|
|
import { Button } from '../ui/button';
|
|
import { Field, FieldGroup, FieldLabel } from '../ui/field';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '../ui/select';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '../ui/dropdown-menu';
|
|
import { formatRelativeTime } from '../../utils/formatRelativeTime';
|
|
import { DialogShell } from '../dialogs/DialogShell';
|
|
import { WorkspaceSection } from './WorkspaceSection';
|
|
import { SessionGroupSection } from './SessionGroupSection';
|
|
import { SessionDetailsSubmenu } from './SessionDetailsSubmenu';
|
|
import { resolveSessionDetailsCollisionBoundary } from './sessionDetailsCollisionBoundary';
|
|
import {
|
|
isPrimaryCollapsedSectionId,
|
|
readCollapsedSessionSectionIds,
|
|
replaceOwnedCollapsedSessionSectionIds,
|
|
} from './collapsedSessionSections';
|
|
import {
|
|
SESSION_LIST_PAGE_SIZE,
|
|
SESSION_ORGANIZATION_FEATURE,
|
|
WEB_SHELL_SESSION_SOURCE_TYPE,
|
|
} from '../../constants/sessions';
|
|
import styles from './WebShellSidebar.module.css';
|
|
|
|
const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width';
|
|
const SIDEBAR_DEFAULT_WIDTH = 260;
|
|
const SIDEBAR_MIN_WIDTH = 220;
|
|
const SIDEBAR_MAX_WIDTH = 420;
|
|
const SIDEBAR_MAX_WIDTH_WINDOW_RATIO = 0.5;
|
|
const SIDEBAR_FOOTER_COMPACT_WIDTH = 344;
|
|
const SIDEBAR_FOOTER_TIGHT_WIDTH = 250;
|
|
const SIDEBAR_DRAG_VISUAL_MIN_WIDTH = 200;
|
|
const SIDEBAR_COLLAPSE_DRAG_THRESHOLD = 56;
|
|
const SIDEBAR_COLLAPSE_DRAG_WIDTH =
|
|
SIDEBAR_DRAG_VISUAL_MIN_WIDTH - SIDEBAR_COLLAPSE_DRAG_THRESHOLD;
|
|
const ACTIVE_SESSION_POLL_INTERVAL_MS = 2000;
|
|
const IDLE_SESSION_POLL_INTERVAL_MS = 30_000;
|
|
const DIALOG_SESSION_LABEL_MAX_LENGTH = 96;
|
|
const RECENT_SESSION_SECTION_ID = 'recent';
|
|
const GROUP_MENU_WIDTH = 240;
|
|
const GROUP_MENU_MARGIN = 8;
|
|
const CUSTOM_GROUP_COLOR_OPTION = '__custom__';
|
|
const DEFAULT_CUSTOM_GROUP_COLOR: DaemonSessionGroupHexColor = '#416ef5';
|
|
|
|
function getSessionIdentity(
|
|
sessionId: string,
|
|
workspaceCwd: string | undefined,
|
|
): string {
|
|
return `${workspaceCwd ?? ''}\0${sessionId}`;
|
|
}
|
|
|
|
export type WebShellSidebarFooterItem =
|
|
| 'settings'
|
|
| 'version'
|
|
| 'theme'
|
|
| 'sessionsOverview'
|
|
| 'splitView'
|
|
| 'daemonStatus'
|
|
| 'collapse';
|
|
|
|
export interface WebShellSidebarBranding {
|
|
/** Replace the complete top branding row. */
|
|
render?: () => ReactNode;
|
|
/** Hide the branding row in the compact drawer. Defaults to true. */
|
|
hideWhenCompact?: boolean;
|
|
}
|
|
|
|
export interface WebShellSidebarLockedWorkspace {
|
|
/** Replace the locked workspace row content while preserving its built-in behavior. */
|
|
render?: (
|
|
workspace: DaemonWorkspaceCapability,
|
|
state: { expanded: boolean },
|
|
) => ReactNode;
|
|
}
|
|
|
|
export type WebShellSidebarPrimaryNavItem =
|
|
| 'newTask'
|
|
| 'plugins'
|
|
| 'channels'
|
|
| 'scheduledTasks'
|
|
| 'goals';
|
|
|
|
export interface WebShellSidebarPrimaryNavOptions {
|
|
/** Built-in primary nav entries to show. Defaults to all. */
|
|
items?: readonly WebShellSidebarPrimaryNavItem[];
|
|
/** Additional custom content rendered after the built-in nav buttons. */
|
|
render?: () => ReactNode;
|
|
}
|
|
|
|
export interface WebShellSidebarFooterOptions {
|
|
/** Built-in footer entries to expose. Entries use the canonical footer order. */
|
|
items?: readonly WebShellSidebarFooterItem[];
|
|
/** Additional custom content rendered before the built-in footer items (left side). */
|
|
render?: () => ReactNode;
|
|
}
|
|
|
|
const DEFAULT_FOOTER_ITEMS: readonly WebShellSidebarFooterItem[] = [
|
|
'settings',
|
|
'version',
|
|
'theme',
|
|
'sessionsOverview',
|
|
'splitView',
|
|
'daemonStatus',
|
|
'collapse',
|
|
];
|
|
|
|
const DEFAULT_PRIMARY_NAV_ITEMS: readonly WebShellSidebarPrimaryNavItem[] = [
|
|
'newTask',
|
|
'plugins',
|
|
'channels',
|
|
'scheduledTasks',
|
|
'goals',
|
|
];
|
|
|
|
export type WebShellSidebarSessionActionItem =
|
|
| 'details'
|
|
| 'rename'
|
|
| 'group'
|
|
| 'export'
|
|
| 'delete'
|
|
| 'pin'
|
|
| 'archive';
|
|
|
|
/** Subset of action items that have working inline (hover-button) handlers. */
|
|
export type WebShellSidebarSessionInlineActionItem =
|
|
| 'pin'
|
|
| 'archive'
|
|
| 'rename'
|
|
| 'export'
|
|
| 'delete';
|
|
|
|
export interface WebShellSidebarSessionActionsOptions {
|
|
/** Session action items to show. Defaults to all. */
|
|
items?: readonly WebShellSidebarSessionActionItem[];
|
|
/**
|
|
* Which items appear as inline buttons (on hover). Defaults to ['pin', 'archive'].
|
|
* Only items that also pass their built-in visibility condition are rendered.
|
|
* Only items with working inline handlers are accepted (details/group are dropdown-only).
|
|
*/
|
|
inlineItems?: readonly WebShellSidebarSessionInlineActionItem[];
|
|
}
|
|
|
|
const DEFAULT_SESSION_ACTION_ITEMS: readonly WebShellSidebarSessionActionItem[] =
|
|
['details', 'rename', 'group', 'export', 'delete', 'pin', 'archive'];
|
|
|
|
const DEFAULT_INLINE_ACTION_ITEMS: readonly WebShellSidebarSessionInlineActionItem[] =
|
|
['pin', 'archive'];
|
|
|
|
/**
|
|
* Palette order for the quick color-grouping buckets. Mirrors core's
|
|
* `GROUP_COLOR_OPTIONS`; kept as a local constant so the client never imports
|
|
* from core. Used both to order the color sections and as a fallback when the
|
|
* daemon's color catalog has not loaded yet.
|
|
*/
|
|
const SESSION_GROUP_COLORS: DaemonSessionGroupPresetColor[] = [
|
|
'red',
|
|
'orange',
|
|
'yellow',
|
|
'green',
|
|
'blue',
|
|
'purple',
|
|
];
|
|
|
|
type GroupEditorMode = 'create' | 'edit';
|
|
|
|
type SessionSectionKind = 'color' | 'group' | 'recent';
|
|
|
|
interface SessionSection {
|
|
id: string;
|
|
kind: SessionSectionKind;
|
|
label: string;
|
|
countLabel?: string;
|
|
color?: DaemonSessionGroupColor;
|
|
group?: DaemonSessionGroup;
|
|
sessions: DaemonSessionSummary[];
|
|
}
|
|
|
|
interface GroupEditorState {
|
|
mode: GroupEditorMode;
|
|
group?: DaemonSessionGroup;
|
|
targetSession?: DaemonSessionSummary;
|
|
workspaceCwd?: string;
|
|
}
|
|
|
|
interface GroupMenuState {
|
|
session: DaemonSessionSummary;
|
|
top: number;
|
|
left: number;
|
|
}
|
|
|
|
type SessionWorkspaceScope =
|
|
| { kind: 'primary'; cwd: string }
|
|
| { kind: 'locked'; cwd: string; workspace: DaemonWorkspaceCapability }
|
|
| { kind: 'restricted'; cwd: string; workspace: DaemonWorkspaceCapability }
|
|
| { kind: 'untrusted'; cwd: string; workspace: DaemonWorkspaceCapability }
|
|
| { kind: 'unknown'; cwd: string };
|
|
|
|
interface WebShellSidebarProps {
|
|
collapsed: boolean;
|
|
onCollapsedChange: (collapsed: boolean) => void;
|
|
onOpenSettings: () => void;
|
|
onOpenPlugins: () => void;
|
|
onOpenChannels: () => void;
|
|
onOpenDaemonStatus: () => void;
|
|
onOpenScheduledTasks: () => void;
|
|
onOpenGoals: () => void;
|
|
onOpenSessions: () => void;
|
|
/**
|
|
* Whether to offer the Session Overview entry point. Gated to large screens
|
|
* by the app: below that there is no room to make managing several sessions
|
|
* side by side worthwhile.
|
|
*/
|
|
canOpenSessionsOverview?: boolean;
|
|
onOpenSplitView: () => void;
|
|
/** Whether to offer the in-window split view (large screens only). */
|
|
canOpenSplitView?: boolean;
|
|
onNewSession: (workspaceCwd?: string) => Promise<boolean> | boolean;
|
|
onLoadSession: (
|
|
sessionId: string,
|
|
workspaceCwd?: string,
|
|
) => Promise<void> | void;
|
|
onSelectCurrentSession?: () => void;
|
|
onError: (error: unknown, fallback: string) => void;
|
|
theme: WebShellTheme;
|
|
onThemeChange: (theme: WebShellTheme) => void;
|
|
mobileOpen?: boolean;
|
|
sessionListReloadToken?: number;
|
|
/**
|
|
* Phase 4: workspace cwd picked for the next new session (undefined =
|
|
* primary). Only meaningful on multi-workspace daemons.
|
|
*/
|
|
selectedWorkspaceCwd?: string;
|
|
onSelectWorkspace?: (workspaceCwd: string | undefined) => void;
|
|
/**
|
|
* Open the working-tree Changes dialog for a workspace. Forwarded to each
|
|
* trusted workspace's folder header, where a live git chip fires it on click.
|
|
*/
|
|
onOpenGitDiff?: (workspaceCwd: string) => void;
|
|
onOpenCommit?: (workspaceCwd: string) => void;
|
|
/**
|
|
* Opens the shared App-owned Add Workspace dialog. Omit this callback when
|
|
* registration is unavailable; locked workspaces hide the action separately.
|
|
*/
|
|
onOpenAddWorkspace?: () => void;
|
|
workspaces?: DaemonWorkspaceCapability[];
|
|
lockedWorkspaceCwd?: string;
|
|
lockedWorkspace?: WebShellSidebarLockedWorkspace;
|
|
branding?: false | WebShellSidebarBranding;
|
|
primaryNav?: WebShellSidebarPrimaryNavOptions;
|
|
/** Whether to hide the "Projects" header row (with search and add workspace). Defaults to false (shown). */
|
|
hideProjectHeader?: boolean;
|
|
/** Customize which action buttons appear on session rows. */
|
|
sessionActions?: WebShellSidebarSessionActionsOptions;
|
|
footer?: false | WebShellSidebarFooterOptions;
|
|
}
|
|
|
|
function cx(...classes: Array<string | false | undefined>): string {
|
|
return classes.filter(Boolean).join(' ');
|
|
}
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return error instanceof DOMException && error.name === 'AbortError';
|
|
}
|
|
|
|
function getWorkspaceName(workspaceCwd: string | undefined): string {
|
|
if (!workspaceCwd) return '';
|
|
const parts = workspaceCwd.split(/[\\/]+/).filter(Boolean);
|
|
return parts.at(-1) ?? workspaceCwd;
|
|
}
|
|
|
|
function getSessionLabel(session: DaemonSessionSummary): string {
|
|
const displayName = session.displayName?.trim();
|
|
return displayName || session.sessionId.slice(0, 8);
|
|
}
|
|
|
|
function getCompactSessionLabel(session: DaemonSessionSummary): string {
|
|
const normalized = getSessionLabel(session).replace(/\s+/g, ' ').trim();
|
|
if (normalized.length <= DIALOG_SESSION_LABEL_MAX_LENGTH) {
|
|
return normalized;
|
|
}
|
|
return `${normalized
|
|
.slice(0, DIALOG_SESSION_LABEL_MAX_LENGTH - 3)
|
|
.trimEnd()}...`;
|
|
}
|
|
|
|
function getSessionCreatedTime(session: DaemonSessionSummary): number {
|
|
if (!session.createdAt) return 0;
|
|
const time = Date.parse(session.createdAt);
|
|
return Number.isFinite(time) ? time : 0;
|
|
}
|
|
|
|
function getDefaultGroupColor(
|
|
colorOptions: DaemonSessionGroupPresetColor[],
|
|
): DaemonSessionGroupPresetColor {
|
|
return colorOptions[0] ?? 'blue';
|
|
}
|
|
|
|
function normalizeHexColorInput(
|
|
value: string,
|
|
): DaemonSessionGroupHexColor | undefined {
|
|
const normalized = value.trim();
|
|
if (/^#[0-9a-f]{6}$/i.test(normalized)) {
|
|
return normalized.toLowerCase() as DaemonSessionGroupHexColor;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function normalizeGroupColorInput(
|
|
value: string,
|
|
presets: readonly DaemonSessionGroupPresetColor[],
|
|
): DaemonSessionGroupColor | undefined {
|
|
const normalized = value.trim();
|
|
if (presets.includes(normalized as DaemonSessionGroupPresetColor)) {
|
|
return normalized as DaemonSessionGroupPresetColor;
|
|
}
|
|
return normalizeHexColorInput(normalized);
|
|
}
|
|
|
|
function getGroupColorClass(
|
|
color: DaemonSessionGroupColor,
|
|
): string | undefined {
|
|
if (color.startsWith('#')) return styles.groupColorCustom;
|
|
switch (color) {
|
|
case 'red':
|
|
return styles.groupColorRed;
|
|
case 'orange':
|
|
return styles.groupColorOrange;
|
|
case 'yellow':
|
|
return styles.groupColorYellow;
|
|
case 'green':
|
|
return styles.groupColorGreen;
|
|
case 'blue':
|
|
return styles.groupColorBlue;
|
|
case 'purple':
|
|
return styles.groupColorPurple;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getGroupColorStyle(
|
|
color: DaemonSessionGroupColor,
|
|
): CSSProperties | undefined {
|
|
return color.startsWith('#') ? { backgroundColor: color } : undefined;
|
|
}
|
|
|
|
// The cap scales with the window so wide displays can reveal full session
|
|
// names, but never exceeds half the window so the sidebar cannot crush the
|
|
// main content area. SIDEBAR_MAX_WIDTH is the floor, preserving the old
|
|
// fixed cap on small windows.
|
|
function getSidebarMaxWidth(): number {
|
|
if (typeof window === 'undefined') return SIDEBAR_MAX_WIDTH;
|
|
return Math.max(
|
|
SIDEBAR_MAX_WIDTH,
|
|
Math.floor(window.innerWidth * SIDEBAR_MAX_WIDTH_WINDOW_RATIO),
|
|
);
|
|
}
|
|
|
|
function clampSidebarWidth(width: number): number {
|
|
return Math.min(getSidebarMaxWidth(), Math.max(SIDEBAR_MIN_WIDTH, width));
|
|
}
|
|
|
|
function clampSidebarVisualWidth(width: number): number {
|
|
return Math.min(
|
|
getSidebarMaxWidth(),
|
|
Math.max(SIDEBAR_DRAG_VISUAL_MIN_WIDTH, width),
|
|
);
|
|
}
|
|
|
|
function readSidebarWidth(): number {
|
|
if (typeof window === 'undefined') return SIDEBAR_DEFAULT_WIDTH;
|
|
try {
|
|
const raw = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
|
|
const width = raw ? Number(raw) : SIDEBAR_DEFAULT_WIDTH;
|
|
return Number.isFinite(width)
|
|
? clampSidebarWidth(width)
|
|
: SIDEBAR_DEFAULT_WIDTH;
|
|
} catch {
|
|
return SIDEBAR_DEFAULT_WIDTH;
|
|
}
|
|
}
|
|
|
|
function writeSidebarWidth(width: number): void {
|
|
try {
|
|
window.localStorage.setItem(
|
|
SIDEBAR_WIDTH_STORAGE_KEY,
|
|
String(clampSidebarWidth(width)),
|
|
);
|
|
} catch {
|
|
// localStorage can be unavailable in private or embedded contexts.
|
|
}
|
|
}
|
|
|
|
function IconNewChat() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Qwen brand mark. Same artwork as the browser-tab favicon in index.html and
|
|
* the QwenLM GitHub avatar; inlined as an SVG rather than hot-linked because
|
|
* the Web Shell CSP is `img-src 'self' data: blob:` (see web-shell-static.ts),
|
|
* which blocks remote images. The purple #6D44E8 fill is legible on both the
|
|
* light and dark sidebar backgrounds. Filled (not stroked) so it opts out of
|
|
* the shared `.navIcon svg` stroke styling.
|
|
*/
|
|
function IconQwenLogo() {
|
|
return (
|
|
<svg viewBox="0 0 141.38 140" aria-hidden="true">
|
|
<path
|
|
fill="#6D44E8"
|
|
d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.323 3.323 0 0 0 0-3.34l-9.62-16.67c-.3-.51-.72-.93-1.22-1.22s-1.07-.45-1.67-.45H82.23l-8.66-15a3.33 3.33 0 0 0-2.89-1.67H51.43c-.59 0-1.17.16-1.66.45-.5.29-.92.71-1.22 1.22L32.19 29.98l-1.92 3.33H12.96c-.59 0-1.17.16-1.66.45-.5.29-.93.71-1.22 1.22L.45 51.66a3.323 3.323 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67c.3.51.72.93 1.22 1.22s1.07.45 1.67.45h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32c.6 0 1.17-.16 1.67-.45s.92-.71 1.22-1.22l9.62-16.67a3.323 3.323 0 0 0 0-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconChevron({ expanded }: { expanded: boolean }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
{expanded ? <path d="m6 9 6 6 6-6" /> : <path d="m9 6 6 6-6 6" />}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function WebShellSidebar({
|
|
collapsed,
|
|
onCollapsedChange,
|
|
onOpenSettings,
|
|
onOpenPlugins,
|
|
onOpenChannels,
|
|
onOpenDaemonStatus,
|
|
onOpenScheduledTasks,
|
|
onOpenGoals,
|
|
onOpenSessions,
|
|
canOpenSessionsOverview,
|
|
onOpenSplitView,
|
|
canOpenSplitView,
|
|
onNewSession,
|
|
onLoadSession,
|
|
onSelectCurrentSession,
|
|
onError,
|
|
theme,
|
|
onThemeChange,
|
|
mobileOpen,
|
|
sessionListReloadToken,
|
|
selectedWorkspaceCwd,
|
|
onSelectWorkspace,
|
|
onOpenGitDiff,
|
|
onOpenCommit,
|
|
onOpenAddWorkspace,
|
|
workspaces: providedWorkspaces,
|
|
lockedWorkspaceCwd,
|
|
lockedWorkspace: lockedWorkspaceOptions,
|
|
branding,
|
|
primaryNav: primaryNavOptions,
|
|
hideProjectHeader,
|
|
sessionActions: sessionActionsOptions,
|
|
footer,
|
|
}: WebShellSidebarProps) {
|
|
const { t } = useI18n();
|
|
const connection = useConnection();
|
|
const actions = useActions();
|
|
const workspaceActions = useWorkspaceActions();
|
|
const workspace = useWorkspace();
|
|
const footerItems = useMemo(
|
|
() =>
|
|
new Set(footer === false ? [] : (footer?.items ?? DEFAULT_FOOTER_ITEMS)),
|
|
[footer],
|
|
);
|
|
const primaryNavItems = useMemo(
|
|
() => new Set(primaryNavOptions?.items ?? DEFAULT_PRIMARY_NAV_ITEMS),
|
|
[primaryNavOptions?.items],
|
|
);
|
|
const sessionActionItems = useMemo(
|
|
() => new Set(sessionActionsOptions?.items ?? DEFAULT_SESSION_ACTION_ITEMS),
|
|
[sessionActionsOptions?.items],
|
|
);
|
|
const inlineActionItems = useMemo(
|
|
() =>
|
|
new Set(
|
|
sessionActionsOptions?.inlineItems ?? DEFAULT_INLINE_ACTION_ITEMS,
|
|
),
|
|
[sessionActionsOptions?.inlineItems],
|
|
);
|
|
const shouldRenderBrand =
|
|
branding !== false && !(mobileOpen && (branding?.hideWhenCompact ?? true));
|
|
const organizationEnabled = Boolean(
|
|
connection.capabilities?.features?.includes(SESSION_ORGANIZATION_FEATURE),
|
|
);
|
|
const sourceMetadataEnabled = Boolean(
|
|
connection.capabilities?.features?.includes('session_source_metadata'),
|
|
);
|
|
const sessionArchiveEnabled = Boolean(
|
|
connection.capabilities?.features?.includes('session_archive'),
|
|
);
|
|
const workspaceQualifiedRestCoreEnabled = Boolean(
|
|
connection.capabilities?.features?.includes(
|
|
'workspace_qualified_rest_core',
|
|
),
|
|
);
|
|
// Phase 4: registered workspaces on a multi-workspace daemon (absent or a
|
|
// single entry otherwise). Drives the new-session workspace picker.
|
|
const workspaces = useMemo(
|
|
() => providedWorkspaces ?? workspace.capabilities?.workspaces ?? [],
|
|
[providedWorkspaces, workspace.capabilities?.workspaces],
|
|
);
|
|
const workspaceCatalogAdvertised =
|
|
workspaces.length > 0 ||
|
|
workspace.capabilities?.workspaces !== undefined ||
|
|
connection.capabilities?.workspaces !== undefined;
|
|
const primaryWorkspaceCwd =
|
|
workspaces.find((entry) => entry.primary)?.cwd ??
|
|
workspace.capabilities?.workspaceCwd ??
|
|
connection.workspaceCwd;
|
|
const lockedWorkspace = lockedWorkspaceCwd
|
|
? workspaces.find((entry) => entry.cwd === lockedWorkspaceCwd)
|
|
: undefined;
|
|
const includePrimaryWorkspaceSessions =
|
|
!lockedWorkspaceCwd || lockedWorkspace?.primary === true;
|
|
const {
|
|
sessions,
|
|
loading,
|
|
error,
|
|
data: sessionsPage,
|
|
reload,
|
|
deleteSession,
|
|
exportSession,
|
|
archiveSession,
|
|
} = useSessions({
|
|
autoLoad: true,
|
|
enabled: includePrimaryWorkspaceSessions,
|
|
pageSize: SESSION_LIST_PAGE_SIZE,
|
|
archiveState: 'active',
|
|
...(sourceMetadataEnabled
|
|
? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE }
|
|
: {}),
|
|
...(organizationEnabled
|
|
? { view: 'organized' as const, group: 'all' }
|
|
: {}),
|
|
});
|
|
// useDaemonResource starts with loading=false before autoLoad runs, so
|
|
// !loading is not “settled”. Treat the first data as the ready signal (empty
|
|
// lists are still defined data) so the initial-catalog latch waits. Errors
|
|
// must NOT settle it: a latch consumed against a failed request would treat
|
|
// every section from the eventual successful reload as brand-new,
|
|
// auto-collapsing and persisting over the user's restored expansions.
|
|
const sessionsCatalogReady =
|
|
!organizationEnabled ||
|
|
!includePrimaryWorkspaceSessions ||
|
|
sessionsPage !== undefined;
|
|
const { sessions: primaryPinnedSessions, reload: reloadPinnedSessions } =
|
|
useSessions({
|
|
autoLoad: organizationEnabled,
|
|
enabled: organizationEnabled && includePrimaryWorkspaceSessions,
|
|
pageSize: SESSION_LIST_PAGE_SIZE,
|
|
archiveState: 'active',
|
|
...(sourceMetadataEnabled
|
|
? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE }
|
|
: {}),
|
|
view: 'organized',
|
|
group: 'pinned',
|
|
});
|
|
const [archivedExpanded, setArchivedExpanded] = useState(false);
|
|
const [pinnedExpanded, setPinnedExpanded] = useState(true);
|
|
const [secondaryPinnedSessions, setSecondaryPinnedSessions] = useState<
|
|
DaemonSessionSummary[]
|
|
>([]);
|
|
const {
|
|
sessions: archivedSessions,
|
|
loading: archivedLoading,
|
|
error: archivedError,
|
|
reload: reloadArchived,
|
|
deleteSession: deleteArchivedSession,
|
|
unarchiveSession,
|
|
} = useSessions({
|
|
autoLoad: true,
|
|
enabled:
|
|
sessionArchiveEnabled &&
|
|
archivedExpanded &&
|
|
includePrimaryWorkspaceSessions,
|
|
pageSize: SESSION_LIST_PAGE_SIZE,
|
|
archiveState: 'archived',
|
|
...(sourceMetadataEnabled
|
|
? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE }
|
|
: {}),
|
|
...(organizationEnabled
|
|
? { view: 'organized' as const, group: 'all' }
|
|
: {}),
|
|
});
|
|
const [secondaryArchivedSessions, setSecondaryArchivedSessions] = useState<
|
|
DaemonSessionSummary[]
|
|
>([]);
|
|
const [secondaryArchivedLoading, setSecondaryArchivedLoading] =
|
|
useState(false);
|
|
const [secondaryArchivedError, setSecondaryArchivedError] = useState(false);
|
|
const [secondaryArchivedReloadToken, setSecondaryArchivedReloadToken] =
|
|
useState(0);
|
|
const [groups, setGroups] = useState<DaemonSessionGroup[]>([]);
|
|
const [menuGroups, setMenuGroups] = useState<DaemonSessionGroup[]>([]);
|
|
const [colorOptions, setColorOptions] = useState<
|
|
DaemonSessionGroupPresetColor[]
|
|
>([]);
|
|
const [groupBusy, setGroupBusy] = useState(false);
|
|
const [editingSessionIdentity, setEditingSessionIdentity] = useState<
|
|
string | null
|
|
>(null);
|
|
const [editingName, setEditingName] = useState('');
|
|
const [busySessionIds, setBusySessionIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const busySessionIdsRef = useRef<Set<string>>(new Set());
|
|
const [exportingSessionIds, setExportingSessionIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const exportingSessionIdsRef = useRef<Set<string>>(new Set());
|
|
const [creatingSession, setCreatingSession] = useState(false);
|
|
const creatingSessionRef = useRef(false);
|
|
const [deleteCandidate, setDeleteCandidate] =
|
|
useState<DaemonSessionSummary | null>(null);
|
|
const [groupMenu, setGroupMenu] = useState<GroupMenuState | null>(null);
|
|
const [groupEditor, setGroupEditor] = useState<GroupEditorState | null>(null);
|
|
const [groupName, setGroupName] = useState('');
|
|
const [groupColor, setGroupColor] = useState<DaemonSessionGroupColor>('blue');
|
|
const [lastValidCustomGroupColor, setLastValidCustomGroupColor] =
|
|
useState<DaemonSessionGroupHexColor>(DEFAULT_CUSTOM_GROUP_COLOR);
|
|
const [deleteGroupCandidate, setDeleteGroupCandidate] = useState<{
|
|
group: DaemonSessionGroup;
|
|
workspaceCwd?: string;
|
|
} | null>(null);
|
|
const [collapsedSessionSectionIds, setCollapsedSessionSectionIds] = useState<
|
|
Set<string>
|
|
>(
|
|
() =>
|
|
new Set(
|
|
Array.from(readCollapsedSessionSectionIds()).filter(
|
|
isPrimaryCollapsedSectionId,
|
|
),
|
|
),
|
|
);
|
|
const knownSessionSectionIdsRef = useRef<Set<string>>(new Set());
|
|
// Dedicated first-sync latch. Cleared only after both groups catalog and
|
|
// sessions list have settled (including empty responses). Do not infer this
|
|
// from knownSessionSectionIdsRef.size — seeding that set early would make the
|
|
// first real sync look mid-session and auto-collapse restored expansions.
|
|
const awaitingInitialSessionCatalogRef = useRef(true);
|
|
const [groupsCatalogReady, setGroupsCatalogReady] =
|
|
useState(!organizationEnabled);
|
|
// organizationEnabled can flip true mid-session (capabilities can land after
|
|
// the flat sessions request settles). Close the gate during that same render:
|
|
// deferring to the reload effect would let the auto-collapse effect consume
|
|
// the first-sync latch against the stale pre-organized catalog first.
|
|
const [prevOrganizationEnabled, setPrevOrganizationEnabled] =
|
|
useState(organizationEnabled);
|
|
if (prevOrganizationEnabled !== organizationEnabled) {
|
|
setPrevOrganizationEnabled(organizationEnabled);
|
|
setGroupsCatalogReady(!organizationEnabled);
|
|
}
|
|
const [sidebarWidth, setSidebarWidth] = useState(readSidebarWidth);
|
|
const [projectExpanded, setProjectExpanded] = useState(false);
|
|
const [projectsExpanded, setProjectsExpanded] = useState(true);
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const [workspaceRemovalCandidate, setWorkspaceRemovalCandidate] =
|
|
useState<DaemonWorkspaceCapability | null>(null);
|
|
const [workspaceRemovalActivity, setWorkspaceRemovalActivity] =
|
|
useState<DaemonWorkspaceRemovalActivity | null>(null);
|
|
const [workspaceRemovalSubmitting, setWorkspaceRemovalSubmitting] =
|
|
useState(false);
|
|
const workspaceRemovalMountedRef = useRef(false);
|
|
const workspaceRemovalDismissedRef = useRef(false);
|
|
const [
|
|
workspaceRemovalRemoteInProgress,
|
|
setWorkspaceRemovalRemoteInProgress,
|
|
] = useState(false);
|
|
const [workspaceSessionsReloadToken, setWorkspaceSessionsReloadToken] =
|
|
useState(0);
|
|
const [autoExpandWorkspace, setAutoExpandWorkspace] = useState<{
|
|
id: string;
|
|
key: string;
|
|
} | null>(null);
|
|
// Bump the token WorkspaceSection instances watch, so the per-workspace
|
|
// session lists re-poll immediately after a mutation instead of waiting for
|
|
// their 10s interval. Stable identity — safe (and required) in consumer deps.
|
|
const bumpWorkspaceReload = useCallback(() => {
|
|
setWorkspaceSessionsReloadToken((v) => v + 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
workspaceRemovalMountedRef.current = true;
|
|
return () => {
|
|
workspaceRemovalMountedRef.current = false;
|
|
workspaceRemovalDismissedRef.current = true;
|
|
};
|
|
}, []);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const [completedUnreadIds, setCompletedUnreadIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const sidebarRef = useRef<HTMLElement>(null);
|
|
const groupMenuRef = useRef<HTMLDivElement>(null);
|
|
const sessionMenuPointerDismissRef = useRef(false);
|
|
const previousRunningRef = useRef<Map<string, boolean> | null>(null);
|
|
const autoOpenedContextRef = useRef<string | null>(null);
|
|
const pollInFlightRef = useRef(false);
|
|
const resizeTeardownRef = useRef<((updateState: boolean) => void) | null>(
|
|
null,
|
|
);
|
|
const currentSessionId = connection.sessionId;
|
|
const workspaceRemovalEnabled = Boolean(
|
|
connection.capabilities?.features?.includes('workspace_runtime_removal'),
|
|
);
|
|
const canExportSessions =
|
|
connection.capabilities?.features?.includes('session_export') ?? false;
|
|
const canExportWorkspaceSessions =
|
|
connection.capabilities?.features?.includes('workspace_session_export') ??
|
|
false;
|
|
const canExportArchivedSessions =
|
|
connection.capabilities?.features?.includes(
|
|
'workspace_archived_session_export',
|
|
) ?? false;
|
|
const currentSessionIdentity = currentSessionId
|
|
? getSessionIdentity(
|
|
currentSessionId,
|
|
connection.workspaceCwd || primaryWorkspaceCwd,
|
|
)
|
|
: null;
|
|
const projectName =
|
|
getWorkspaceName(connection.workspaceCwd) || t('sidebar.projectFallback');
|
|
const displayedWorkspaces = useMemo<DaemonWorkspaceCapability[]>(() => {
|
|
const availableWorkspaces =
|
|
workspaces.length > 0
|
|
? workspaces
|
|
: [
|
|
{
|
|
id: 'primary',
|
|
cwd: connection.workspaceCwd || projectName,
|
|
primary: true,
|
|
trusted: true,
|
|
},
|
|
];
|
|
return lockedWorkspaceCwd
|
|
? availableWorkspaces.filter((entry) => entry.cwd === lockedWorkspaceCwd)
|
|
: availableWorkspaces;
|
|
}, [connection.workspaceCwd, lockedWorkspaceCwd, projectName, workspaces]);
|
|
const pinnedSessions = useMemo(() => {
|
|
const byId = new Map<string, DaemonSessionSummary>();
|
|
for (const session of [
|
|
...(includePrimaryWorkspaceSessions ? primaryPinnedSessions : []),
|
|
...secondaryPinnedSessions,
|
|
]) {
|
|
byId.set(
|
|
getSessionIdentity(
|
|
session.sessionId,
|
|
session.workspaceCwd || primaryWorkspaceCwd,
|
|
),
|
|
session,
|
|
);
|
|
}
|
|
return [...byId.values()];
|
|
}, [
|
|
includePrimaryWorkspaceSessions,
|
|
primaryWorkspaceCwd,
|
|
primaryPinnedSessions,
|
|
secondaryPinnedSessions,
|
|
]);
|
|
const resolveSessionWorkspaceScope = useCallback(
|
|
(session: DaemonSessionSummary): SessionWorkspaceScope => {
|
|
const explicitCwd = session.workspaceCwd;
|
|
const cwd = explicitCwd || primaryWorkspaceCwd || '';
|
|
const workspaceEntry = workspaces.find((entry) => entry.cwd === cwd);
|
|
|
|
if (explicitCwd && !workspaceEntry) {
|
|
if (!workspaceCatalogAdvertised && cwd === primaryWorkspaceCwd) {
|
|
return { kind: 'primary', cwd };
|
|
}
|
|
return { kind: 'unknown', cwd };
|
|
}
|
|
if (workspaceEntry && !workspaceEntry.trusted) {
|
|
return { kind: 'untrusted', cwd, workspace: workspaceEntry };
|
|
}
|
|
if (
|
|
workspaceEntry?.primary ||
|
|
(!explicitCwd && cwd === primaryWorkspaceCwd)
|
|
) {
|
|
return { kind: 'primary', cwd };
|
|
}
|
|
if (!workspaceEntry) return { kind: 'unknown', cwd };
|
|
if (lockedWorkspaceCwd === cwd) {
|
|
return { kind: 'locked', cwd, workspace: workspaceEntry };
|
|
}
|
|
return { kind: 'restricted', cwd, workspace: workspaceEntry };
|
|
},
|
|
[
|
|
lockedWorkspaceCwd,
|
|
primaryWorkspaceCwd,
|
|
workspaceCatalogAdvertised,
|
|
workspaces,
|
|
],
|
|
);
|
|
const getSessionWorkspaceCwd = useCallback(
|
|
(session: DaemonSessionSummary) =>
|
|
resolveSessionWorkspaceScope(session).cwd,
|
|
[resolveSessionWorkspaceScope],
|
|
);
|
|
const isMutableSessionScope = useCallback(
|
|
(scope: SessionWorkspaceScope) =>
|
|
scope.kind === 'primary' || scope.kind === 'locked',
|
|
[],
|
|
);
|
|
const canUseWorkspaceQualifiedActions = useCallback(
|
|
(scope: SessionWorkspaceScope) =>
|
|
scope.kind === 'primary' ||
|
|
(scope.kind === 'locked' && workspaceQualifiedRestCoreEnabled),
|
|
[workspaceQualifiedRestCoreEnabled],
|
|
);
|
|
// Organization (pin/group) is safe for any trusted workspace — not just
|
|
// locked ones — because it only mutates display metadata, never executes
|
|
// code or touches the filesystem.
|
|
const canUseOrganizationActions = useCallback(
|
|
(scope: SessionWorkspaceScope) => {
|
|
if (scope.kind === 'unknown' || scope.kind === 'untrusted') return false;
|
|
return scope.kind === 'primary' || workspaceQualifiedRestCoreEnabled;
|
|
},
|
|
[workspaceQualifiedRestCoreEnabled],
|
|
);
|
|
const isActiveSessionReadOnly = useCallback(
|
|
(session: DaemonSessionSummary) =>
|
|
!isMutableSessionScope(resolveSessionWorkspaceScope(session)),
|
|
[isMutableSessionScope, resolveSessionWorkspaceScope],
|
|
);
|
|
const getSessionWorkspaceActions = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
const scope = resolveSessionWorkspaceScope(session);
|
|
if (scope.kind === 'primary') return workspaceActions;
|
|
if (scope.kind === 'locked' || scope.kind === 'restricted') {
|
|
return workspace.client.workspaceByCwd(scope.cwd);
|
|
}
|
|
return undefined;
|
|
},
|
|
[resolveSessionWorkspaceScope, workspace.client, workspaceActions],
|
|
);
|
|
const getIdentityForSession = useCallback(
|
|
(session: DaemonSessionSummary) =>
|
|
getSessionIdentity(session.sessionId, getSessionWorkspaceCwd(session)),
|
|
[getSessionWorkspaceCwd],
|
|
);
|
|
const isCurrentSession = useCallback(
|
|
(session: DaemonSessionSummary) =>
|
|
currentSessionIdentity === getIdentityForSession(session),
|
|
[currentSessionIdentity, getIdentityForSession],
|
|
);
|
|
const canRenameSession = useCallback(
|
|
(session: DaemonSessionSummary) =>
|
|
sessionActionItems.has('rename') &&
|
|
isCurrentSession(session) &&
|
|
isMutableSessionScope(resolveSessionWorkspaceScope(session)),
|
|
[
|
|
isCurrentSession,
|
|
isMutableSessionScope,
|
|
resolveSessionWorkspaceScope,
|
|
sessionActionItems,
|
|
],
|
|
);
|
|
const canShowDeleteSession = useCallback(
|
|
(session: DaemonSessionSummary) =>
|
|
sessionActionItems.has('delete') &&
|
|
canUseWorkspaceQualifiedActions(resolveSessionWorkspaceScope(session)),
|
|
[
|
|
canUseWorkspaceQualifiedActions,
|
|
resolveSessionWorkspaceScope,
|
|
sessionActionItems,
|
|
],
|
|
);
|
|
const canDeleteSession = useCallback(
|
|
(session: DaemonSessionSummary) =>
|
|
!isCurrentSession(session) && canShowDeleteSession(session),
|
|
[canShowDeleteSession, isCurrentSession],
|
|
);
|
|
const canOrganizeSession = useCallback(
|
|
(session: DaemonSessionSummary, item: 'pin' | 'group') =>
|
|
organizationEnabled &&
|
|
sessionActionItems.has(item) &&
|
|
canUseOrganizationActions(resolveSessionWorkspaceScope(session)),
|
|
[
|
|
canUseOrganizationActions,
|
|
organizationEnabled,
|
|
resolveSessionWorkspaceScope,
|
|
sessionActionItems,
|
|
],
|
|
);
|
|
const resolveWorkspaceScope = useCallback(
|
|
(workspaceCwd?: string) =>
|
|
resolveSessionWorkspaceScope({
|
|
sessionId: '',
|
|
workspaceCwd,
|
|
} as DaemonSessionSummary),
|
|
[resolveSessionWorkspaceScope],
|
|
);
|
|
const canOrganizeWorkspace = useCallback(
|
|
(workspaceCwd?: string) =>
|
|
organizationEnabled &&
|
|
sessionActionItems.has('group') &&
|
|
canUseOrganizationActions(resolveWorkspaceScope(workspaceCwd)),
|
|
[
|
|
canUseOrganizationActions,
|
|
organizationEnabled,
|
|
resolveWorkspaceScope,
|
|
sessionActionItems,
|
|
],
|
|
);
|
|
const groupAssignmentPolicyRef = useRef<{
|
|
canOrganizeSession: typeof canOrganizeSession;
|
|
getSessionWorkspaceActions: typeof getSessionWorkspaceActions;
|
|
resolveWorkspaceScope: typeof resolveWorkspaceScope;
|
|
} | null>(null);
|
|
groupAssignmentPolicyRef.current = {
|
|
canOrganizeSession,
|
|
getSessionWorkspaceActions,
|
|
resolveWorkspaceScope,
|
|
};
|
|
const canMutateSessionArchive = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
const scope = resolveSessionWorkspaceScope(session);
|
|
if (scope.kind === 'unknown' || scope.kind === 'untrusted') return false;
|
|
return (
|
|
sessionArchiveEnabled &&
|
|
(scope.kind === 'primary' || workspaceQualifiedRestCoreEnabled)
|
|
);
|
|
},
|
|
[
|
|
resolveSessionWorkspaceScope,
|
|
sessionArchiveEnabled,
|
|
workspaceQualifiedRestCoreEnabled,
|
|
],
|
|
);
|
|
const getActiveExportScope = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
const scope = resolveSessionWorkspaceScope(session);
|
|
if (scope.kind === 'primary' && canExportSessions) {
|
|
return scope;
|
|
}
|
|
if (scope.kind === 'locked' && canExportWorkspaceSessions) {
|
|
return scope;
|
|
}
|
|
return undefined;
|
|
},
|
|
[
|
|
canExportSessions,
|
|
canExportWorkspaceSessions,
|
|
resolveSessionWorkspaceScope,
|
|
],
|
|
);
|
|
const getArchivedExportWorkspaceCwd = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
const scope = resolveSessionWorkspaceScope(session);
|
|
return canExportArchivedSessions &&
|
|
scope.kind !== 'unknown' &&
|
|
scope.kind !== 'untrusted'
|
|
? scope.cwd
|
|
: undefined;
|
|
},
|
|
[canExportArchivedSessions, resolveSessionWorkspaceScope],
|
|
);
|
|
const canArchiveSession = useCallback(
|
|
(session: DaemonSessionSummary) =>
|
|
sessionActionItems.has('archive') &&
|
|
!isCurrentSession(session) &&
|
|
canMutateSessionArchive(session),
|
|
[canMutateSessionArchive, isCurrentSession, sessionActionItems],
|
|
);
|
|
const canUnarchiveSession = useCallback(
|
|
(session: DaemonSessionSummary) =>
|
|
sessionActionItems.has('archive') && canMutateSessionArchive(session),
|
|
[canMutateSessionArchive, sessionActionItems],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!organizationEnabled) {
|
|
setSecondaryPinnedSessions([]);
|
|
return;
|
|
}
|
|
const secondaryWorkspaces = displayedWorkspaces.filter(
|
|
(entry) => !entry.primary && entry.trusted,
|
|
);
|
|
let cancelled = false;
|
|
void Promise.allSettled(
|
|
secondaryWorkspaces.map(async (entry) => {
|
|
const result = await workspace.client
|
|
.workspaceByCwd(entry.cwd)
|
|
.listWorkspaceSessions({
|
|
pageSize: SESSION_LIST_PAGE_SIZE,
|
|
archiveState: 'active',
|
|
...(sourceMetadataEnabled
|
|
? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE }
|
|
: {}),
|
|
view: 'organized',
|
|
group: 'pinned',
|
|
});
|
|
return result.map((session) => ({
|
|
...session,
|
|
workspaceCwd: entry.cwd,
|
|
}));
|
|
}),
|
|
).then((results) => {
|
|
if (cancelled) return;
|
|
setSecondaryPinnedSessions(
|
|
results.flatMap((result) =>
|
|
result.status === 'fulfilled' ? result.value : [],
|
|
),
|
|
);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
displayedWorkspaces,
|
|
organizationEnabled,
|
|
sourceMetadataEnabled,
|
|
workspace.client,
|
|
workspaceSessionsReloadToken,
|
|
]);
|
|
const allArchivedSessions = useMemo(() => {
|
|
const byIdentity = new Map<string, DaemonSessionSummary>();
|
|
for (const session of [
|
|
...(includePrimaryWorkspaceSessions ? archivedSessions : []),
|
|
...secondaryArchivedSessions,
|
|
]) {
|
|
byIdentity.set(getIdentityForSession(session), session);
|
|
}
|
|
return [...byIdentity.values()];
|
|
}, [
|
|
archivedSessions,
|
|
getIdentityForSession,
|
|
includePrimaryWorkspaceSessions,
|
|
secondaryArchivedSessions,
|
|
]);
|
|
const effectiveArchivedLoading =
|
|
(includePrimaryWorkspaceSessions && archivedLoading) ||
|
|
secondaryArchivedLoading;
|
|
const effectiveArchivedError =
|
|
(includePrimaryWorkspaceSessions && Boolean(archivedError)) ||
|
|
secondaryArchivedError;
|
|
|
|
useEffect(() => {
|
|
if (!archivedExpanded) return;
|
|
if (!sessionArchiveEnabled || !workspaceQualifiedRestCoreEnabled) {
|
|
setSecondaryArchivedSessions([]);
|
|
setSecondaryArchivedLoading(false);
|
|
setSecondaryArchivedError(false);
|
|
return;
|
|
}
|
|
const secondaryWorkspaces = displayedWorkspaces.filter(
|
|
(entry) => !entry.primary && entry.trusted,
|
|
);
|
|
if (secondaryWorkspaces.length === 0) {
|
|
setSecondaryArchivedSessions([]);
|
|
setSecondaryArchivedLoading(false);
|
|
setSecondaryArchivedError(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setSecondaryArchivedLoading(true);
|
|
void Promise.allSettled(
|
|
secondaryWorkspaces.map(async (entry) => {
|
|
const sessions = await workspace.client
|
|
.workspaceByCwd(entry.cwd)
|
|
.listWorkspaceSessions({
|
|
pageSize: SESSION_LIST_PAGE_SIZE,
|
|
archiveState: 'archived',
|
|
...(sourceMetadataEnabled
|
|
? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE }
|
|
: {}),
|
|
...(organizationEnabled
|
|
? { view: 'organized' as const, group: 'all' }
|
|
: {}),
|
|
});
|
|
return sessions.map((session) => ({
|
|
...session,
|
|
workspaceCwd: entry.cwd,
|
|
}));
|
|
}),
|
|
)
|
|
.then((results) => {
|
|
if (cancelled) return;
|
|
const failures = results.filter(
|
|
(result) => result.status === 'rejected',
|
|
);
|
|
setSecondaryArchivedSessions(
|
|
results.flatMap((result) =>
|
|
result.status === 'fulfilled' ? result.value : [],
|
|
),
|
|
);
|
|
setSecondaryArchivedError(failures.length > 0);
|
|
for (const failure of failures) {
|
|
console.warn(
|
|
'[WebShellSidebar] archived session load failed:',
|
|
failure.reason,
|
|
);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setSecondaryArchivedLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
archivedExpanded,
|
|
displayedWorkspaces,
|
|
organizationEnabled,
|
|
secondaryArchivedReloadToken,
|
|
sessionArchiveEnabled,
|
|
sourceMetadataEnabled,
|
|
workspace.client,
|
|
workspaceQualifiedRestCoreEnabled,
|
|
workspaceSessionsReloadToken,
|
|
]);
|
|
const qwenCodeVersion = connection.capabilities?.qwenCodeVersion || '';
|
|
// Numeric releases render as "v1.2.3"; a non-semver fallback such as
|
|
// "unknown" is shown as-is so we never produce a bogus "vunknown".
|
|
const versionLabel = qwenCodeVersion
|
|
? /^\d/.test(qwenCodeVersion)
|
|
? `v${qwenCodeVersion}`
|
|
: qwenCodeVersion
|
|
: '';
|
|
const footerCompact =
|
|
!collapsed && sidebarWidth < SIDEBAR_FOOTER_COMPACT_WIDTH;
|
|
const footerTight = !collapsed && sidebarWidth < SIDEBAR_FOOTER_TIGHT_WIDTH;
|
|
const sidebarStyle = {
|
|
'--web-shell-sidebar-width': `${sidebarWidth}px`,
|
|
} as CSSProperties;
|
|
const newSessionDisabled = creatingSession;
|
|
|
|
useEffect(() => {
|
|
if (!currentSessionId) return;
|
|
const activeWorkspace =
|
|
displayedWorkspaces.find(
|
|
(entry) => entry.cwd === connection.workspaceCwd,
|
|
) ??
|
|
(displayedWorkspaces.length === 1 && displayedWorkspaces[0]?.primary
|
|
? displayedWorkspaces[0]
|
|
: undefined);
|
|
if (!activeWorkspace) return;
|
|
const contextKey = `session:${currentSessionId}:${activeWorkspace.id}`;
|
|
if (autoOpenedContextRef.current === contextKey) return;
|
|
autoOpenedContextRef.current = contextKey;
|
|
setProjectsExpanded(true);
|
|
if (activeWorkspace.primary) {
|
|
setProjectExpanded(true);
|
|
} else {
|
|
setAutoExpandWorkspace({ id: activeWorkspace.id, key: contextKey });
|
|
}
|
|
}, [connection.workspaceCwd, currentSessionId, displayedWorkspaces]);
|
|
|
|
useEffect(() => {
|
|
if (currentSessionId || selectedWorkspaceCwd !== undefined) {
|
|
return;
|
|
}
|
|
if (!workspace.capabilities) return;
|
|
const connectedWorkspace = workspaces.find(
|
|
(entry) => entry.cwd === connection.workspaceCwd,
|
|
);
|
|
const contextKey = `new:${connectedWorkspace?.id ?? 'primary'}`;
|
|
if (autoOpenedContextRef.current === contextKey) return;
|
|
autoOpenedContextRef.current = contextKey;
|
|
setProjectsExpanded(true);
|
|
if (connectedWorkspace && !connectedWorkspace.primary) {
|
|
setProjectExpanded(false);
|
|
setAutoExpandWorkspace({
|
|
id: connectedWorkspace.id,
|
|
key: contextKey,
|
|
});
|
|
onSelectWorkspace?.(connectedWorkspace.cwd);
|
|
return;
|
|
}
|
|
setProjectExpanded(true);
|
|
}, [
|
|
connection.workspaceCwd,
|
|
currentSessionId,
|
|
onSelectWorkspace,
|
|
selectedWorkspaceCwd,
|
|
workspace.capabilities,
|
|
workspaces,
|
|
]);
|
|
|
|
const setSessionBusy = useCallback(
|
|
(sessionId: string, busy: boolean, workspaceCwd?: string) => {
|
|
const identity = getSessionIdentity(
|
|
sessionId,
|
|
workspaceCwd || primaryWorkspaceCwd,
|
|
);
|
|
const next = new Set(busySessionIdsRef.current);
|
|
if (busy) {
|
|
next.add(identity);
|
|
} else {
|
|
next.delete(identity);
|
|
}
|
|
busySessionIdsRef.current = next;
|
|
setBusySessionIds(next);
|
|
},
|
|
[primaryWorkspaceCwd],
|
|
);
|
|
|
|
const reloadGroups = useCallback(async () => {
|
|
if (!organizationEnabled) {
|
|
setGroups([]);
|
|
setColorOptions([]);
|
|
setGroupsCatalogReady(true);
|
|
return;
|
|
}
|
|
try {
|
|
const catalog = await workspaceActions.listSessionGroups();
|
|
setGroups(catalog.groups);
|
|
setMenuGroups(catalog.groups);
|
|
setColorOptions(catalog.colorOptions);
|
|
// Empty catalogs still settle the latch — sessions/groups hydrate on
|
|
// independent requests, so readiness cannot wait for a non-empty list.
|
|
// Failures must not settle it (see sessionsCatalogReady above).
|
|
setGroupsCatalogReady(true);
|
|
} catch (err) {
|
|
onError(err, t('sidebar.groupsLoadFailed'));
|
|
}
|
|
}, [onError, organizationEnabled, t, workspaceActions]);
|
|
|
|
useEffect(() => {
|
|
if (!organizationEnabled) {
|
|
setGroups([]);
|
|
setColorOptions([]);
|
|
setGroupsCatalogReady(true);
|
|
return;
|
|
}
|
|
setGroupsCatalogReady(false);
|
|
void reloadGroups();
|
|
}, [organizationEnabled, reloadGroups]);
|
|
|
|
useEffect(() => {
|
|
if (!groupMenu) return;
|
|
const handlePointerDown = (event: MouseEvent) => {
|
|
if (groupMenuRef.current?.contains(event.target as Node)) return;
|
|
setGroupMenu(null);
|
|
};
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setGroupMenu(null);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handlePointerDown);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handlePointerDown);
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [groupMenu]);
|
|
|
|
useEffect(() => {
|
|
if (!groupMenu) return;
|
|
const animationFrame = window.requestAnimationFrame(() => {
|
|
const items = Array.from(
|
|
groupMenuRef.current?.querySelectorAll<HTMLButtonElement>(
|
|
'button:not(:disabled)',
|
|
) ?? [],
|
|
);
|
|
const selected =
|
|
items.find((item) => item.getAttribute('aria-checked') === 'true') ??
|
|
items[0];
|
|
selected?.focus();
|
|
});
|
|
return () => window.cancelAnimationFrame(animationFrame);
|
|
}, [groupMenu]);
|
|
|
|
const handleGroupMenuKeyDown = useCallback(
|
|
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
const items = Array.from(
|
|
groupMenuRef.current?.querySelectorAll<HTMLButtonElement>(
|
|
'button:not(:disabled)',
|
|
) ?? [],
|
|
);
|
|
if (items.length === 0) return;
|
|
const activeIndex = items.indexOf(
|
|
document.activeElement as HTMLButtonElement,
|
|
);
|
|
const currentIndex = activeIndex >= 0 ? activeIndex : -1;
|
|
let nextIndex: number | undefined;
|
|
if (event.key === 'ArrowDown') {
|
|
nextIndex = (currentIndex + 1) % items.length;
|
|
} else if (event.key === 'ArrowUp') {
|
|
nextIndex = (currentIndex - 1 + items.length) % items.length;
|
|
} else if (event.key === 'Home') {
|
|
nextIndex = 0;
|
|
} else if (event.key === 'End') {
|
|
nextIndex = items.length - 1;
|
|
} else if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
setGroupMenu(null);
|
|
return;
|
|
}
|
|
if (nextIndex === undefined) return;
|
|
event.preventDefault();
|
|
items[nextIndex]?.focus();
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
resizeTeardownRef.current?.(false);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// The max width derives from window size, so re-clamp when the window
|
|
// shrinks below a previously stored wider sidebar.
|
|
useEffect(() => {
|
|
function handleWindowResize() {
|
|
setSidebarWidth((current) => clampSidebarWidth(current));
|
|
}
|
|
window.addEventListener('resize', handleWindowResize);
|
|
return () => window.removeEventListener('resize', handleWindowResize);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (collapsed) {
|
|
setProjectExpanded(false);
|
|
setSearchOpen(false);
|
|
setSearchQuery('');
|
|
}
|
|
}, [collapsed]);
|
|
|
|
const hasRunningSession = useMemo(
|
|
() => sessions.some((session) => session.hasActivePrompt),
|
|
[sessions],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!projectExpanded && !hasRunningSession) return;
|
|
const pollInterval =
|
|
hasRunningSession && !error
|
|
? ACTIVE_SESSION_POLL_INTERVAL_MS
|
|
: IDLE_SESSION_POLL_INTERVAL_MS;
|
|
const intervalId = window.setInterval(() => {
|
|
if (document.hidden || pollInFlightRef.current) return;
|
|
pollInFlightRef.current = true;
|
|
void reload().finally(() => {
|
|
pollInFlightRef.current = false;
|
|
});
|
|
}, pollInterval);
|
|
return () => window.clearInterval(intervalId);
|
|
}, [error, hasRunningSession, projectExpanded, reload]);
|
|
|
|
const prevReloadTokenRef = useRef(sessionListReloadToken);
|
|
useEffect(() => {
|
|
if (
|
|
sessionListReloadToken !== undefined &&
|
|
sessionListReloadToken !== prevReloadTokenRef.current &&
|
|
!document.hidden &&
|
|
!pollInFlightRef.current
|
|
) {
|
|
prevReloadTokenRef.current = sessionListReloadToken;
|
|
pollInFlightRef.current = true;
|
|
void reload().finally(() => {
|
|
pollInFlightRef.current = false;
|
|
});
|
|
}
|
|
}, [sessionListReloadToken, reload]);
|
|
|
|
useEffect(() => {
|
|
const runningBySessionId = new Map(
|
|
sessions.map((session) => [
|
|
getIdentityForSession(session),
|
|
Boolean(session.hasActivePrompt),
|
|
]),
|
|
);
|
|
const previousRunningBySessionId = previousRunningRef.current;
|
|
previousRunningRef.current = runningBySessionId;
|
|
if (previousRunningBySessionId === null) return;
|
|
|
|
setCompletedUnreadIds((current) => {
|
|
const next = new Set(current);
|
|
let changed = false;
|
|
|
|
for (const [sessionIdentity, wasRunning] of previousRunningBySessionId) {
|
|
const isRunning = runningBySessionId.get(sessionIdentity);
|
|
if (
|
|
wasRunning &&
|
|
isRunning === false &&
|
|
sessionIdentity !== currentSessionIdentity &&
|
|
!next.has(sessionIdentity)
|
|
) {
|
|
next.add(sessionIdentity);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
for (const sessionIdentity of next) {
|
|
if (
|
|
sessionIdentity === currentSessionIdentity ||
|
|
!runningBySessionId.has(sessionIdentity) ||
|
|
runningBySessionId.get(sessionIdentity)
|
|
) {
|
|
next.delete(sessionIdentity);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return changed ? next : current;
|
|
});
|
|
}, [currentSessionIdentity, getIdentityForSession, sessions]);
|
|
|
|
const reconcileRemovedWorkspace = useCallback(
|
|
async (removed: DaemonWorkspaceCapability) => {
|
|
if (!workspaceRemovalMountedRef.current) return;
|
|
if (selectedWorkspaceCwd === removed.cwd) {
|
|
onSelectWorkspace?.(undefined);
|
|
}
|
|
setWorkspaceSessionsReloadToken((token) => token + 1);
|
|
try {
|
|
await workspace.refreshCapabilities?.();
|
|
} catch {
|
|
// The mutation already converged; a later refresh will reconcile.
|
|
}
|
|
if (!workspaceRemovalMountedRef.current) return;
|
|
setWorkspaceRemovalCandidate(null);
|
|
setWorkspaceRemovalActivity(null);
|
|
setWorkspaceRemovalRemoteInProgress(false);
|
|
void reload().catch(() => undefined);
|
|
void reloadArchived().catch(() => undefined);
|
|
},
|
|
[
|
|
onSelectWorkspace,
|
|
reload,
|
|
reloadArchived,
|
|
selectedWorkspaceCwd,
|
|
workspace,
|
|
],
|
|
);
|
|
|
|
const requestWorkspaceRemoval = useCallback(
|
|
(candidate: DaemonWorkspaceCapability) => {
|
|
if (workspaceRemovalSubmitting) return;
|
|
workspaceRemovalDismissedRef.current = false;
|
|
setWorkspaceRemovalActivity(null);
|
|
setWorkspaceRemovalRemoteInProgress(false);
|
|
setWorkspaceRemovalCandidate(candidate);
|
|
},
|
|
[workspaceRemovalSubmitting],
|
|
);
|
|
|
|
const confirmWorkspaceRemoval = useCallback(async () => {
|
|
const candidate = workspaceRemovalCandidate;
|
|
if (!candidate || workspaceRemovalSubmitting) return;
|
|
const force = workspaceRemovalActivity !== null;
|
|
if (
|
|
force &&
|
|
connection.sessionId &&
|
|
connection.workspaceCwd === candidate.cwd
|
|
) {
|
|
return;
|
|
}
|
|
setWorkspaceRemovalSubmitting(true);
|
|
try {
|
|
await workspaceActions.removeWorkspace(candidate.id, { force });
|
|
await reconcileRemovedWorkspace(candidate);
|
|
} catch (error) {
|
|
if (!workspaceRemovalMountedRef.current) return;
|
|
if (error instanceof DaemonHttpError) {
|
|
const body = error.body as
|
|
| {
|
|
code?: unknown;
|
|
activity?: DaemonWorkspaceRemovalActivity;
|
|
}
|
|
| undefined;
|
|
if (
|
|
error.status === 409 &&
|
|
body?.code === 'workspace_busy' &&
|
|
body.activity
|
|
) {
|
|
setWorkspaceRemovalActivity(body.activity);
|
|
return;
|
|
}
|
|
if (error.status === 400 && body?.code === 'workspace_mismatch') {
|
|
await reconcileRemovedWorkspace(candidate);
|
|
return;
|
|
}
|
|
if (
|
|
error.status === 409 &&
|
|
(body?.code === 'workspace_removal_in_progress' ||
|
|
body?.code === 'workspace_registration_in_progress')
|
|
) {
|
|
setWorkspaceRemovalRemoteInProgress(true);
|
|
let lastError: unknown = error;
|
|
let exhaustedTransientRetries = true;
|
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
if (
|
|
!workspaceRemovalMountedRef.current ||
|
|
workspaceRemovalDismissedRef.current
|
|
) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => window.setTimeout(resolve, 250));
|
|
if (
|
|
!workspaceRemovalMountedRef.current ||
|
|
workspaceRemovalDismissedRef.current
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
await workspaceActions.removeWorkspace(candidate.id, { force });
|
|
await reconcileRemovedWorkspace(candidate);
|
|
return;
|
|
} catch (retryError) {
|
|
if (!workspaceRemovalMountedRef.current) return;
|
|
lastError = retryError;
|
|
if (retryError instanceof DaemonHttpError) {
|
|
const retryBody = retryError.body as
|
|
| {
|
|
code?: unknown;
|
|
activity?: DaemonWorkspaceRemovalActivity;
|
|
}
|
|
| undefined;
|
|
if (
|
|
retryError.status === 400 &&
|
|
retryBody?.code === 'workspace_mismatch'
|
|
) {
|
|
await reconcileRemovedWorkspace(candidate);
|
|
return;
|
|
}
|
|
if (
|
|
retryError.status === 409 &&
|
|
retryBody?.code === 'workspace_busy' &&
|
|
retryBody.activity
|
|
) {
|
|
setWorkspaceRemovalRemoteInProgress(false);
|
|
setWorkspaceRemovalActivity(retryBody.activity);
|
|
return;
|
|
}
|
|
if (
|
|
retryError.status === 409 &&
|
|
(retryBody?.code === 'workspace_removal_in_progress' ||
|
|
retryBody?.code === 'workspace_registration_in_progress')
|
|
) {
|
|
continue;
|
|
}
|
|
}
|
|
exhaustedTransientRetries = false;
|
|
break;
|
|
}
|
|
}
|
|
if (
|
|
!workspaceRemovalMountedRef.current ||
|
|
workspaceRemovalDismissedRef.current
|
|
) {
|
|
return;
|
|
}
|
|
setWorkspaceRemovalRemoteInProgress(false);
|
|
onError(
|
|
exhaustedTransientRetries
|
|
? new Error(
|
|
'Workspace removal remained in progress after retries.',
|
|
)
|
|
: lastError,
|
|
t('sidebar.removeWorkspaceError'),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
onError(error, t('sidebar.removeWorkspaceError'));
|
|
} finally {
|
|
if (workspaceRemovalMountedRef.current) {
|
|
setWorkspaceRemovalSubmitting(false);
|
|
}
|
|
}
|
|
}, [
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
onError,
|
|
reconcileRemovedWorkspace,
|
|
t,
|
|
workspaceActions,
|
|
workspaceRemovalActivity,
|
|
workspaceRemovalCandidate,
|
|
workspaceRemovalSubmitting,
|
|
]);
|
|
|
|
const handleNewSession = useCallback(
|
|
(workspaceCwd?: string) => {
|
|
if (creatingSessionRef.current) return;
|
|
|
|
creatingSessionRef.current = true;
|
|
setCreatingSession(true);
|
|
void (async () => {
|
|
try {
|
|
const created = await onNewSession(workspaceCwd);
|
|
if (created) {
|
|
void reload().catch(() => undefined);
|
|
bumpWorkspaceReload();
|
|
}
|
|
} catch (err) {
|
|
if (!isAbortError(err)) {
|
|
onError(err, t('sidebar.newSessionFailed'));
|
|
}
|
|
} finally {
|
|
creatingSessionRef.current = false;
|
|
setCreatingSession(false);
|
|
}
|
|
})();
|
|
},
|
|
[bumpWorkspaceReload, onError, onNewSession, reload, t],
|
|
);
|
|
|
|
const handleLoadSession = useCallback(
|
|
(sessionId: string, workspaceCwd?: string) => {
|
|
const sessionIdentity = getSessionIdentity(
|
|
sessionId,
|
|
workspaceCwd || primaryWorkspaceCwd,
|
|
);
|
|
if (sessionIdentity === currentSessionIdentity) {
|
|
onSelectCurrentSession?.();
|
|
return;
|
|
}
|
|
if (busySessionIdsRef.current.has(sessionIdentity)) return;
|
|
setCompletedUnreadIds((current) => {
|
|
if (!current.has(sessionIdentity)) return current;
|
|
const next = new Set(current);
|
|
next.delete(sessionIdentity);
|
|
return next;
|
|
});
|
|
setSessionBusy(sessionId, true, workspaceCwd);
|
|
void (async () => {
|
|
try {
|
|
await onLoadSession(sessionId, workspaceCwd);
|
|
} catch (err) {
|
|
if (!isAbortError(err)) {
|
|
onError(err, t('sidebar.switchFailed'));
|
|
}
|
|
} finally {
|
|
setSessionBusy(sessionId, false, workspaceCwd);
|
|
}
|
|
})();
|
|
},
|
|
[
|
|
currentSessionIdentity,
|
|
onError,
|
|
onLoadSession,
|
|
onSelectCurrentSession,
|
|
primaryWorkspaceCwd,
|
|
setSessionBusy,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const startRename = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
if (!canRenameSession(session)) return;
|
|
setEditingSessionIdentity(getIdentityForSession(session));
|
|
setEditingName(getSessionLabel(session));
|
|
},
|
|
[canRenameSession, getIdentityForSession],
|
|
);
|
|
|
|
const cancelRename = useCallback(() => {
|
|
setEditingSessionIdentity(null);
|
|
setEditingName('');
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const currentSession = currentSessionId
|
|
? ({
|
|
sessionId: currentSessionId,
|
|
workspaceCwd: connection.workspaceCwd,
|
|
} as DaemonSessionSummary)
|
|
: undefined;
|
|
if (
|
|
editingSessionIdentity !== null &&
|
|
(!currentSession ||
|
|
editingSessionIdentity !== currentSessionIdentity ||
|
|
!canRenameSession(currentSession))
|
|
) {
|
|
cancelRename();
|
|
}
|
|
}, [
|
|
canRenameSession,
|
|
cancelRename,
|
|
connection.workspaceCwd,
|
|
currentSessionId,
|
|
currentSessionIdentity,
|
|
editingSessionIdentity,
|
|
]);
|
|
|
|
const saveRename = useCallback(() => {
|
|
const nextName = editingName.trim();
|
|
if (
|
|
!nextName ||
|
|
!currentSessionId ||
|
|
editingSessionIdentity !== currentSessionIdentity ||
|
|
!canRenameSession({
|
|
sessionId: currentSessionId,
|
|
workspaceCwd: connection.workspaceCwd,
|
|
} as DaemonSessionSummary)
|
|
) {
|
|
cancelRename();
|
|
return;
|
|
}
|
|
const sessionId = currentSessionId;
|
|
const sessionIdentity = currentSessionIdentity;
|
|
if (!sessionIdentity || busySessionIdsRef.current.has(sessionIdentity)) {
|
|
return;
|
|
}
|
|
setSessionBusy(sessionId, true, connection.workspaceCwd);
|
|
actions
|
|
.renameSession(nextName)
|
|
.then(() => {
|
|
cancelRename();
|
|
reload();
|
|
bumpWorkspaceReload();
|
|
})
|
|
.catch((err: unknown) => {
|
|
onError(err, t('sidebar.renameFailed'));
|
|
cancelRename();
|
|
})
|
|
.finally(() => {
|
|
setSessionBusy(sessionId, false, connection.workspaceCwd);
|
|
});
|
|
}, [
|
|
actions,
|
|
bumpWorkspaceReload,
|
|
canRenameSession,
|
|
cancelRename,
|
|
connection.workspaceCwd,
|
|
currentSessionIdentity,
|
|
currentSessionId,
|
|
editingName,
|
|
editingSessionIdentity,
|
|
onError,
|
|
reload,
|
|
setSessionBusy,
|
|
t,
|
|
]);
|
|
|
|
const handleDeleteSession = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
if (!canDeleteSession(session)) return;
|
|
setDeleteCandidate(session);
|
|
},
|
|
[canDeleteSession],
|
|
);
|
|
|
|
const setSessionExporting = useCallback(
|
|
(sessionId: string, exporting: boolean, workspaceCwd?: string) => {
|
|
const identity = getSessionIdentity(
|
|
sessionId,
|
|
workspaceCwd || primaryWorkspaceCwd,
|
|
);
|
|
const next = new Set(exportingSessionIdsRef.current);
|
|
if (exporting) {
|
|
next.add(identity);
|
|
} else {
|
|
next.delete(identity);
|
|
}
|
|
exportingSessionIdsRef.current = next;
|
|
setExportingSessionIds(next);
|
|
},
|
|
[primaryWorkspaceCwd],
|
|
);
|
|
|
|
const handleExportSession = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
const sessionId = session.sessionId;
|
|
const sessionIdentity = getIdentityForSession(session);
|
|
const archived = session.isArchived === true;
|
|
const activeExportScope = getActiveExportScope(session);
|
|
if (
|
|
!sessionActionItems.has('export') ||
|
|
(archived
|
|
? !getArchivedExportWorkspaceCwd(session)
|
|
: !activeExportScope) ||
|
|
exportingSessionIdsRef.current.has(sessionIdentity)
|
|
) {
|
|
return;
|
|
}
|
|
setSessionExporting(sessionId, true, session.workspaceCwd);
|
|
void (async () => {
|
|
try {
|
|
let result;
|
|
if (archived) {
|
|
const workspaceCwd = getArchivedExportWorkspaceCwd(session);
|
|
if (!workspaceCwd) return;
|
|
result = await workspace.client
|
|
.workspaceByCwd(workspaceCwd)
|
|
.exportArchivedSession(sessionId, { format: 'html' });
|
|
} else if (activeExportScope?.kind === 'primary') {
|
|
result = await exportSession(sessionId, 'html');
|
|
} else if (activeExportScope?.kind === 'locked') {
|
|
result = await workspace.client
|
|
.workspaceByCwd(activeExportScope.cwd)
|
|
.exportSession(sessionId, { format: 'html' });
|
|
} else {
|
|
return;
|
|
}
|
|
const blob = new Blob([result.content], {
|
|
type: result.mimeType || 'text/html',
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
try {
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = result.filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
} finally {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
} catch (err) {
|
|
onError(err, t('sidebar.exportFailed'));
|
|
} finally {
|
|
setSessionExporting(sessionId, false, session.workspaceCwd);
|
|
}
|
|
})();
|
|
},
|
|
[
|
|
exportSession,
|
|
getActiveExportScope,
|
|
getArchivedExportWorkspaceCwd,
|
|
getIdentityForSession,
|
|
onError,
|
|
setSessionExporting,
|
|
sessionActionItems,
|
|
t,
|
|
workspace.client,
|
|
],
|
|
);
|
|
|
|
const confirmDeleteSession = useCallback(() => {
|
|
if (!deleteCandidate) return;
|
|
const sessionId = deleteCandidate.sessionId;
|
|
const sessionIdentity = getIdentityForSession(deleteCandidate);
|
|
if (!canDeleteSession(deleteCandidate)) {
|
|
setDeleteCandidate(null);
|
|
return;
|
|
}
|
|
const scope = resolveSessionWorkspaceScope(deleteCandidate);
|
|
const isArchived = Boolean(deleteCandidate.isArchived);
|
|
const removeSession =
|
|
scope.kind === 'locked'
|
|
? async (id: string) => {
|
|
const result = await workspace.client
|
|
.workspaceByCwd(scope.cwd)
|
|
.deleteSessionsData([id]);
|
|
const itemError = result.errors.find(
|
|
(entry) => entry.sessionId === id,
|
|
);
|
|
if (itemError) throw new Error(itemError.error);
|
|
}
|
|
: scope.kind === 'primary'
|
|
? isArchived
|
|
? deleteArchivedSession
|
|
: deleteSession
|
|
: undefined;
|
|
setDeleteCandidate(null);
|
|
if (!removeSession) return;
|
|
if (busySessionIdsRef.current.has(sessionIdentity)) return;
|
|
setSessionBusy(sessionId, true, deleteCandidate.workspaceCwd);
|
|
removeSession(sessionId)
|
|
.then(() => {
|
|
// A hard delete unlinks the transcript from BOTH the active and
|
|
// archived directories, so resync both lists regardless of origin.
|
|
void reload();
|
|
void reloadArchived();
|
|
bumpWorkspaceReload();
|
|
})
|
|
.catch((err: unknown) => onError(err, t('sidebar.deleteFailed')))
|
|
.finally(() => {
|
|
setSessionBusy(sessionId, false, deleteCandidate.workspaceCwd);
|
|
});
|
|
}, [
|
|
bumpWorkspaceReload,
|
|
canDeleteSession,
|
|
deleteArchivedSession,
|
|
deleteCandidate,
|
|
deleteSession,
|
|
getIdentityForSession,
|
|
onError,
|
|
reload,
|
|
reloadArchived,
|
|
resolveSessionWorkspaceScope,
|
|
setSessionBusy,
|
|
t,
|
|
workspace.client,
|
|
]);
|
|
|
|
const handleRenameFromMenu = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
if (!isCurrentSession(session)) return;
|
|
startRename(session);
|
|
},
|
|
[isCurrentSession, startRename],
|
|
);
|
|
|
|
const handleCreateGroup = useCallback(() => {
|
|
if (!canOrganizeWorkspace()) return;
|
|
setGroupMenu(null);
|
|
setGroupName('');
|
|
setGroupColor(getDefaultGroupColor(colorOptions));
|
|
setLastValidCustomGroupColor(DEFAULT_CUSTOM_GROUP_COLOR);
|
|
setGroupEditor({ mode: 'create' });
|
|
}, [canOrganizeWorkspace, colorOptions]);
|
|
|
|
const handleCreateWorkspaceGroup = useCallback(
|
|
(workspaceCwd: string) => {
|
|
if (!canOrganizeWorkspace(workspaceCwd)) return;
|
|
void (async () => {
|
|
try {
|
|
const catalog = await workspace.client
|
|
.workspaceByCwd(workspaceCwd)
|
|
.listSessionGroups();
|
|
setGroupMenu(null);
|
|
setGroupName('');
|
|
setGroupColor(getDefaultGroupColor(catalog.colorOptions));
|
|
setLastValidCustomGroupColor(DEFAULT_CUSTOM_GROUP_COLOR);
|
|
setGroupEditor({ mode: 'create', workspaceCwd });
|
|
} catch (err) {
|
|
onError(err, t('sidebar.groupsLoadFailed'));
|
|
}
|
|
})();
|
|
},
|
|
[canOrganizeWorkspace, onError, t, workspace.client],
|
|
);
|
|
|
|
const handleCreateGroupForSession = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
if (!canOrganizeSession(session, 'group')) return;
|
|
setGroupMenu(null);
|
|
setGroupName('');
|
|
setGroupColor(getDefaultGroupColor(colorOptions));
|
|
setLastValidCustomGroupColor(DEFAULT_CUSTOM_GROUP_COLOR);
|
|
setGroupEditor({
|
|
mode: 'create',
|
|
targetSession: session,
|
|
workspaceCwd: session.workspaceCwd,
|
|
});
|
|
},
|
|
[canOrganizeSession, colorOptions],
|
|
);
|
|
|
|
const handleRenameGroup = useCallback(
|
|
(group: DaemonSessionGroup, workspaceCwd?: string) => {
|
|
if (!canOrganizeWorkspace(workspaceCwd)) return;
|
|
setGroupName(group.name);
|
|
setGroupColor(group.color);
|
|
setLastValidCustomGroupColor(
|
|
normalizeHexColorInput(group.color) ?? DEFAULT_CUSTOM_GROUP_COLOR,
|
|
);
|
|
setGroupEditor({ mode: 'edit', group, workspaceCwd });
|
|
},
|
|
[canOrganizeWorkspace],
|
|
);
|
|
|
|
const closeGroupEditor = useCallback(() => {
|
|
if (groupBusy) return;
|
|
setGroupEditor(null);
|
|
setGroupName('');
|
|
setGroupColor(getDefaultGroupColor(colorOptions));
|
|
setLastValidCustomGroupColor(DEFAULT_CUSTOM_GROUP_COLOR);
|
|
}, [colorOptions, groupBusy]);
|
|
|
|
const saveGroupEditor = useCallback(() => {
|
|
if (!groupEditor) return;
|
|
if (
|
|
!canOrganizeWorkspace(groupEditor.workspaceCwd) ||
|
|
(groupEditor.targetSession &&
|
|
!canOrganizeSession(groupEditor.targetSession, 'group'))
|
|
) {
|
|
closeGroupEditor();
|
|
return;
|
|
}
|
|
const name = groupName.trim();
|
|
const color = normalizeGroupColorInput(
|
|
groupColor,
|
|
colorOptions.length > 0 ? colorOptions : SESSION_GROUP_COLORS,
|
|
);
|
|
if (!name || !color) return;
|
|
void (async () => {
|
|
setGroupBusy(true);
|
|
const reportCreatedGroupAssignmentFailure = (error: unknown) => {
|
|
setGroupEditor(null);
|
|
setGroupName('');
|
|
if (groupEditor.workspaceCwd) {
|
|
bumpWorkspaceReload();
|
|
} else {
|
|
void reloadGroups().catch(() => undefined);
|
|
}
|
|
onError(error, t('sidebar.groupAssignFailedAfterCreate'));
|
|
};
|
|
try {
|
|
const scope = resolveWorkspaceScope(groupEditor.workspaceCwd);
|
|
const groupActions =
|
|
scope.kind === 'primary'
|
|
? workspaceActions
|
|
: scope.kind === 'locked' || scope.kind === 'restricted'
|
|
? workspace.client.workspaceByCwd(scope.cwd)
|
|
: undefined;
|
|
if (!groupActions) return;
|
|
const group =
|
|
groupEditor.mode === 'create'
|
|
? await groupActions.createSessionGroup({
|
|
name,
|
|
color,
|
|
})
|
|
: await groupActions.updateSessionGroup(groupEditor.group!.id, {
|
|
name,
|
|
color,
|
|
});
|
|
if (groupEditor.mode === 'create') {
|
|
if (groupEditor.targetSession) {
|
|
try {
|
|
const livePolicy = groupAssignmentPolicyRef.current;
|
|
const targetScope = livePolicy?.resolveWorkspaceScope(
|
|
groupEditor.targetSession.workspaceCwd,
|
|
);
|
|
if (
|
|
!livePolicy ||
|
|
!targetScope ||
|
|
!livePolicy.canOrganizeSession(
|
|
groupEditor.targetSession,
|
|
'group',
|
|
) ||
|
|
targetScope.kind !== scope.kind ||
|
|
targetScope.cwd !== scope.cwd
|
|
) {
|
|
reportCreatedGroupAssignmentFailure(
|
|
new Error(t('sidebar.groupAssignFailedAfterCreate')),
|
|
);
|
|
return;
|
|
}
|
|
const targetActions = livePolicy.getSessionWorkspaceActions(
|
|
groupEditor.targetSession,
|
|
);
|
|
if (!targetActions) {
|
|
reportCreatedGroupAssignmentFailure(
|
|
new Error(t('sidebar.groupAssignFailedAfterCreate')),
|
|
);
|
|
return;
|
|
}
|
|
await targetActions.updateSessionOrganization(
|
|
groupEditor.targetSession.sessionId,
|
|
// Assigning a named group clears any color tag (single choice
|
|
// in the UI), matching assignSessionGroup.
|
|
{ groupId: group.id, color: null },
|
|
);
|
|
if (!groupEditor.workspaceCwd) {
|
|
void reload().catch(() => undefined);
|
|
}
|
|
bumpWorkspaceReload();
|
|
} catch (err) {
|
|
reportCreatedGroupAssignmentFailure(err);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
setGroupEditor(null);
|
|
setGroupName('');
|
|
if (groupEditor.workspaceCwd) {
|
|
bumpWorkspaceReload();
|
|
} else {
|
|
void reloadGroups().catch(() => undefined);
|
|
}
|
|
} catch (err) {
|
|
onError(
|
|
err,
|
|
groupEditor.mode === 'create'
|
|
? t('sidebar.groupCreateFailed')
|
|
: t('sidebar.groupUpdateFailed'),
|
|
);
|
|
} finally {
|
|
setGroupBusy(false);
|
|
}
|
|
})();
|
|
}, [
|
|
bumpWorkspaceReload,
|
|
colorOptions,
|
|
groupColor,
|
|
groupEditor,
|
|
groupName,
|
|
canOrganizeSession,
|
|
canOrganizeWorkspace,
|
|
closeGroupEditor,
|
|
onError,
|
|
reload,
|
|
reloadGroups,
|
|
resolveWorkspaceScope,
|
|
t,
|
|
workspaceActions,
|
|
workspace.client,
|
|
]);
|
|
|
|
const handleDeleteGroup = useCallback(
|
|
(group: DaemonSessionGroup, workspaceCwd?: string) => {
|
|
if (!canOrganizeWorkspace(workspaceCwd)) return;
|
|
setDeleteGroupCandidate({ group, workspaceCwd });
|
|
},
|
|
[canOrganizeWorkspace],
|
|
);
|
|
|
|
const confirmDeleteGroup = useCallback(() => {
|
|
if (!deleteGroupCandidate) return;
|
|
if (!canOrganizeWorkspace(deleteGroupCandidate.workspaceCwd)) {
|
|
setDeleteGroupCandidate(null);
|
|
return;
|
|
}
|
|
setGroupBusy(true);
|
|
const scope = resolveWorkspaceScope(deleteGroupCandidate.workspaceCwd);
|
|
const groupActions =
|
|
scope.kind === 'primary'
|
|
? workspaceActions
|
|
: scope.kind === 'locked' || scope.kind === 'restricted'
|
|
? workspace.client.workspaceByCwd(scope.cwd)
|
|
: undefined;
|
|
if (!groupActions) {
|
|
setGroupBusy(false);
|
|
return;
|
|
}
|
|
groupActions
|
|
.deleteSessionGroup(deleteGroupCandidate.group.id)
|
|
.then(() => {
|
|
setDeleteGroupCandidate(null);
|
|
if (deleteGroupCandidate.workspaceCwd) bumpWorkspaceReload();
|
|
else void reload().catch(() => undefined);
|
|
})
|
|
.catch((err: unknown) => onError(err, t('sidebar.groupDeleteFailed')))
|
|
.then(() =>
|
|
deleteGroupCandidate.workspaceCwd
|
|
? undefined
|
|
: reloadGroups().catch(() => undefined),
|
|
)
|
|
.finally(() => setGroupBusy(false));
|
|
}, [
|
|
deleteGroupCandidate,
|
|
canOrganizeWorkspace,
|
|
onError,
|
|
reload,
|
|
reloadGroups,
|
|
t,
|
|
bumpWorkspaceReload,
|
|
resolveWorkspaceScope,
|
|
workspace.client,
|
|
workspaceActions,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (deleteCandidate && !canDeleteSession(deleteCandidate)) {
|
|
setDeleteCandidate(null);
|
|
}
|
|
}, [canDeleteSession, deleteCandidate]);
|
|
|
|
useEffect(() => {
|
|
if (groupMenu && !canOrganizeSession(groupMenu.session, 'group')) {
|
|
setGroupMenu(null);
|
|
}
|
|
}, [canOrganizeSession, groupMenu]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
groupEditor &&
|
|
(!canOrganizeWorkspace(groupEditor.workspaceCwd) ||
|
|
(groupEditor.targetSession &&
|
|
!canOrganizeSession(groupEditor.targetSession, 'group')))
|
|
) {
|
|
setGroupEditor(null);
|
|
setGroupName('');
|
|
}
|
|
}, [canOrganizeSession, canOrganizeWorkspace, groupEditor]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
deleteGroupCandidate &&
|
|
!canOrganizeWorkspace(deleteGroupCandidate.workspaceCwd)
|
|
) {
|
|
setDeleteGroupCandidate(null);
|
|
}
|
|
}, [canOrganizeWorkspace, deleteGroupCandidate]);
|
|
|
|
const handleTogglePin = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
const sessionId = session.sessionId;
|
|
const sessionIdentity = getIdentityForSession(session);
|
|
if (
|
|
!canOrganizeSession(session, 'pin') ||
|
|
busySessionIdsRef.current.has(sessionIdentity)
|
|
) {
|
|
return;
|
|
}
|
|
setSessionBusy(sessionId, true, session.workspaceCwd);
|
|
const sessionActions = getSessionWorkspaceActions(session);
|
|
if (!sessionActions) {
|
|
setSessionBusy(sessionId, false, session.workspaceCwd);
|
|
return;
|
|
}
|
|
sessionActions
|
|
.updateSessionOrganization(sessionId, {
|
|
isPinned: !session.isPinned,
|
|
})
|
|
.then(() => {
|
|
void reload().catch(() => undefined);
|
|
void reloadPinnedSessions().catch(() => undefined);
|
|
bumpWorkspaceReload();
|
|
})
|
|
.catch((err: unknown) => onError(err, t('sidebar.organizationFailed')))
|
|
.finally(() => {
|
|
setSessionBusy(sessionId, false, session.workspaceCwd);
|
|
});
|
|
},
|
|
[
|
|
bumpWorkspaceReload,
|
|
getIdentityForSession,
|
|
getSessionWorkspaceActions,
|
|
onError,
|
|
canOrganizeSession,
|
|
reload,
|
|
reloadPinnedSessions,
|
|
setSessionBusy,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const handleArchive = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
const sessionId = session.sessionId;
|
|
const sessionIdentity = getIdentityForSession(session);
|
|
// The daemon force-ends a live turn on archive; keep the current
|
|
// session off-limits, mirroring the delete guard.
|
|
if (!canArchiveSession(session)) return;
|
|
if (busySessionIdsRef.current.has(sessionIdentity)) return;
|
|
setSessionBusy(sessionId, true, session.workspaceCwd);
|
|
void (async () => {
|
|
try {
|
|
const scope = resolveSessionWorkspaceScope(session);
|
|
if (scope.kind === 'locked' || scope.kind === 'restricted') {
|
|
const result = await workspace.client
|
|
.workspaceByCwd(scope.cwd)
|
|
.archiveSessionsData([sessionId]);
|
|
const itemError = result.errors.find(
|
|
(entry) => entry.sessionId === sessionId,
|
|
);
|
|
if (itemError) {
|
|
onError(new Error(itemError.error), t('sidebar.archiveFailed'));
|
|
}
|
|
} else if (scope.kind === 'primary') {
|
|
await archiveSession(sessionId);
|
|
} else {
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
onError(err, t('sidebar.archiveFailed'));
|
|
} finally {
|
|
void reload().catch(() => undefined);
|
|
void reloadArchived().catch(() => undefined);
|
|
bumpWorkspaceReload();
|
|
setSessionBusy(sessionId, false, session.workspaceCwd);
|
|
}
|
|
})();
|
|
},
|
|
[
|
|
archiveSession,
|
|
bumpWorkspaceReload,
|
|
canArchiveSession,
|
|
getIdentityForSession,
|
|
onError,
|
|
reload,
|
|
reloadArchived,
|
|
setSessionBusy,
|
|
t,
|
|
resolveSessionWorkspaceScope,
|
|
workspace.client,
|
|
],
|
|
);
|
|
|
|
const handleUnarchive = useCallback(
|
|
(session: DaemonSessionSummary) => {
|
|
const sessionId = session.sessionId;
|
|
const sessionIdentity = getIdentityForSession(session);
|
|
if (!canUnarchiveSession(session)) return;
|
|
if (busySessionIdsRef.current.has(sessionIdentity)) return;
|
|
setSessionBusy(sessionId, true, session.workspaceCwd);
|
|
void (async () => {
|
|
try {
|
|
const scope = resolveSessionWorkspaceScope(session);
|
|
if (scope.kind === 'locked' || scope.kind === 'restricted') {
|
|
const result = await workspace.client
|
|
.workspaceByCwd(scope.cwd)
|
|
.unarchiveSessionsData([sessionId]);
|
|
const itemError = result.errors.find(
|
|
(entry) => entry.sessionId === sessionId,
|
|
);
|
|
if (itemError) {
|
|
onError(new Error(itemError.error), t('sidebar.unarchiveFailed'));
|
|
}
|
|
} else if (scope.kind === 'primary') {
|
|
await unarchiveSession(sessionId);
|
|
} else {
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
onError(err, t('sidebar.unarchiveFailed'));
|
|
} finally {
|
|
void reload().catch(() => undefined);
|
|
void reloadArchived().catch(() => undefined);
|
|
bumpWorkspaceReload();
|
|
setSessionBusy(sessionId, false, session.workspaceCwd);
|
|
}
|
|
})();
|
|
},
|
|
[
|
|
bumpWorkspaceReload,
|
|
canUnarchiveSession,
|
|
getIdentityForSession,
|
|
onError,
|
|
reload,
|
|
reloadArchived,
|
|
setSessionBusy,
|
|
t,
|
|
unarchiveSession,
|
|
resolveSessionWorkspaceScope,
|
|
workspace.client,
|
|
],
|
|
);
|
|
|
|
const openGroupMenuFromAnchor = useCallback(
|
|
async (anchorEl: HTMLElement, session: DaemonSessionSummary) => {
|
|
if (!canOrganizeSession(session, 'group')) return;
|
|
let groupCount = 0;
|
|
try {
|
|
const sessionActions = getSessionWorkspaceActions(session);
|
|
if (!sessionActions) return;
|
|
const catalog = await sessionActions.listSessionGroups();
|
|
if (!canOrganizeSession(session, 'group')) return;
|
|
setMenuGroups(catalog.groups);
|
|
setColorOptions(catalog.colorOptions);
|
|
groupCount = catalog.groups.length;
|
|
} catch (err) {
|
|
onError(err, t('sidebar.groupsLoadFailed'));
|
|
return;
|
|
}
|
|
if (!anchorEl.isConnected) return;
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
const viewportWidth =
|
|
typeof window === 'undefined'
|
|
? rect.right + GROUP_MENU_WIDTH
|
|
: window.innerWidth;
|
|
const viewportHeight =
|
|
typeof window === 'undefined' ? rect.top + 320 : window.innerHeight;
|
|
const estimatedHeight = Math.min(
|
|
320,
|
|
34 * (groupCount + SESSION_GROUP_COLORS.length + 2) + 25,
|
|
);
|
|
const left =
|
|
rect.right + GROUP_MENU_MARGIN + GROUP_MENU_WIDTH <= viewportWidth
|
|
? rect.right + GROUP_MENU_MARGIN
|
|
: Math.max(
|
|
GROUP_MENU_MARGIN,
|
|
rect.left - GROUP_MENU_WIDTH - GROUP_MENU_MARGIN,
|
|
);
|
|
const top = Math.max(
|
|
GROUP_MENU_MARGIN,
|
|
Math.min(
|
|
rect.top,
|
|
viewportHeight - estimatedHeight - GROUP_MENU_MARGIN,
|
|
),
|
|
);
|
|
setGroupMenu({
|
|
session,
|
|
top,
|
|
left,
|
|
});
|
|
},
|
|
[canOrganizeSession, getSessionWorkspaceActions, onError, t],
|
|
);
|
|
|
|
const assignSessionGroup = useCallback(
|
|
(session: DaemonSessionSummary, groupId: string | null) => {
|
|
const sessionId = session.sessionId;
|
|
const sessionIdentity = getIdentityForSession(session);
|
|
if (
|
|
!canOrganizeSession(session, 'group') ||
|
|
busySessionIdsRef.current.has(sessionIdentity)
|
|
) {
|
|
return;
|
|
}
|
|
setGroupMenu(null);
|
|
setSessionBusy(sessionId, true, session.workspaceCwd);
|
|
const sessionActions = getSessionWorkspaceActions(session);
|
|
if (!sessionActions) {
|
|
setSessionBusy(sessionId, false, session.workspaceCwd);
|
|
return;
|
|
}
|
|
sessionActions
|
|
// Group and color are a single choice in the UI: assigning a named
|
|
// group (or "Ungrouped", groupId=null) clears any color tag.
|
|
.updateSessionOrganization(sessionId, { groupId, color: null })
|
|
.then(() => {
|
|
void reload().catch(() => undefined);
|
|
bumpWorkspaceReload();
|
|
})
|
|
.catch((err: unknown) => onError(err, t('sidebar.organizationFailed')))
|
|
.finally(() => {
|
|
setSessionBusy(sessionId, false, session.workspaceCwd);
|
|
});
|
|
},
|
|
[
|
|
bumpWorkspaceReload,
|
|
getIdentityForSession,
|
|
getSessionWorkspaceActions,
|
|
onError,
|
|
canOrganizeSession,
|
|
reload,
|
|
setSessionBusy,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const assignSessionColor = useCallback(
|
|
(
|
|
session: DaemonSessionSummary,
|
|
color: DaemonSessionGroupPresetColor | null,
|
|
) => {
|
|
const sessionId = session.sessionId;
|
|
const sessionIdentity = getIdentityForSession(session);
|
|
if (
|
|
!canOrganizeSession(session, 'group') ||
|
|
busySessionIdsRef.current.has(sessionIdentity)
|
|
) {
|
|
return;
|
|
}
|
|
setGroupMenu(null);
|
|
setSessionBusy(sessionId, true, session.workspaceCwd);
|
|
const sessionActions = getSessionWorkspaceActions(session);
|
|
if (!sessionActions) {
|
|
setSessionBusy(sessionId, false, session.workspaceCwd);
|
|
return;
|
|
}
|
|
sessionActions
|
|
// Picking a color clears any named-group assignment (single choice).
|
|
.updateSessionOrganization(sessionId, { color, groupId: null })
|
|
.then(() => {
|
|
void reload().catch(() => undefined);
|
|
bumpWorkspaceReload();
|
|
})
|
|
.catch((err: unknown) => onError(err, t('sidebar.organizationFailed')))
|
|
.finally(() => {
|
|
setSessionBusy(sessionId, false, session.workspaceCwd);
|
|
});
|
|
},
|
|
[
|
|
bumpWorkspaceReload,
|
|
getIdentityForSession,
|
|
getSessionWorkspaceActions,
|
|
onError,
|
|
canOrganizeSession,
|
|
reload,
|
|
setSessionBusy,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const filteredSessions = useMemo(() => {
|
|
const query = searchQuery.trim().toLowerCase();
|
|
const unpinnedSessions = sessions.filter((session) => !session.isPinned);
|
|
const nextSessions = query
|
|
? unpinnedSessions.filter((session) => {
|
|
const label = getSessionLabel(session).toLowerCase();
|
|
return (
|
|
label.includes(query) ||
|
|
session.sessionId.toLowerCase().includes(query)
|
|
);
|
|
})
|
|
: unpinnedSessions.slice();
|
|
if (organizationEnabled) {
|
|
return nextSessions;
|
|
}
|
|
const createdTimeById = new Map(
|
|
nextSessions.map((session) => [
|
|
session.sessionId,
|
|
getSessionCreatedTime(session),
|
|
]),
|
|
);
|
|
return nextSessions.sort(
|
|
(a, b) =>
|
|
(createdTimeById.get(b.sessionId) ?? 0) -
|
|
(createdTimeById.get(a.sessionId) ?? 0),
|
|
);
|
|
}, [organizationEnabled, searchQuery, sessions]);
|
|
|
|
const sessionSections = useMemo<SessionSection[]>(() => {
|
|
if (!organizationEnabled) return [];
|
|
const searching = searchQuery.trim().length > 0;
|
|
const validGroupIds = new Set(groups.map((group) => group.id));
|
|
const sessionsByColor = new Map<
|
|
DaemonSessionGroupPresetColor,
|
|
DaemonSessionSummary[]
|
|
>();
|
|
const sessionsByGroupId = new Map<string, DaemonSessionSummary[]>();
|
|
for (const group of groups) {
|
|
sessionsByGroupId.set(group.id, []);
|
|
}
|
|
const recentSessions: DaemonSessionSummary[] = [];
|
|
for (const session of filteredSessions) {
|
|
// Color takes precedence: the picker keeps color and group mutually
|
|
// exclusive, but stay defensive if a store somehow carries both.
|
|
if (session.color && SESSION_GROUP_COLORS.includes(session.color)) {
|
|
const bucket = sessionsByColor.get(session.color) ?? [];
|
|
bucket.push(session);
|
|
sessionsByColor.set(session.color, bucket);
|
|
continue;
|
|
}
|
|
const groupSessions =
|
|
session.groupId && validGroupIds.has(session.groupId)
|
|
? sessionsByGroupId.get(session.groupId)
|
|
: undefined;
|
|
if (groupSessions) {
|
|
groupSessions.push(session);
|
|
} else {
|
|
recentSessions.push(session);
|
|
}
|
|
}
|
|
const sections: SessionSection[] = [];
|
|
// Color buckets first, in palette order; only render non-empty ones so the
|
|
// sidebar never shows six empty color headers.
|
|
for (const color of SESSION_GROUP_COLORS) {
|
|
const colorSessions = sessionsByColor.get(color);
|
|
if (!colorSessions || colorSessions.length === 0) continue;
|
|
sections.push({
|
|
id: `color:${color}`,
|
|
kind: 'color',
|
|
label: t(`sidebar.groupColor.${color}`),
|
|
countLabel: String(colorSessions.length),
|
|
color,
|
|
sessions: colorSessions,
|
|
});
|
|
}
|
|
// Named groups next (kept visible even when empty, unless searching).
|
|
for (const group of groups) {
|
|
const groupSessions = sessionsByGroupId.get(group.id) ?? [];
|
|
if (searching && groupSessions.length === 0) continue;
|
|
sections.push({
|
|
id: `group:${group.id}`,
|
|
kind: 'group',
|
|
label: group.name,
|
|
countLabel: String(groupSessions.length),
|
|
color: group.color,
|
|
group,
|
|
sessions: groupSessions,
|
|
});
|
|
}
|
|
if (recentSessions.length > 0 && sections.length > 0) {
|
|
sections.push({
|
|
id: RECENT_SESSION_SECTION_ID,
|
|
kind: 'recent',
|
|
label: t('sidebar.groupUngrouped'),
|
|
countLabel: String(recentSessions.length),
|
|
sessions: recentSessions,
|
|
});
|
|
}
|
|
return sections;
|
|
}, [filteredSessions, groups, organizationEnabled, searchQuery, t]);
|
|
|
|
useEffect(() => {
|
|
if (!organizationEnabled) return;
|
|
// Wait for both independent catalog sources. Flipping the latch on the
|
|
// first non-empty derived sections would treat later initial recent/color
|
|
// ids as brand-new and auto-collapse them; leaving the latch set when the
|
|
// first ready catalog is empty would leave the first real section expanded.
|
|
if (!groupsCatalogReady || !sessionsCatalogReady) return;
|
|
|
|
const unseenIds = sessionSections
|
|
.map((section) => section.id)
|
|
.filter((id) => !knownSessionSectionIdsRef.current.has(id));
|
|
const isInitialCatalog = awaitingInitialSessionCatalogRef.current;
|
|
if (isInitialCatalog) {
|
|
awaitingInitialSessionCatalogRef.current = false;
|
|
for (const id of unseenIds) knownSessionSectionIdsRef.current.add(id);
|
|
return;
|
|
}
|
|
if (unseenIds.length === 0) return;
|
|
for (const id of unseenIds) knownSessionSectionIdsRef.current.add(id);
|
|
// Brand-new sections that appear mid-session still start collapsed.
|
|
setCollapsedSessionSectionIds((current) => {
|
|
const next = new Set(current);
|
|
for (const id of unseenIds) next.add(id);
|
|
return next;
|
|
});
|
|
}, [
|
|
groupsCatalogReady,
|
|
organizationEnabled,
|
|
sessionSections,
|
|
sessionsCatalogReady,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
replaceOwnedCollapsedSessionSectionIds(
|
|
collapsedSessionSectionIds,
|
|
isPrimaryCollapsedSectionId,
|
|
);
|
|
}, [collapsedSessionSectionIds]);
|
|
|
|
const toggleSessionSection = useCallback((sectionId: string) => {
|
|
setCollapsedSessionSectionIds((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(sectionId)) {
|
|
next.delete(sectionId);
|
|
} else {
|
|
next.add(sectionId);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const getSessionDetailsCollisionBoundary = useCallback(
|
|
() => resolveSessionDetailsCollisionBoundary(sidebarRef.current),
|
|
[],
|
|
);
|
|
|
|
const handleResizePointerDown = useCallback(
|
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (collapsed) return;
|
|
event.preventDefault();
|
|
resizeTeardownRef.current?.(true);
|
|
setIsResizing(true);
|
|
const startX = event.clientX;
|
|
const startWidth = sidebarWidth;
|
|
const previousCursor = document.body.style.cursor;
|
|
const previousUserSelect = document.body.style.userSelect;
|
|
let collapsedByDrag = false;
|
|
let teardown: (updateState: boolean) => void = () => undefined;
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
try {
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
} catch {
|
|
// Pointer capture is best-effort; window listeners still handle drag.
|
|
}
|
|
function getRawWidth(clientX: number) {
|
|
return startWidth + clientX - startX;
|
|
}
|
|
function restoreExpandedWidth() {
|
|
const restoredWidth = clampSidebarWidth(startWidth);
|
|
setSidebarWidth(restoredWidth);
|
|
writeSidebarWidth(restoredWidth);
|
|
}
|
|
function collapseFromDrag() {
|
|
if (collapsedByDrag) return;
|
|
collapsedByDrag = true;
|
|
restoreExpandedWidth();
|
|
teardown(true);
|
|
onCollapsedChange(true);
|
|
}
|
|
function handlePointerMove(moveEvent: PointerEvent) {
|
|
const rawWidth = getRawWidth(moveEvent.clientX);
|
|
if (rawWidth <= SIDEBAR_COLLAPSE_DRAG_WIDTH) {
|
|
collapseFromDrag();
|
|
return;
|
|
}
|
|
setSidebarWidth(clampSidebarVisualWidth(rawWidth));
|
|
}
|
|
teardown = function resizeTeardown(updateState: boolean) {
|
|
document.body.style.cursor = previousCursor;
|
|
document.body.style.userSelect = previousUserSelect;
|
|
window.removeEventListener('pointermove', handlePointerMove);
|
|
window.removeEventListener('pointerup', handlePointerUp);
|
|
window.removeEventListener('pointercancel', handlePointerCancel);
|
|
resizeTeardownRef.current = null;
|
|
if (updateState) {
|
|
setIsResizing(false);
|
|
}
|
|
};
|
|
function handlePointerUp(upEvent: PointerEvent) {
|
|
const rawWidth = getRawWidth(upEvent.clientX);
|
|
if (rawWidth <= SIDEBAR_COLLAPSE_DRAG_WIDTH) {
|
|
collapseFromDrag();
|
|
return;
|
|
}
|
|
const nextWidth = clampSidebarWidth(rawWidth);
|
|
setSidebarWidth(nextWidth);
|
|
writeSidebarWidth(nextWidth);
|
|
teardown(true);
|
|
}
|
|
function handlePointerCancel() {
|
|
teardown(true);
|
|
}
|
|
resizeTeardownRef.current = teardown;
|
|
window.addEventListener('pointermove', handlePointerMove);
|
|
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
|
window.addEventListener('pointercancel', handlePointerCancel, {
|
|
once: true,
|
|
});
|
|
},
|
|
[collapsed, onCollapsedChange, sidebarWidth],
|
|
);
|
|
|
|
const deleteCandidateLabel = deleteCandidate
|
|
? getCompactSessionLabel(deleteCandidate)
|
|
: '';
|
|
const groupMenuSelectedColor =
|
|
groupMenu?.session.color &&
|
|
SESSION_GROUP_COLORS.includes(groupMenu.session.color)
|
|
? groupMenu.session.color
|
|
: null;
|
|
const groupMenuSelectedGroupId =
|
|
!groupMenuSelectedColor &&
|
|
groupMenu?.session.groupId &&
|
|
menuGroups.some((group) => group.id === groupMenu.session.groupId)
|
|
? groupMenu.session.groupId
|
|
: null;
|
|
const menuColorOptions =
|
|
colorOptions.length > 0 ? colorOptions : SESSION_GROUP_COLORS;
|
|
const groupMenuUngroupedSelected =
|
|
groupMenuSelectedGroupId === null && groupMenuSelectedColor === null;
|
|
const deleteGroupCandidateLabel = deleteGroupCandidate?.group.name ?? '';
|
|
const groupColorChoices =
|
|
colorOptions.length > 0
|
|
? colorOptions
|
|
: (['blue'] as DaemonSessionGroupPresetColor[]);
|
|
const normalizedGroupColor = normalizeGroupColorInput(
|
|
groupColor,
|
|
groupColorChoices,
|
|
);
|
|
const customGroupColor = !groupColorChoices.includes(
|
|
groupColor as DaemonSessionGroupPresetColor,
|
|
);
|
|
const canSaveGroup =
|
|
groupName.trim().length > 0 &&
|
|
normalizedGroupColor !== undefined &&
|
|
!groupBusy;
|
|
const groupEditorTitle =
|
|
groupEditor?.mode === 'create'
|
|
? t('sidebar.groupCreate')
|
|
: t('sidebar.groupRename');
|
|
|
|
const renderSessionRow = useCallback(
|
|
(
|
|
session: DaemonSessionSummary,
|
|
options: {
|
|
isArchived?: boolean;
|
|
// Keep unlocked secondary rows conservative while allowing a trusted
|
|
// locked workspace to use normal session controls.
|
|
readOnly?: boolean;
|
|
} = {},
|
|
) => {
|
|
const { isArchived = false } = options;
|
|
const readOnly = options.readOnly ?? isActiveSessionReadOnly(session);
|
|
const sessionIdentity = getIdentityForSession(session);
|
|
const label = getSessionLabel(session);
|
|
const stamp = session.updatedAt || session.createdAt;
|
|
const time = stamp ? formatRelativeTime(stamp, t) : '';
|
|
const busy = busySessionIds.has(sessionIdentity);
|
|
const exporting = exportingSessionIds.has(sessionIdentity);
|
|
const completedUnread =
|
|
!isCurrentSession(session) && completedUnreadIds.has(sessionIdentity);
|
|
if (isArchived) {
|
|
const archivedExportWorkspaceCwd =
|
|
getArchivedExportWorkspaceCwd(session);
|
|
const showArchivedDetails = sessionActionItems.has('details');
|
|
const showArchivedExport =
|
|
sessionActionItems.has('export') &&
|
|
Boolean(archivedExportWorkspaceCwd);
|
|
const showArchivedUnarchive = canUnarchiveSession(session);
|
|
const showArchivedDelete = canDeleteSession(session);
|
|
const hasArchivedActions =
|
|
showArchivedDetails ||
|
|
showArchivedExport ||
|
|
showArchivedUnarchive ||
|
|
showArchivedDelete;
|
|
return (
|
|
<div
|
|
key={sessionIdentity}
|
|
className={cx(
|
|
styles.sessionRow,
|
|
styles.archivedRow,
|
|
busy && styles.busySession,
|
|
)}
|
|
>
|
|
<span className={styles.sessionText} title={label}>
|
|
{label}
|
|
</span>
|
|
<div className={styles.sessionMetaSlot}>
|
|
<span className={styles.sessionTime}>{time}</span>
|
|
{hasArchivedActions && (
|
|
<div
|
|
className={styles.sessionActions}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|
>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className={styles.sessionActionButton}
|
|
type="button"
|
|
aria-label={t('sidebar.moreActions')}
|
|
title={t('sidebar.moreActions')}
|
|
>
|
|
<EllipsisVerticalIcon />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="w-auto min-w-40"
|
|
onPointerDownOutside={() => {
|
|
sessionMenuPointerDismissRef.current = true;
|
|
}}
|
|
onCloseAutoFocus={(event) => {
|
|
if (!sessionMenuPointerDismissRef.current) return;
|
|
sessionMenuPointerDismissRef.current = false;
|
|
event.preventDefault();
|
|
}}
|
|
>
|
|
<DropdownMenuGroup>
|
|
{showArchivedDetails && (
|
|
<SessionDetailsSubmenu
|
|
session={session}
|
|
label={label}
|
|
completedUnread={completedUnread}
|
|
onError={onError}
|
|
getCollisionBoundary={
|
|
getSessionDetailsCollisionBoundary
|
|
}
|
|
/>
|
|
)}
|
|
{showArchivedExport && (
|
|
<DropdownMenuItem
|
|
disabled={exporting}
|
|
onSelect={() => handleExportSession(session)}
|
|
>
|
|
<DownloadIcon />
|
|
{t('sidebar.export')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{showArchivedUnarchive && (
|
|
<DropdownMenuItem
|
|
onSelect={() => handleUnarchive(session)}
|
|
>
|
|
<ArchiveRestoreIcon />
|
|
{t('sidebar.unarchive')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{showArchivedDelete && (
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onSelect={() => handleDeleteSession(session)}
|
|
>
|
|
<Trash2Icon />
|
|
{t('sidebar.delete')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isCurrent = isCurrentSession(session);
|
|
const isEditing = isCurrent && editingSessionIdentity === sessionIdentity;
|
|
const needsUserInput =
|
|
!session.isWaitingForPermission && session.isWaitingForUserQuestion;
|
|
const attentionLabel = session.isWaitingForPermission
|
|
? t('sidebar.waitingForApproval')
|
|
: needsUserInput
|
|
? t('sidebar.userInputNeeded')
|
|
: null;
|
|
const mutableScope = !isActiveSessionReadOnly(session);
|
|
const showPin = canOrganizeSession(session, 'pin');
|
|
const showArchive =
|
|
sessionActionItems.has('archive') && canMutateSessionArchive(session);
|
|
const showRename = sessionActionItems.has('rename') && mutableScope;
|
|
const activeExportScope = getActiveExportScope(session);
|
|
const showExport =
|
|
sessionActionItems.has('export') && Boolean(activeExportScope);
|
|
const showDelete = canShowDeleteSession(session);
|
|
return (
|
|
<div
|
|
key={sessionIdentity}
|
|
className={cx(
|
|
styles.sessionRow,
|
|
isCurrent && styles.currentSession,
|
|
session.isPinned && styles.pinnedSession,
|
|
session.hasActivePrompt && styles.runningSession,
|
|
busy && styles.busySession,
|
|
)}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-current={isCurrent ? 'page' : undefined}
|
|
onClick={() =>
|
|
handleLoadSession(session.sessionId, session.workspaceCwd)
|
|
}
|
|
onDoubleClick={() => {
|
|
if (!collapsed && canRenameSession(session)) startRename(session);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
handleLoadSession(session.sessionId, session.workspaceCwd);
|
|
}
|
|
}}
|
|
>
|
|
{!collapsed && (
|
|
<>
|
|
<span className={styles.sessionStatusSlot}>
|
|
{completedUnread ? (
|
|
<span
|
|
className={styles.sessionStatusDot}
|
|
aria-hidden="true"
|
|
/>
|
|
) : null}
|
|
</span>
|
|
{isEditing && canRenameSession(session) ? (
|
|
<form
|
|
className={styles.renameForm}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
saveRename();
|
|
}}
|
|
>
|
|
<input
|
|
autoFocus
|
|
className={styles.renameInput}
|
|
value={editingName}
|
|
onChange={(event) => setEditingName(event.target.value)}
|
|
onBlur={cancelRename}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
cancelRename();
|
|
}
|
|
}}
|
|
/>
|
|
</form>
|
|
) : (
|
|
<>
|
|
<span className={styles.sessionText} title={label}>
|
|
{session.worktree && (
|
|
<GitForkIcon
|
|
size={11}
|
|
strokeWidth={1.5}
|
|
className={styles.sessionBadgeIcon}
|
|
aria-label={t('sidebar.newWorktreeTask')}
|
|
/>
|
|
)}
|
|
{session.branch && (
|
|
<GitBranchIcon
|
|
size={11}
|
|
strokeWidth={1.5}
|
|
className={styles.sessionBadgeIcon}
|
|
aria-label={session.branch.name}
|
|
/>
|
|
)}
|
|
{label}
|
|
</span>
|
|
<div className={styles.sessionMetaSlot}>
|
|
{attentionLabel && (
|
|
<span
|
|
className={cx(
|
|
styles.sessionAttention,
|
|
needsUserInput && styles.sessionAttentionUserInput,
|
|
)}
|
|
aria-label={attentionLabel}
|
|
>
|
|
{attentionLabel}
|
|
</span>
|
|
)}
|
|
{session.hasActivePrompt ? (
|
|
<span
|
|
className={styles.sessionLoading}
|
|
aria-label={t('sidebar.running')}
|
|
/>
|
|
) : !attentionLabel ? (
|
|
<span className={styles.sessionTime}>{time}</span>
|
|
) : null}
|
|
{readOnly && showArchive && (
|
|
<div
|
|
className={styles.sessionActions}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|
>
|
|
{inlineActionItems.has('archive') ? (
|
|
<button
|
|
className={styles.sessionActionButton}
|
|
type="button"
|
|
disabled={busy || isCurrent}
|
|
aria-label={t('sidebar.archive')}
|
|
title={
|
|
isCurrent
|
|
? t('sidebar.archiveCurrentDisabled')
|
|
: t('sidebar.archive')
|
|
}
|
|
onClick={() => handleArchive(session)}
|
|
>
|
|
<ArchiveIcon />
|
|
</button>
|
|
) : (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className={styles.sessionActionButton}
|
|
type="button"
|
|
aria-label={t('sidebar.moreActions')}
|
|
title={t('sidebar.moreActions')}
|
|
>
|
|
<EllipsisVerticalIcon />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="w-auto min-w-40"
|
|
onPointerDownOutside={() => {
|
|
sessionMenuPointerDismissRef.current = true;
|
|
}}
|
|
onCloseAutoFocus={(event) => {
|
|
if (!sessionMenuPointerDismissRef.current)
|
|
return;
|
|
sessionMenuPointerDismissRef.current = false;
|
|
event.preventDefault();
|
|
}}
|
|
>
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem
|
|
disabled={busy || isCurrent}
|
|
title={
|
|
isCurrent
|
|
? t('sidebar.archiveCurrentDisabled')
|
|
: undefined
|
|
}
|
|
onSelect={() => handleArchive(session)}
|
|
>
|
|
<ArchiveIcon />
|
|
{t('sidebar.archive')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
)}
|
|
{(!readOnly || showPin) && (
|
|
<div
|
|
className={styles.sessionActions}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|
>
|
|
{(() => {
|
|
const inlineActions: Array<{
|
|
key: WebShellSidebarSessionInlineActionItem;
|
|
icon?: ReactNode;
|
|
label: string;
|
|
disabled?: boolean;
|
|
title?: string;
|
|
active?: boolean;
|
|
destructive?: boolean;
|
|
visible: boolean;
|
|
onClick: () => void;
|
|
}> = [
|
|
{
|
|
key: 'pin',
|
|
icon: <PinIcon size={16} strokeWidth={1.2} />,
|
|
label: session.isPinned
|
|
? t('sidebar.unpin')
|
|
: t('sidebar.pin'),
|
|
disabled: busy,
|
|
active: session.isPinned,
|
|
visible: showPin && inlineActionItems.has('pin'),
|
|
onClick: () => handleTogglePin(session),
|
|
},
|
|
{
|
|
key: 'archive',
|
|
icon: <ArchiveIcon size={16} strokeWidth={1.2} />,
|
|
label: t('sidebar.archive'),
|
|
disabled: busy || isCurrent,
|
|
title: isCurrent
|
|
? t('sidebar.archiveCurrentDisabled')
|
|
: t('sidebar.archive'),
|
|
visible:
|
|
showArchive && inlineActionItems.has('archive'),
|
|
onClick: () => handleArchive(session),
|
|
},
|
|
{
|
|
key: 'rename',
|
|
icon: <PencilIcon size={16} strokeWidth={1.2} />,
|
|
label: t('sidebar.rename'),
|
|
disabled: !isCurrent,
|
|
title: !isCurrent
|
|
? t('sidebar.renameCurrentOnly')
|
|
: undefined,
|
|
visible:
|
|
showRename && inlineActionItems.has('rename'),
|
|
onClick: () => handleRenameFromMenu(session),
|
|
},
|
|
{
|
|
key: 'export',
|
|
icon: (
|
|
<DownloadIcon size={16} strokeWidth={1.2} />
|
|
),
|
|
label: t('sidebar.export'),
|
|
disabled: exporting,
|
|
visible:
|
|
showExport && inlineActionItems.has('export'),
|
|
onClick: () => handleExportSession(session),
|
|
},
|
|
{
|
|
key: 'delete',
|
|
icon: <Trash2Icon size={16} strokeWidth={1.2} />,
|
|
label: t('sidebar.delete'),
|
|
disabled: isCurrent,
|
|
destructive: true,
|
|
title: isCurrent
|
|
? t('sidebar.currentDeleteDisabled')
|
|
: undefined,
|
|
visible:
|
|
showDelete && inlineActionItems.has('delete'),
|
|
onClick: () => handleDeleteSession(session),
|
|
},
|
|
];
|
|
return inlineActions
|
|
.filter((a) => a.visible)
|
|
.map((action) => (
|
|
<button
|
|
key={action.key}
|
|
className={cx(
|
|
styles.sessionActionButton,
|
|
action.active &&
|
|
styles.activeSessionActionButton,
|
|
)}
|
|
type="button"
|
|
disabled={action.disabled}
|
|
aria-label={action.label}
|
|
title={action.title ?? action.label}
|
|
onClick={action.onClick}
|
|
style={
|
|
action.destructive && !action.disabled
|
|
? {
|
|
color: 'var(--destructive, #dc2626)',
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{action.icon ?? (
|
|
<span style={{ fontSize: 12 }}>
|
|
{action.label}
|
|
</span>
|
|
)}
|
|
</button>
|
|
));
|
|
})()}
|
|
{(showPin && !inlineActionItems.has('pin')) ||
|
|
(showArchive && !inlineActionItems.has('archive')) ||
|
|
sessionActionItems.has('details') ||
|
|
(showRename && !inlineActionItems.has('rename')) ||
|
|
canOrganizeSession(session, 'group') ||
|
|
(showExport && !inlineActionItems.has('export')) ||
|
|
(showDelete && !inlineActionItems.has('delete')) ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className={styles.sessionActionButton}
|
|
type="button"
|
|
aria-label={t('sidebar.moreActions')}
|
|
title={t('sidebar.moreActions')}
|
|
>
|
|
<EllipsisVerticalIcon />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="w-auto min-w-40"
|
|
onPointerDownOutside={() => {
|
|
sessionMenuPointerDismissRef.current = true;
|
|
}}
|
|
onCloseAutoFocus={(event) => {
|
|
if (!sessionMenuPointerDismissRef.current)
|
|
return;
|
|
sessionMenuPointerDismissRef.current = false;
|
|
event.preventDefault();
|
|
}}
|
|
>
|
|
<DropdownMenuGroup>
|
|
{showPin && !inlineActionItems.has('pin') && (
|
|
<DropdownMenuItem
|
|
disabled={busy}
|
|
onSelect={() => handleTogglePin(session)}
|
|
>
|
|
<PinIcon />
|
|
{session.isPinned
|
|
? t('sidebar.unpin')
|
|
: t('sidebar.pin')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{showArchive &&
|
|
!inlineActionItems.has('archive') && (
|
|
<DropdownMenuItem
|
|
disabled={busy || isCurrent}
|
|
title={
|
|
isCurrent
|
|
? t('sidebar.archiveCurrentDisabled')
|
|
: undefined
|
|
}
|
|
onSelect={() => handleArchive(session)}
|
|
>
|
|
<ArchiveIcon />
|
|
{t('sidebar.archive')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{sessionActionItems.has('details') && (
|
|
<SessionDetailsSubmenu
|
|
session={session}
|
|
label={label}
|
|
completedUnread={completedUnread}
|
|
onError={onError}
|
|
getCollisionBoundary={
|
|
getSessionDetailsCollisionBoundary
|
|
}
|
|
/>
|
|
)}
|
|
{showRename &&
|
|
!inlineActionItems.has('rename') && (
|
|
<DropdownMenuItem
|
|
disabled={!isCurrent}
|
|
title={
|
|
!isCurrent
|
|
? t('sidebar.renameCurrentOnly')
|
|
: undefined
|
|
}
|
|
onSelect={() =>
|
|
handleRenameFromMenu(session)
|
|
}
|
|
>
|
|
<PencilIcon />
|
|
{t('sidebar.rename')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{canOrganizeSession(session, 'group') && (
|
|
<DropdownMenuItem
|
|
disabled={busy}
|
|
onSelect={(event) =>
|
|
openGroupMenuFromAnchor(
|
|
event.currentTarget as HTMLElement,
|
|
session,
|
|
)
|
|
}
|
|
>
|
|
<FolderInputIcon />
|
|
{t('sidebar.sessionGroup')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{showExport &&
|
|
!inlineActionItems.has('export') && (
|
|
<DropdownMenuItem
|
|
disabled={exporting}
|
|
onSelect={() =>
|
|
handleExportSession(session)
|
|
}
|
|
>
|
|
<DownloadIcon />
|
|
{t('sidebar.export')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{showDelete &&
|
|
!inlineActionItems.has('delete') && (
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
disabled={isCurrent}
|
|
title={
|
|
isCurrent
|
|
? t('sidebar.currentDeleteDisabled')
|
|
: undefined
|
|
}
|
|
onSelect={() =>
|
|
handleDeleteSession(session)
|
|
}
|
|
>
|
|
<Trash2Icon />
|
|
{t('sidebar.delete')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
[
|
|
busySessionIds,
|
|
canDeleteSession,
|
|
canShowDeleteSession,
|
|
canOrganizeSession,
|
|
canRenameSession,
|
|
canUnarchiveSession,
|
|
canMutateSessionArchive,
|
|
cancelRename,
|
|
collapsed,
|
|
completedUnreadIds,
|
|
editingName,
|
|
editingSessionIdentity,
|
|
exportingSessionIds,
|
|
getArchivedExportWorkspaceCwd,
|
|
getActiveExportScope,
|
|
getIdentityForSession,
|
|
getSessionDetailsCollisionBoundary,
|
|
onError,
|
|
handleArchive,
|
|
handleDeleteSession,
|
|
handleExportSession,
|
|
handleLoadSession,
|
|
handleRenameFromMenu,
|
|
handleTogglePin,
|
|
handleUnarchive,
|
|
isCurrentSession,
|
|
openGroupMenuFromAnchor,
|
|
saveRename,
|
|
sessionActionItems,
|
|
inlineActionItems,
|
|
isActiveSessionReadOnly,
|
|
startRename,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const body = useMemo(() => {
|
|
if (loading && sessions.length === 0) {
|
|
return (
|
|
<div className={styles.notice}>{t('sidebar.loadingSessions')}</div>
|
|
);
|
|
}
|
|
if (error && sessions.length === 0) {
|
|
return (
|
|
<button className={styles.retry} type="button" onClick={reload}>
|
|
{t('sidebar.loadFailed')}
|
|
</button>
|
|
);
|
|
}
|
|
if (
|
|
filteredSessions.length === 0 &&
|
|
(searchQuery.trim() ||
|
|
!organizationEnabled ||
|
|
sessionSections.length === 0)
|
|
) {
|
|
return <div className={styles.notice}>{t('sidebar.searchEmpty')}</div>;
|
|
}
|
|
if (!organizationEnabled) {
|
|
return filteredSessions.map((session) => renderSessionRow(session));
|
|
}
|
|
if (sessionSections.length === 0) {
|
|
return filteredSessions.map((session) => renderSessionRow(session));
|
|
}
|
|
|
|
return sessionSections.map((section) => {
|
|
const expanded = !collapsedSessionSectionIds.has(section.id);
|
|
const group = section.group;
|
|
return (
|
|
<SessionGroupSection
|
|
key={section.id}
|
|
id={section.id}
|
|
label={section.label}
|
|
count={section.sessions.length}
|
|
color={section.color}
|
|
expanded={expanded}
|
|
onToggle={() => toggleSessionSection(section.id)}
|
|
onRename={
|
|
section.kind === 'group' && group && canOrganizeWorkspace()
|
|
? () => handleRenameGroup(group)
|
|
: undefined
|
|
}
|
|
onDelete={
|
|
section.kind === 'group' && group && canOrganizeWorkspace()
|
|
? () => handleDeleteGroup(group)
|
|
: undefined
|
|
}
|
|
renameLabel={t('sidebar.groupRename')}
|
|
deleteLabel={t('sidebar.groupDelete')}
|
|
actionsDisabled={groupBusy}
|
|
>
|
|
{section.sessions.map((session) => renderSessionRow(session))}
|
|
</SessionGroupSection>
|
|
);
|
|
});
|
|
}, [
|
|
collapsedSessionSectionIds,
|
|
canOrganizeWorkspace,
|
|
error,
|
|
filteredSessions,
|
|
groupBusy,
|
|
handleDeleteGroup,
|
|
handleRenameGroup,
|
|
loading,
|
|
organizationEnabled,
|
|
reload,
|
|
renderSessionRow,
|
|
searchQuery,
|
|
sessionSections,
|
|
sessions.length,
|
|
t,
|
|
toggleSessionSection,
|
|
]);
|
|
|
|
const archivedSection = useMemo(() => {
|
|
if (!sessionArchiveEnabled || collapsed || searchQuery.trim()) return null;
|
|
|
|
const header = (
|
|
<button
|
|
type="button"
|
|
className={styles.archivedHeader}
|
|
aria-expanded={archivedExpanded}
|
|
onClick={() => setArchivedExpanded((expanded) => !expanded)}
|
|
>
|
|
<span className={styles.archivedTitle} style={{ flex: '0 1 auto' }}>
|
|
{t('sidebar.archivedTitle')}
|
|
</span>
|
|
<span className={styles.archivedChevron} aria-hidden="true">
|
|
{archivedExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
|
</span>
|
|
{archivedExpanded && allArchivedSessions.length > 0 && (
|
|
<span className={styles.archivedCount}>
|
|
{allArchivedSessions.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
|
|
if (!archivedExpanded) {
|
|
return <div className={styles.archivedSection}>{header}</div>;
|
|
}
|
|
|
|
const retry = (
|
|
<button
|
|
className={styles.retry}
|
|
type="button"
|
|
onClick={() => {
|
|
void reloadArchived();
|
|
setSecondaryArchivedReloadToken((token) => token + 1);
|
|
}}
|
|
>
|
|
{t('sidebar.loadFailed')}
|
|
</button>
|
|
);
|
|
let content: ReactNode;
|
|
if (effectiveArchivedLoading && allArchivedSessions.length === 0) {
|
|
content = (
|
|
<div className={styles.notice}>{t('sidebar.loadingSessions')}</div>
|
|
);
|
|
} else if (effectiveArchivedError && allArchivedSessions.length === 0) {
|
|
content = retry;
|
|
} else if (allArchivedSessions.length === 0) {
|
|
content = (
|
|
<div className={styles.notice}>{t('sidebar.archivedEmpty')}</div>
|
|
);
|
|
} else {
|
|
content = (
|
|
<>
|
|
{allArchivedSessions.map((session) =>
|
|
renderSessionRow(session, { isArchived: true }),
|
|
)}
|
|
{effectiveArchivedError && retry}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.archivedSection}>
|
|
{header}
|
|
<div className={styles.archivedList}>{content}</div>
|
|
</div>
|
|
);
|
|
}, [
|
|
archivedExpanded,
|
|
allArchivedSessions,
|
|
collapsed,
|
|
effectiveArchivedError,
|
|
effectiveArchivedLoading,
|
|
reloadArchived,
|
|
renderSessionRow,
|
|
searchQuery,
|
|
sessionArchiveEnabled,
|
|
setSecondaryArchivedReloadToken,
|
|
t,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
<aside
|
|
ref={sidebarRef}
|
|
className={cx(
|
|
styles.sidebar,
|
|
collapsed && styles.collapsed,
|
|
isResizing && styles.resizing,
|
|
mobileOpen && styles.mobileOpen,
|
|
)}
|
|
aria-label={t('sidebar.label')}
|
|
style={sidebarStyle}
|
|
>
|
|
{groupMenu && (
|
|
<div
|
|
ref={groupMenuRef}
|
|
className={styles.groupMenu}
|
|
role="menu"
|
|
aria-label={t('sidebar.sessionGroup')}
|
|
style={{ top: groupMenu.top, left: groupMenu.left }}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={handleGroupMenuKeyDown}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<button
|
|
className={cx(
|
|
styles.groupMenuItem,
|
|
groupMenuUngroupedSelected && styles.groupMenuItemActive,
|
|
)}
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={groupMenuUngroupedSelected}
|
|
onClick={() => assignSessionGroup(groupMenu.session, null)}
|
|
>
|
|
<span className={styles.groupMenuEmptyDot} />
|
|
<span className={styles.groupMenuName}>
|
|
{t('sidebar.groupUngrouped')}
|
|
</span>
|
|
{groupMenuUngroupedSelected && (
|
|
<span className={styles.groupMenuCheck}>✓</span>
|
|
)}
|
|
</button>
|
|
{menuColorOptions.map((color) => {
|
|
const selected = groupMenuSelectedColor === color;
|
|
return (
|
|
<button
|
|
key={`color:${color}`}
|
|
className={cx(
|
|
styles.groupMenuItem,
|
|
selected && styles.groupMenuItemActive,
|
|
)}
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={selected}
|
|
onClick={() => assignSessionColor(groupMenu.session, color)}
|
|
>
|
|
<span
|
|
className={cx(
|
|
styles.groupMenuDot,
|
|
getGroupColorClass(color),
|
|
)}
|
|
/>
|
|
<span className={styles.groupMenuName}>
|
|
{t(`sidebar.groupColor.${color}`)}
|
|
</span>
|
|
{selected && <span className={styles.groupMenuCheck}>✓</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
{menuGroups.map((group) => {
|
|
const selected = groupMenuSelectedGroupId === group.id;
|
|
return (
|
|
<button
|
|
key={group.id}
|
|
className={cx(
|
|
styles.groupMenuItem,
|
|
selected && styles.groupMenuItemActive,
|
|
)}
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={selected}
|
|
onClick={() =>
|
|
assignSessionGroup(groupMenu.session, group.id)
|
|
}
|
|
>
|
|
<span
|
|
className={cx(
|
|
styles.groupMenuDot,
|
|
getGroupColorClass(group.color),
|
|
)}
|
|
style={getGroupColorStyle(group.color)}
|
|
/>
|
|
<span className={styles.groupMenuName}>{group.name}</span>
|
|
{selected && <span className={styles.groupMenuCheck}>✓</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
<div className={styles.groupMenuSeparator} />
|
|
<button
|
|
className={styles.groupMenuItem}
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => handleCreateGroupForSession(groupMenu.session)}
|
|
>
|
|
<span className={styles.groupMenuIcon}>
|
|
<IconNewChat />
|
|
</span>
|
|
<span className={styles.groupMenuName}>
|
|
{t('sidebar.groupCreate')}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
{deleteCandidate && (
|
|
<DialogShell
|
|
title={t('delete.title')}
|
|
size="sm"
|
|
onClose={() => setDeleteCandidate(null)}
|
|
>
|
|
<div className={styles.confirmContent}>
|
|
<p className={styles.confirmDescription}>
|
|
{t('sidebar.deleteConfirmDescription', {
|
|
name: deleteCandidateLabel,
|
|
})}
|
|
</p>
|
|
<div className={styles.confirmActions}>
|
|
<button
|
|
className={styles.secondaryButton}
|
|
type="button"
|
|
onClick={() => setDeleteCandidate(null)}
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
className={styles.dangerButton}
|
|
type="button"
|
|
onClick={confirmDeleteSession}
|
|
>
|
|
{t('sidebar.delete')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</DialogShell>
|
|
)}
|
|
{workspaceRemovalCandidate && (
|
|
<DialogShell
|
|
title={t('sidebar.removeWorkspaceTitle')}
|
|
size="sm"
|
|
onClose={() => {
|
|
if (
|
|
!workspaceRemovalSubmitting ||
|
|
workspaceRemovalRemoteInProgress
|
|
) {
|
|
workspaceRemovalDismissedRef.current = true;
|
|
setWorkspaceRemovalCandidate(null);
|
|
setWorkspaceRemovalActivity(null);
|
|
setWorkspaceRemovalRemoteInProgress(false);
|
|
}
|
|
}}
|
|
>
|
|
<div className={styles.confirmContent}>
|
|
<p className={styles.confirmDescription}>
|
|
{workspaceRemovalActivity
|
|
? t('sidebar.removeWorkspaceBusy', {
|
|
name: workspaceRemovalCandidate.cwd,
|
|
})
|
|
: t('sidebar.removeWorkspaceConfirm', {
|
|
name: workspaceRemovalCandidate.cwd,
|
|
})}
|
|
</p>
|
|
{workspaceRemovalActivity && (
|
|
<ul className={styles.workspaceRemovalActivityList}>
|
|
<li>
|
|
{t('sidebar.removeWorkspaceSessions', {
|
|
count: workspaceRemovalActivity.sessions,
|
|
})}
|
|
</li>
|
|
<li>
|
|
{t('sidebar.removeWorkspacePrompts', {
|
|
count: workspaceRemovalActivity.activePrompts,
|
|
})}
|
|
</li>
|
|
<li>
|
|
{t('sidebar.removeWorkspaceStarts', {
|
|
count: workspaceRemovalActivity.pendingSessionStarts,
|
|
})}
|
|
</li>
|
|
<li>
|
|
{t('sidebar.removeWorkspaceConnections', {
|
|
count: workspaceRemovalActivity.acpConnections,
|
|
})}
|
|
</li>
|
|
<li>
|
|
{t('sidebar.removeWorkspaceMemoryTasks', {
|
|
count: workspaceRemovalActivity.memoryTasks,
|
|
})}
|
|
</li>
|
|
<li>
|
|
{t('sidebar.removeWorkspaceWorkers', {
|
|
count: workspaceRemovalActivity.channelWorkers,
|
|
})}
|
|
</li>
|
|
<li>
|
|
{t('sidebar.removeWorkspaceVoiceSessions', {
|
|
count: workspaceRemovalActivity.voiceSessions ?? 0,
|
|
})}
|
|
</li>
|
|
</ul>
|
|
)}
|
|
{workspaceRemovalActivity &&
|
|
connection.sessionId &&
|
|
connection.workspaceCwd === workspaceRemovalCandidate.cwd && (
|
|
<p className={styles.confirmDescription}>
|
|
{t('sidebar.removeWorkspaceCurrentSession')}
|
|
</p>
|
|
)}
|
|
{workspaceRemovalRemoteInProgress && (
|
|
<p className={styles.confirmDescription}>
|
|
{t('sidebar.removeWorkspaceInProgress')}
|
|
</p>
|
|
)}
|
|
<div className={styles.confirmActions}>
|
|
<button
|
|
className={styles.secondaryButton}
|
|
type="button"
|
|
disabled={
|
|
workspaceRemovalSubmitting &&
|
|
!workspaceRemovalRemoteInProgress
|
|
}
|
|
onClick={() => {
|
|
workspaceRemovalDismissedRef.current = true;
|
|
setWorkspaceRemovalCandidate(null);
|
|
setWorkspaceRemovalActivity(null);
|
|
setWorkspaceRemovalRemoteInProgress(false);
|
|
}}
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
className={styles.dangerButton}
|
|
type="button"
|
|
disabled={
|
|
workspaceRemovalSubmitting ||
|
|
workspaceRemovalRemoteInProgress ||
|
|
(workspaceRemovalActivity !== null &&
|
|
Boolean(connection.sessionId) &&
|
|
connection.workspaceCwd === workspaceRemovalCandidate.cwd)
|
|
}
|
|
onClick={() => void confirmWorkspaceRemoval()}
|
|
>
|
|
{workspaceRemovalActivity
|
|
? t('sidebar.forceRemoveWorkspace')
|
|
: t('sidebar.removeWorkspace')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</DialogShell>
|
|
)}
|
|
{groupEditor && (
|
|
<DialogShell
|
|
title={groupEditorTitle}
|
|
size="sm"
|
|
onClose={closeGroupEditor}
|
|
>
|
|
<form
|
|
className="flex flex-col gap-6"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
saveGroupEditor();
|
|
}}
|
|
>
|
|
<FieldGroup>
|
|
<Field>
|
|
<FieldLabel htmlFor="session-group-name">
|
|
{t('sidebar.groupNamePrompt')}
|
|
</FieldLabel>
|
|
<Input
|
|
id="session-group-name"
|
|
value={groupName}
|
|
autoFocus
|
|
maxLength={64}
|
|
onChange={(event) => setGroupName(event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="session-group-color">
|
|
{t('sidebar.groupColor')}
|
|
</FieldLabel>
|
|
<Select
|
|
value={
|
|
customGroupColor ? CUSTOM_GROUP_COLOR_OPTION : groupColor
|
|
}
|
|
onValueChange={(value) => {
|
|
setGroupColor(
|
|
value === CUSTOM_GROUP_COLOR_OPTION
|
|
? lastValidCustomGroupColor
|
|
: (value as DaemonSessionGroupPresetColor),
|
|
);
|
|
}}
|
|
>
|
|
<SelectTrigger id="session-group-color" className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
{groupColorChoices.map((color) => (
|
|
<SelectItem key={color} value={color}>
|
|
{t(`sidebar.groupColor.${color}`)}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value={CUSTOM_GROUP_COLOR_OPTION}>
|
|
{t('sidebar.groupColor.custom')}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
{customGroupColor && (
|
|
<Field>
|
|
<FieldLabel htmlFor="session-group-hex-color">
|
|
{t('sidebar.groupColor.hex')}
|
|
</FieldLabel>
|
|
<div className={styles.groupCustomColorRow}>
|
|
<Input
|
|
className={styles.groupColorPicker}
|
|
type="color"
|
|
value={lastValidCustomGroupColor}
|
|
aria-label={t('sidebar.groupColor.picker')}
|
|
onChange={(event) => {
|
|
const value =
|
|
event.target.value.toLowerCase() as DaemonSessionGroupHexColor;
|
|
setLastValidCustomGroupColor(value);
|
|
setGroupColor(value);
|
|
}}
|
|
/>
|
|
<Input
|
|
id="session-group-hex-color"
|
|
value={groupColor}
|
|
maxLength={7}
|
|
spellCheck={false}
|
|
aria-invalid={normalizedGroupColor === undefined}
|
|
onChange={(event) => {
|
|
const raw = event.target.value;
|
|
const trimmed = raw.trim();
|
|
const value = (
|
|
trimmed && !trimmed.startsWith('#')
|
|
? `#${trimmed}`
|
|
: raw
|
|
) as DaemonSessionGroupColor;
|
|
setGroupColor(value);
|
|
const normalized = normalizeHexColorInput(value);
|
|
if (normalized) {
|
|
setLastValidCustomGroupColor(normalized);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
{normalizedGroupColor === undefined && (
|
|
<span className={styles.groupColorError} role="alert">
|
|
{t('sidebar.groupColor.invalid')}
|
|
</span>
|
|
)}
|
|
</Field>
|
|
)}
|
|
</FieldGroup>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={groupBusy}
|
|
onClick={closeGroupEditor}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button type="submit" disabled={!canSaveGroup}>
|
|
{t('common.save')}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogShell>
|
|
)}
|
|
{deleteGroupCandidate && (
|
|
<DialogShell
|
|
title={t('sidebar.groupDelete')}
|
|
size="sm"
|
|
onClose={() => {
|
|
if (!groupBusy) setDeleteGroupCandidate(null);
|
|
}}
|
|
>
|
|
<div className={styles.confirmContent}>
|
|
<p className={styles.confirmDescription}>
|
|
{t('sidebar.groupDeleteConfirm', {
|
|
name: deleteGroupCandidateLabel,
|
|
})}
|
|
</p>
|
|
<div className={styles.confirmActions}>
|
|
<button
|
|
className={styles.secondaryButton}
|
|
type="button"
|
|
disabled={groupBusy}
|
|
onClick={() => setDeleteGroupCandidate(null)}
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
className={styles.dangerButton}
|
|
type="button"
|
|
disabled={groupBusy}
|
|
onClick={confirmDeleteGroup}
|
|
>
|
|
{t('sidebar.groupDelete')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</DialogShell>
|
|
)}
|
|
{shouldRenderBrand && (
|
|
<div className={styles.topRow}>
|
|
{branding?.render ? (
|
|
branding.render()
|
|
) : (
|
|
<>
|
|
<span className={styles.brandLogo} aria-hidden="true">
|
|
<IconQwenLogo />
|
|
</span>
|
|
{!collapsed && (
|
|
<span className={styles.brandName}>Qwen Code</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className={styles.primaryNav}>
|
|
{primaryNavItems.has('newTask') && (
|
|
<button
|
|
className={styles.newChatButton}
|
|
type="button"
|
|
title={t('sidebar.newTask')}
|
|
aria-label={t('sidebar.newTask')}
|
|
disabled={newSessionDisabled}
|
|
onClick={() => handleNewSession()}
|
|
>
|
|
<span className={styles.navIcon}>
|
|
<SquarePenIcon size={16} strokeWidth={1.2} />
|
|
</span>
|
|
{!collapsed && <span>{t('sidebar.newTask')}</span>}
|
|
</button>
|
|
)}
|
|
{primaryNavItems.has('plugins') && (
|
|
<button
|
|
className={styles.pluginButton}
|
|
type="button"
|
|
title={t('sidebar.plugins')}
|
|
aria-label={t('sidebar.plugins')}
|
|
onClick={onOpenPlugins}
|
|
>
|
|
<span className={styles.navIcon}>
|
|
<BlocksIcon size={16} strokeWidth={1.2} />
|
|
</span>
|
|
{!collapsed && <span>{t('sidebar.plugins')}</span>}
|
|
</button>
|
|
)}
|
|
{primaryNavItems.has('channels') && (
|
|
<button
|
|
className={styles.pluginButton}
|
|
type="button"
|
|
title={t('sidebar.channels')}
|
|
aria-label={t('sidebar.channels')}
|
|
onClick={onOpenChannels}
|
|
>
|
|
<span className={styles.navIcon}>
|
|
<RadioTowerIcon size={16} strokeWidth={1.2} />
|
|
</span>
|
|
{!collapsed && <span>{t('sidebar.channels')}</span>}
|
|
</button>
|
|
)}
|
|
{primaryNavItems.has('scheduledTasks') && (
|
|
<button
|
|
className={styles.pluginButton}
|
|
type="button"
|
|
title={t('sidebar.scheduledTasks')}
|
|
aria-label={t('sidebar.scheduledTasks')}
|
|
onClick={onOpenScheduledTasks}
|
|
>
|
|
<span className={styles.navIcon}>
|
|
<CalendarClockIcon size={16} strokeWidth={1.2} />
|
|
</span>
|
|
{!collapsed && <span>{t('sidebar.scheduledTasks')}</span>}
|
|
</button>
|
|
)}
|
|
{primaryNavItems.has('goals') && (
|
|
<button
|
|
className={styles.pluginButton}
|
|
type="button"
|
|
title={t('sidebar.goals')}
|
|
aria-label={t('sidebar.goals')}
|
|
onClick={onOpenGoals}
|
|
>
|
|
<span className={styles.navIcon}>
|
|
<TargetIcon size={16} strokeWidth={1.2} />
|
|
</span>
|
|
{!collapsed && <span>{t('sidebar.goals')}</span>}
|
|
</button>
|
|
)}
|
|
{primaryNavOptions?.render?.()}
|
|
</div>
|
|
<div className={styles.body}>
|
|
<div className={styles.sessionList}>
|
|
{!collapsed && pinnedSessions.length > 0 && (
|
|
<>
|
|
<div className={styles.projectsHeader}>
|
|
<button
|
|
className={styles.projectsHeaderToggle}
|
|
type="button"
|
|
aria-expanded={pinnedExpanded}
|
|
onClick={() => setPinnedExpanded((expanded) => !expanded)}
|
|
>
|
|
<span>{t('sidebar.pinnedSessions')}</span>
|
|
<IconChevron expanded={pinnedExpanded} />
|
|
</button>
|
|
</div>
|
|
{pinnedExpanded && (
|
|
<div className={styles.pinnedSessionList}>
|
|
{pinnedSessions.map((session) =>
|
|
renderSessionRow(session, {
|
|
readOnly: isActiveSessionReadOnly(session),
|
|
}),
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{!collapsed && !hideProjectHeader && (
|
|
<div className={styles.projectsHeader}>
|
|
<button
|
|
className={styles.projectsHeaderToggle}
|
|
type="button"
|
|
aria-expanded={projectsExpanded}
|
|
onClick={() => setProjectsExpanded((expanded) => !expanded)}
|
|
>
|
|
<span>{t('sidebar.project')}</span>
|
|
<IconChevron expanded={projectsExpanded} />
|
|
</button>
|
|
<div className={styles.projectsHeaderActions}>
|
|
<button
|
|
className={styles.projectsHeaderAction}
|
|
type="button"
|
|
title={t('sidebar.search')}
|
|
aria-label={t('sidebar.search')}
|
|
onClick={() => {
|
|
setSearchOpen((open) => {
|
|
if (open) setSearchQuery('');
|
|
return !open;
|
|
});
|
|
setProjectsExpanded(true);
|
|
}}
|
|
>
|
|
<SearchIcon />
|
|
</button>
|
|
{!lockedWorkspaceCwd && onOpenAddWorkspace && (
|
|
<button
|
|
className={styles.projectsHeaderAction}
|
|
type="button"
|
|
title={t('sidebar.addWorkspace')}
|
|
aria-label={t('sidebar.addWorkspace')}
|
|
onClick={onOpenAddWorkspace}
|
|
>
|
|
<PlusIcon />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{searchOpen && !collapsed && !hideProjectHeader && (
|
|
<div className={styles.projectSearch}>
|
|
<SearchIcon aria-hidden="true" />
|
|
<Input
|
|
value={searchQuery}
|
|
placeholder={t('sidebar.searchPlaceholder')}
|
|
aria-label={t('sidebar.search')}
|
|
autoFocus
|
|
onChange={(event) => setSearchQuery(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
setSearchQuery('');
|
|
setSearchOpen(false);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{(collapsed || projectsExpanded) && (
|
|
<>
|
|
{!collapsed && (
|
|
<div className={styles.workspacePicker}>
|
|
<div className={styles.workspaceList}>
|
|
{displayedWorkspaces.map((ws) => (
|
|
<Fragment key={ws.id}>
|
|
<WorkspaceSection
|
|
workspace={ws}
|
|
renderHeader={
|
|
lockedWorkspaceCwd &&
|
|
lockedWorkspaceOptions?.render
|
|
? (expanded) =>
|
|
lockedWorkspaceOptions.render?.(ws, {
|
|
expanded,
|
|
})
|
|
: undefined
|
|
}
|
|
client={workspace.client}
|
|
reloadToken={workspaceSessionsReloadToken}
|
|
untrustedLabel={t('sidebar.workspaceUntrusted')}
|
|
readOnlyLabel={t('sidebar.workspaceReadOnly')}
|
|
trustToOpenLabel={t('sidebar.workspaceTrustToOpen')}
|
|
noSessionsLabel={t('sidebar.noSessions')}
|
|
loadErrorLabel={t('sidebar.loadFailed')}
|
|
organizationEnabled={organizationEnabled}
|
|
sourceMetadataEnabled={sourceMetadataEnabled}
|
|
ungroupedLabel={t('sidebar.groupUngrouped')}
|
|
onRenameGroup={
|
|
canOrganizeWorkspace(ws.cwd)
|
|
? handleRenameGroup
|
|
: undefined
|
|
}
|
|
onDeleteGroup={
|
|
canOrganizeWorkspace(ws.cwd)
|
|
? handleDeleteGroup
|
|
: undefined
|
|
}
|
|
renameGroupLabel={t('sidebar.groupRename')}
|
|
deleteGroupLabel={t('sidebar.groupDelete')}
|
|
groupActionsDisabled={groupBusy}
|
|
excludePinned
|
|
onOpenGitDiff={onOpenGitDiff}
|
|
onOpenCommit={onOpenCommit}
|
|
formatTime={(iso) => formatRelativeTime(iso, t)}
|
|
searchQuery={searchQuery}
|
|
expanded={ws.primary ? projectExpanded : undefined}
|
|
autoExpandKey={
|
|
autoExpandWorkspace?.id === ws.id
|
|
? autoExpandWorkspace?.key
|
|
: undefined
|
|
}
|
|
onExpandedChange={
|
|
ws.primary ? setProjectExpanded : undefined
|
|
}
|
|
renderSessions={!ws.primary}
|
|
renderSession={(session) =>
|
|
renderSessionRow(
|
|
{
|
|
...session,
|
|
workspaceCwd: ws.cwd,
|
|
},
|
|
{
|
|
readOnly: isActiveSessionReadOnly({
|
|
...session,
|
|
workspaceCwd: ws.cwd,
|
|
}),
|
|
},
|
|
)
|
|
}
|
|
headerActions={(visible) => {
|
|
if (
|
|
lockedWorkspaceCwd &&
|
|
lockedWorkspaceOptions?.render
|
|
) {
|
|
return null;
|
|
}
|
|
const canRemove =
|
|
!lockedWorkspaceCwd &&
|
|
workspaceRemovalEnabled &&
|
|
!ws.primary &&
|
|
ws.removable === true;
|
|
if (!ws.trusted && !canRemove) return null;
|
|
const wsCwd = ws.primary ? undefined : ws.cwd;
|
|
return (
|
|
<div
|
|
className={styles.workspaceHeaderActions}
|
|
style={{
|
|
visibility: visible ? 'visible' : 'hidden',
|
|
}}
|
|
>
|
|
{ws.trusted && (
|
|
<>
|
|
{canOrganizeWorkspace(ws.cwd) && (
|
|
<button
|
|
className={
|
|
styles.workspaceHeaderAction
|
|
}
|
|
type="button"
|
|
aria-label={t('sidebar.groupCreate')}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (ws.primary) {
|
|
handleCreateGroup();
|
|
} else {
|
|
handleCreateWorkspaceGroup(
|
|
ws.cwd,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<PlusIcon
|
|
size={16}
|
|
strokeWidth={1.2}
|
|
/>
|
|
</button>
|
|
)}
|
|
<button
|
|
className={styles.workspaceHeaderAction}
|
|
type="button"
|
|
title={t('sidebar.newTask')}
|
|
aria-label={t('sidebar.newTask')}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleNewSession(wsCwd);
|
|
}}
|
|
>
|
|
<SquarePenIcon
|
|
size={16}
|
|
strokeWidth={1.2}
|
|
/>
|
|
</button>
|
|
</>
|
|
)}
|
|
{canRemove && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className={
|
|
styles.workspaceHeaderAction
|
|
}
|
|
type="button"
|
|
aria-label={t(
|
|
'sidebar.workspaceActions',
|
|
)}
|
|
disabled={
|
|
workspaceRemovalSubmitting &&
|
|
workspaceRemovalCandidate?.id ===
|
|
ws.id
|
|
}
|
|
>
|
|
<EllipsisVerticalIcon
|
|
size={16}
|
|
strokeWidth={1.2}
|
|
/>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="w-auto min-w-40"
|
|
>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
aria-label={`${t(
|
|
'sidebar.removeWorkspace',
|
|
)}: ${ws.cwd}`}
|
|
onSelect={() =>
|
|
requestWorkspaceRemoval(ws)
|
|
}
|
|
>
|
|
<Trash2Icon />
|
|
{t('sidebar.removeWorkspace')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
{ws.primary &&
|
|
(projectExpanded || searchQuery.trim()) ? (
|
|
<div className={styles.workspaceSessionBody}>
|
|
{body}
|
|
</div>
|
|
) : null}
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{archivedSection}
|
|
</div>
|
|
</div>
|
|
|
|
{footer !== false && (
|
|
<div
|
|
className={cx(
|
|
styles.footer,
|
|
footerCompact && styles.footerCompact,
|
|
footerTight && styles.footerTight,
|
|
)}
|
|
>
|
|
<div className={styles.footerPrimary}>
|
|
{footer && typeof footer === 'object' && footer.render?.()}
|
|
{footerItems.has('settings') && (
|
|
<button
|
|
className={styles.footerButton}
|
|
type="button"
|
|
title={t('sidebar.settings')}
|
|
aria-label={t('sidebar.settings')}
|
|
onClick={onOpenSettings}
|
|
>
|
|
<span className={`${styles.navIcon} ${styles.settingsIcon}`}>
|
|
<SettingsIcon size={16} strokeWidth={1.2} />
|
|
</span>
|
|
{!collapsed && !footerCompact && (
|
|
<span className={styles.footerButtonLabel}>
|
|
{t('sidebar.settings')}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
{!collapsed &&
|
|
!footerTight &&
|
|
versionLabel &&
|
|
footerItems.has('version') && (
|
|
<span
|
|
className={styles.version}
|
|
title={`Qwen Code ${versionLabel}`}
|
|
>
|
|
{versionLabel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className={styles.footerActions}>
|
|
{footerItems.has('theme') && (
|
|
<button
|
|
className={styles.collapseButton}
|
|
type="button"
|
|
title={
|
|
theme === WebShellThemeId.Dark
|
|
? t('sidebar.themeLight')
|
|
: t('sidebar.themeDark')
|
|
}
|
|
aria-label={
|
|
theme === WebShellThemeId.Dark
|
|
? t('sidebar.themeLight')
|
|
: t('sidebar.themeDark')
|
|
}
|
|
onClick={() =>
|
|
onThemeChange(
|
|
theme === WebShellThemeId.Dark
|
|
? WebShellThemeId.Light
|
|
: WebShellThemeId.Dark,
|
|
)
|
|
}
|
|
>
|
|
{theme === WebShellThemeId.Dark ? (
|
|
<SunIcon size={16} strokeWidth={1.2} />
|
|
) : (
|
|
<MoonIcon size={16} strokeWidth={1.2} />
|
|
)}
|
|
</button>
|
|
)}
|
|
{canOpenSessionsOverview &&
|
|
footerItems.has('sessionsOverview') && (
|
|
<button
|
|
className={styles.collapseButton}
|
|
type="button"
|
|
title={t('sidebar.sessionsOverview')}
|
|
aria-label={t('sidebar.sessionsOverview')}
|
|
onClick={onOpenSessions}
|
|
>
|
|
<LayoutGridIcon size={16} strokeWidth={1.2} />
|
|
</button>
|
|
)}
|
|
{canOpenSplitView && footerItems.has('splitView') && (
|
|
<button
|
|
className={styles.collapseButton}
|
|
type="button"
|
|
title={t('sidebar.splitView')}
|
|
aria-label={t('sidebar.splitView')}
|
|
onClick={onOpenSplitView}
|
|
>
|
|
<Columns2Icon size={16} strokeWidth={1.2} />
|
|
</button>
|
|
)}
|
|
{footerItems.has('daemonStatus') && (
|
|
<button
|
|
className={styles.collapseButton}
|
|
type="button"
|
|
title={t('sidebar.daemonStatus')}
|
|
aria-label={t('sidebar.daemonStatus')}
|
|
onClick={onOpenDaemonStatus}
|
|
>
|
|
<ActivityIcon size={16} strokeWidth={1.2} />
|
|
</button>
|
|
)}
|
|
{!mobileOpen && footerItems.has('collapse') && (
|
|
<button
|
|
className={styles.collapseButton}
|
|
type="button"
|
|
title={
|
|
collapsed ? t('sidebar.expand') : t('sidebar.collapse')
|
|
}
|
|
aria-label={
|
|
collapsed ? t('sidebar.expand') : t('sidebar.collapse')
|
|
}
|
|
onClick={() => onCollapsedChange(!collapsed)}
|
|
>
|
|
{collapsed ? (
|
|
<PanelLeftOpenIcon size={16} strokeWidth={1.2} />
|
|
) : (
|
|
<PanelLeftCloseIcon size={16} strokeWidth={1.2} />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={styles.resizeHandle}
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
onPointerDown={handleResizePointerDown}
|
|
/>
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|