From 4d4b3aa9d999bdeedfe85386e7fb08cc1be168e8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 01:39:15 +0300 Subject: [PATCH] fix: improve context panel session visibility Marks active embedded chat sessions as seen only while focused Shows real session titles for context panel chat tabs Names review sessions after the implementation session --- .../ui/src/components/layout/ContextPanel.tsx | 80 ++++++++++++++++++- .../session/sidebar/SessionNodeItem.tsx | 3 +- packages/ui/src/lib/reviewFlow.ts | 9 ++- packages/ui/src/stores/useUIStore.ts | 6 ++ 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 7e9bbee8..28644e82 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -18,7 +18,7 @@ import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore' import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInputStore } from '@/sync/input-store'; import { markSessionViewed } from '@/sync/notification-store'; -import { setExternallyViewedSession } from '@/sync/sync-context'; +import { setExternallyViewedSession, useDirectoryStore } from '@/sync/sync-context'; import { ContextPanelContent } from './ContextSidebarTab'; import { toast } from '@/components/ui'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -45,6 +45,7 @@ const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_DEFAULT_WIDTH = 600; const CONTEXT_TAB_LABEL_MAX_CHARS = 24; type TranslateFn = ReturnType['t']; +const EMPTY_SESSION_TITLE_MAP = new Map(); type PreviewConsoleEvent = { id: number; @@ -177,9 +178,27 @@ const getFileNameFromPath = (path: string | null): string | null => { }; const getTabLabel = ( - tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; stagedDiff?: boolean }, + tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; dedupeKey?: string; sessionTitleFallback?: string | null; stagedDiff?: boolean }, + sessionTitleById: ReadonlyMap, t: TranslateFn ): string => { + if (tab.mode === 'chat') { + const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey); + if (sessionID) { + const sessionTitle = sessionTitleById.get(sessionID)?.trim(); + if (sessionTitle) { + return sessionTitle; + } + } + + const sessionTitleFallback = tab.sessionTitleFallback?.trim(); + if (sessionTitleFallback) { + return sessionTitleFallback; + } + + return t('contextPanel.mode.chat'); + } + if (tab.label) { return tab.label; } @@ -251,6 +270,47 @@ const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null return sessionID || null; }; +const areTitleMapsEqual = (a: ReadonlyMap, b: ReadonlyMap): boolean => { + if (a.size !== b.size) return false; + for (const [key, value] of a) { + if (b.get(key) !== value) return false; + } + return true; +}; + +const buildSessionTitleMap = (sessions: Array<{ id: string; title?: string | null }>, sessionIDs: readonly string[]): Map => { + if (sessionIDs.length === 0) return EMPTY_SESSION_TITLE_MAP; + const wanted = new Set(sessionIDs); + const next = new Map(); + for (const session of sessions) { + if (!wanted.has(session.id)) continue; + const title = session.title?.trim(); + if (title) next.set(session.id, title); + } + return next.size === 0 ? EMPTY_SESSION_TITLE_MAP : next; +}; + +const useSessionTitleMap = (directory: string | undefined, sessionIDs: readonly string[]): ReadonlyMap => { + const store = useDirectoryStore(directory); + const snapshotRef = React.useRef>(EMPTY_SESSION_TITLE_MAP); + const sessionIDsRef = React.useRef(sessionIDs); + + sessionIDsRef.current = sessionIDs; + + return React.useSyncExternalStore( + store.subscribe, + React.useCallback(() => { + const next = buildSessionTitleMap(store.getState().session, sessionIDsRef.current); + if (areTitleMapsEqual(snapshotRef.current, next)) { + return snapshotRef.current; + } + snapshotRef.current = next; + return next; + }, [store]), + () => EMPTY_SESSION_TITLE_MAP, + ); +}; + const DESKTOP_BROWSER_INSPECT_SCRIPT = `new Promise((resolve) => { const existing = document.getElementById('__openchamber_desktop_browser_overlay'); if (existing) existing.remove(); @@ -2010,6 +2070,16 @@ export const ContextPanel: React.FC = () => { const isOpen = Boolean(panelState?.isOpen && activeTab); const isExpanded = Boolean(isOpen && panelState?.expanded); const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH); + const chatSessionIDs = React.useMemo(() => { + const ids: string[] = []; + for (const tab of tabs) { + if (tab.mode !== 'chat') continue; + const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey); + if (sessionID && !ids.includes(sessionID)) ids.push(sessionID); + } + return ids; + }, [tabs]); + const sessionTitleById = useSessionTitleMap(directoryKey || undefined, chatSessionIDs); const [isResizing, setIsResizing] = React.useState(false); const [suppressWidthTransition, setSuppressWidthTransition] = React.useState(false); @@ -2198,11 +2268,13 @@ export const ContextPanel: React.FC = () => { markActiveChatViewed(); const interval = window.setInterval(markActiveChatViewed, 10_000); window.addEventListener('focus', markActiveChatViewed); + window.addEventListener('blur', markActiveChatViewed); document.addEventListener('visibilitychange', markActiveChatViewed); return () => { window.clearInterval(interval); window.removeEventListener('focus', markActiveChatViewed); + window.removeEventListener('blur', markActiveChatViewed); document.removeEventListener('visibilitychange', markActiveChatViewed); setExternallyViewedSession(directoryKey, activeChatSessionID, false); }; @@ -2356,7 +2428,7 @@ export const ContextPanel: React.FC = () => { }, [darkThemeId, lightThemeId, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]); const tabItems = React.useMemo(() => tabs.map((tab) => { - const rawLabel = getTabLabel(tab, t); + const rawLabel = getTabLabel(tab, sessionTitleById, t); const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS); const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory); return { @@ -2366,7 +2438,7 @@ export const ContextPanel: React.FC = () => { title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel, closeLabel: t('contextPanel.tab.closeTabAria', { label }), }; - }), [effectiveDirectory, t, tabs]); + }), [effectiveDirectory, sessionTitleById, t, tabs]); const activeNonChatContent = activeTab?.mode === 'context' ? diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index a20e8feb..89dc1620 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -82,7 +82,7 @@ type Props = { removeSessionFromFolder: (scopeKey: string, sessionId: string) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; - openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; readOnly?: boolean }) => void; + openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void; mobileVariant: boolean; alwaysShowActions: boolean; @@ -867,6 +867,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { mode: 'chat', dedupeKey: `session:${session.id}`, label: sessionTitle, + sessionTitleFallback: sessionTitle, }); }} className="[&>svg]:mr-1" diff --git a/packages/ui/src/lib/reviewFlow.ts b/packages/ui/src/lib/reviewFlow.ts index 432ff43d..26915e57 100644 --- a/packages/ui/src/lib/reviewFlow.ts +++ b/packages/ui/src/lib/reviewFlow.ts @@ -27,7 +27,6 @@ const AUTO_REVIEW_POLL_MS = 300; const AUTO_REVIEW_MAX_ITERATIONS = 15; const AUTO_REVIEW_FINAL_MARKER = 'FINAL_REVIEW_STATUS: no_remaining_findings'; const AUTO_REVIEW_FINAL_MARKER_NORMALIZED = AUTO_REVIEW_FINAL_MARKER.toLowerCase(); -const REVIEW_SESSION_TITLE = 'Review of workspace changes'; const activeAutoReviewLoops = new Set(); const activeAutoReviewForwardKeys = new Set(); @@ -417,6 +416,7 @@ const openReviewSessionPanel = (directory: string, session: Session): void => { mode: 'chat', dedupeKey: `session:${session.id}`, label: session.title ?? null, + sessionTitleFallback: session.title ?? null, }); }; @@ -428,6 +428,11 @@ const getSessionOrNull = async (sessionID: string, directory: string): Promise { + const implementationTitle = original.title?.trim() || original.id; + return `Review: ${implementationTitle}`; +}; + const createOrReuseReviewSession = async (originalSessionID: string, directory: string, expectedRuntimeKey?: string): Promise => { assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey); const original = await opencodeClient.getSession(originalSessionID, directory); @@ -451,7 +456,7 @@ const createOrReuseReviewSession = async (originalSessionID: string, directory: assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey); const review = await opencodeClient.createSession({ - title: REVIEW_SESSION_TITLE, + title: getReviewSessionTitle(original), metadata: withReviewSessionMarker({}, originalSessionID), }, directory); assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 6033f607..e4db27f1 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -31,6 +31,7 @@ type ContextPanelTab = { targetPath: string | null; dedupeKey: string; label: string | null; + sessionTitleFallback: string | null; readOnly: boolean; stagedDiff: boolean; touchedAt: number; @@ -41,6 +42,7 @@ type ContextPanelTabDescriptor = { targetPath?: string | null; dedupeKey?: string | null; label?: string | null; + sessionTitleFallback?: string | null; readOnly?: boolean; stagedDiff?: boolean; }; @@ -223,6 +225,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa targetPath: normalizedTargetPath, dedupeKey, label: normalizeContextTabLabel(descriptor.label), + sessionTitleFallback: normalizeContextTabLabel(descriptor.sessionTitleFallback), readOnly: descriptor.readOnly === true, stagedDiff: descriptor.stagedDiff === true, touchedAt: Date.now(), @@ -263,6 +266,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => { targetPath?: unknown; dedupeKey?: unknown; label?: unknown; + sessionTitleFallback?: unknown; readOnly?: unknown; stagedDiff?: unknown; touchedAt?: unknown; @@ -290,6 +294,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => { targetPath, dedupeKey, label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null), + sessionTitleFallback: normalizeContextTabLabel(typeof candidate.sessionTitleFallback === 'string' ? candidate.sessionTitleFallback : null), readOnly: candidate.readOnly === true, stagedDiff: candidate.stagedDiff === true, touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt) @@ -350,6 +355,7 @@ const upsertContextPanelTab = ( targetPath: nextTab.targetPath || tab.targetPath, dedupeKey: nextTab.dedupeKey, label: nextTab.label, + sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback, stagedDiff: nextTab.stagedDiff, readOnly: nextTab.readOnly, touchedAt: Date.now(),