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
This commit is contained in:
@@ -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<typeof useI18n>['t'];
|
||||
const EMPTY_SESSION_TITLE_MAP = new Map<string, string>();
|
||||
|
||||
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<string, string>,
|
||||
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<string, string>, b: ReadonlyMap<string, string>): 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<string, string> => {
|
||||
if (sessionIDs.length === 0) return EMPTY_SESSION_TITLE_MAP;
|
||||
const wanted = new Set(sessionIDs);
|
||||
const next = new Map<string, string>();
|
||||
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<string, string> => {
|
||||
const store = useDirectoryStore(directory);
|
||||
const snapshotRef = React.useRef<ReadonlyMap<string, string>>(EMPTY_SESSION_TITLE_MAP);
|
||||
const sessionIDsRef = React.useRef<readonly string[]>(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'
|
||||
? <ContextPanelContent />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string>();
|
||||
const activeAutoReviewForwardKeys = new Set<string>();
|
||||
|
||||
@@ -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<S
|
||||
}
|
||||
};
|
||||
|
||||
const getReviewSessionTitle = (original: Session): string => {
|
||||
const implementationTitle = original.title?.trim() || original.id;
|
||||
return `Review: ${implementationTitle}`;
|
||||
};
|
||||
|
||||
const createOrReuseReviewSession = async (originalSessionID: string, directory: string, expectedRuntimeKey?: string): Promise<Session> => {
|
||||
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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user