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(),