diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index 6efd895545..40b90c7f90 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -284,7 +284,7 @@ -
+
+
{{ t('chat.sidebar.workspace') }}
@@ -405,60 +412,72 @@ : '' ]" > - + + - + diff --git a/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts b/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts new file mode 100644 index 0000000000..e76d8815b7 --- /dev/null +++ b/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts @@ -0,0 +1,186 @@ +import { computed, nextTick, ref, type MaybeRefOrGetter, type Ref, toValue } from 'vue' +import { tryOnScopeDispose } from '@vueuse/core' +import type { useProjectStore } from '@/stores/ui/project' +import type { SessionGroup, useSessionStore } from '@/stores/ui/session' +import type { SidebarWorkspaceGroup } from './useSidebarWorkspaceGroups' +import { restoreSessionListScrollTop } from './useSessionListAutoFill' + +export type ProjectGroupMoveTarget = 'top' | 'up' | 'down' | 'bottom' + +interface UseProjectGroupReorderOptions { + sessionStore: ReturnType + projectStore: ReturnType + sessionListRef: Ref + collapsed: MaybeRefOrGetter + normalizedSearchQuery: MaybeRefOrGetter + pinFlightSessionId: MaybeRefOrGetter + workspaceGroups: MaybeRefOrGetter + /** Shared drag flag owned by the component so sibling composables can pause on it. */ + isProjectGroupDragging: Ref + isActiveProjectDirectoryGroup: (group: SessionGroup) => boolean + getGroupIdentifier: (group: SessionGroup) => string + getWorkspacePath: (group: SessionGroup) => string + ensureSessionListFilled: () => Promise + onDragStart?: () => void +} + +/** + * Drag-and-drop plus menu-based reordering for active workspace groups. Reorders are + * committed to the project store as a full environment order, preserving hidden + * environments in their current slots. + */ +export function useProjectGroupReorder(options: UseProjectGroupReorderOptions) { + const { sessionStore, projectStore, sessionListRef, isProjectGroupDragging } = options + + const projectGroupDragScrollTop = ref(null) + + const projectReorderableGroups = computed(() => + toValue(options.workspaceGroups).filter(options.isActiveProjectDirectoryGroup) + ) + const canReorderProjectGroups = computed( + () => + !toValue(options.collapsed) && + sessionStore.groupMode === 'project' && + toValue(options.normalizedSearchQuery).length === 0 && + !toValue(options.pinFlightSessionId) && + projectStore.snapshotReady && + sessionStore.hasLoadedInitialPage && + !sessionStore.loading && + projectReorderableGroups.value.length > 1 + ) + + const isProjectGroupReorderTarget = (group: SessionGroup) => + options.isActiveProjectDirectoryGroup(group) + + const getCurrentProjectOrderPaths = () => { + const environmentPaths = projectStore.environments.map((environment) => environment.path) + return environmentPaths.length > 0 + ? environmentPaths + : projectReorderableGroups.value.map(options.getWorkspacePath) + } + + const commitVisibleProjectGroupOrder = async (nextVisiblePaths: string[]) => { + const currentOrder = getCurrentProjectOrderPaths() + const previousVisiblePaths = projectReorderableGroups.value.map(options.getWorkspacePath) + const previousVisiblePathSet = new Set(previousVisiblePaths) + const nextVisiblePathSet = new Set(nextVisiblePaths) + const isPermutationOfVisiblePaths = + nextVisiblePaths.length === previousVisiblePaths.length && + previousVisiblePathSet.size === previousVisiblePaths.length && + nextVisiblePathSet.size === nextVisiblePaths.length && + nextVisiblePaths.every((path) => previousVisiblePathSet.has(path)) + if (!isPermutationOfVisiblePaths) { + console.warn('[WindowSideBar] Skipping project group reorder: visible group mismatch') + return + } + const nextOrder = [...currentOrder] + let nextVisibleIndex = 0 + + for (let index = 0; index < nextOrder.length; index += 1) { + if (!previousVisiblePathSet.has(nextOrder[index])) { + continue + } + + const nextPath = nextVisiblePaths[nextVisibleIndex] + if (nextPath) { + nextOrder[index] = nextPath + } + nextVisibleIndex += 1 + } + + for (const path of nextVisiblePaths) { + if (!nextOrder.includes(path)) { + nextOrder.push(path) + } + } + + await projectStore.reorderEnvironments(nextOrder) + } + + const handleProjectGroupModelUpdate = (nextGroups: SessionGroup[]) => { + if (!canReorderProjectGroups.value) { + return + } + + const nextVisiblePaths = nextGroups + .filter(options.isActiveProjectDirectoryGroup) + .map(options.getWorkspacePath) + void commitVisibleProjectGroupOrder(nextVisiblePaths).catch((error) => { + console.warn('[WindowSideBar] Failed to reorder project groups:', error) + }) + } + + const canMoveProjectGroup = (group: SessionGroup, delta: -1 | 1) => { + if (!canReorderProjectGroups.value || !isProjectGroupReorderTarget(group)) { + return false + } + + const groups = projectReorderableGroups.value + const index = groups.findIndex( + (candidate) => options.getGroupIdentifier(candidate) === options.getGroupIdentifier(group) + ) + if (index < 0) { + return false + } + + return delta < 0 ? index > 0 : index < groups.length - 1 + } + + const handleMoveProjectGroup = (group: SessionGroup, target: ProjectGroupMoveTarget) => { + if (!canReorderProjectGroups.value || !isProjectGroupReorderTarget(group)) { + return + } + + const paths = projectReorderableGroups.value.map(options.getWorkspacePath) + const currentIndex = paths.indexOf(options.getWorkspacePath(group)) + if (currentIndex < 0) { + return + } + + const [path] = paths.splice(currentIndex, 1) + const nextIndex = + target === 'top' + ? 0 + : target === 'bottom' + ? paths.length + : target === 'up' + ? Math.max(0, currentIndex - 1) + : Math.min(paths.length, currentIndex + 1) + + paths.splice(nextIndex, 0, path) + void commitVisibleProjectGroupOrder(paths).catch((error) => { + console.warn('[WindowSideBar] Failed to move project group:', error) + }) + } + + const handleProjectGroupDragStart = () => { + isProjectGroupDragging.value = true + projectGroupDragScrollTop.value = sessionListRef.value?.scrollTop ?? null + options.onDragStart?.() + } + + const handleProjectGroupDragEnd = () => { + void nextTick(() => { + restoreSessionListScrollTop(sessionListRef.value, projectGroupDragScrollTop.value) + projectGroupDragScrollTop.value = null + isProjectGroupDragging.value = false + void options.ensureSessionListFilled() + }) + } + + tryOnScopeDispose(() => { + isProjectGroupDragging.value = false + projectGroupDragScrollTop.value = null + }) + + return { + projectReorderableGroups, + canReorderProjectGroups, + isProjectGroupReorderTarget, + handleProjectGroupModelUpdate, + canMoveProjectGroup, + handleMoveProjectGroup, + handleProjectGroupDragStart, + handleProjectGroupDragEnd + } +} diff --git a/src/renderer/src/composables/sidebar/useSessionListAutoFill.ts b/src/renderer/src/composables/sidebar/useSessionListAutoFill.ts new file mode 100644 index 0000000000..d07989f474 --- /dev/null +++ b/src/renderer/src/composables/sidebar/useSessionListAutoFill.ts @@ -0,0 +1,169 @@ +import { nextTick, watch, type MaybeRefOrGetter, type Ref, toValue } from 'vue' +import { tryOnMounted, tryOnScopeDispose, useResizeObserver } from '@vueuse/core' +import type { useSessionStore } from '@/stores/ui/session' + +export const restoreSessionListScrollTop = ( + listElement: HTMLElement | null, + scrollTop: number | null +) => { + if (scrollTop === null || !listElement) { + return + } + + listElement.scrollTop = scrollTop +} + +interface UseSessionListAutoFillOptions { + sessionStore: ReturnType + sessionListRef: Ref + collapsed: MaybeRefOrGetter + canAutoFill: MaybeRefOrGetter + /** While true (e.g. during a group drag) scrolling and auto-fill are paused. */ + suspended: MaybeRefOrGetter + /** Extra reactive sources that should re-trigger the fill check when they change. */ + fillCheckSources: MaybeRefOrGetter[] +} + +/** + * Keeps the session list paginated: loads the next page near the scroll bottom, and when + * the first pages are too short to produce a scrollbar keeps loading until the viewport + * is filled (issue #1762). + */ +export function useSessionListAutoFill(options: UseSessionListAutoFillOptions) { + const { sessionStore, sessionListRef } = options + + let scrollFrame: number | null = null + let fillFrame: number | null = null + let isFillingSessionList = false + + const performSessionListScrollCheck = () => { + const listElement = sessionListRef.value + if ( + !listElement || + toValue(options.suspended) || + sessionStore.loadingMore || + !sessionStore.hasMore + ) { + return + } + + const distanceToBottom = + listElement.scrollHeight - listElement.scrollTop - listElement.clientHeight + + if (distanceToBottom <= 96) { + void sessionStore.loadNextPage() + } + } + + const handleSessionListScroll = () => { + if (scrollFrame !== null) { + return + } + + scrollFrame = window.requestAnimationFrame(() => { + scrollFrame = null + performSessionListScrollCheck() + }) + } + + const ensureSessionListFilled = async () => { + if ( + isFillingSessionList || + toValue(options.suspended) || + toValue(options.collapsed) || + !toValue(options.canAutoFill) + ) { + return + } + isFillingSessionList = true + try { + // 轮数上限兜底,避免异常情况下(如 cursor 不推进)陷入死循环。 + const MAX_FILL_ROUNDS = 50 + for (let round = 0; round < MAX_FILL_ROUNDS; round += 1) { + await nextTick() + const listElement = sessionListRef.value + if ( + !listElement || + toValue(options.suspended) || + toValue(options.collapsed) || + !toValue(options.canAutoFill) || + !sessionStore.hasMore || + sessionStore.loadingMore || + sessionStore.loading + ) { + return + } + // 内容高度已超过容器(存在可滚动空间),交还给滚动事件处理后续分页。 + if (listElement.scrollHeight > listElement.clientHeight + 1) { + return + } + const beforeCount = sessionStore.sessions.length + const beforeHasMore = sessionStore.hasMore + await sessionStore.loadNextPage() + if ( + beforeHasMore === sessionStore.hasMore && + sessionStore.hasMore && + sessionStore.sessions.length <= beforeCount + ) { + return + } + } + } finally { + isFillingSessionList = false + } + } + + const scheduleSessionListFillCheck = () => { + if (fillFrame !== null) { + return + } + + fillFrame = window.requestAnimationFrame(() => { + fillFrame = null + void ensureSessionListFilled() + }) + } + + // 会话列表内容或容器高度变化后,若视口仍未填满则继续加载,保证「滚动加载更多」 + // 在首屏内容过少时也能启动(issue #1762)。搜索仅过滤已加载会话,仍要求所有 + // 分组展开,避免因为筛选或隐藏行扫完剩余分页。 + watch( + [ + () => sessionStore.sessions.length, + () => sessionStore.hasMore, + () => sessionStore.loading, + () => sessionStore.groupMode, + () => toValue(options.collapsed), + ...options.fillCheckSources.map((source) => () => toValue(source)) + ], + () => { + scheduleSessionListFillCheck() + }, + { immediate: true } + ) + + useResizeObserver(sessionListRef, () => { + scheduleSessionListFillCheck() + }) + + tryOnMounted(() => { + scheduleSessionListFillCheck() + }) + + tryOnScopeDispose(() => { + if (scrollFrame !== null) { + window.cancelAnimationFrame(scrollFrame) + scrollFrame = null + } + + if (fillFrame !== null) { + window.cancelAnimationFrame(fillFrame) + fillFrame = null + } + }) + + return { + handleSessionListScroll, + ensureSessionListFilled + } +} diff --git a/src/renderer/src/composables/sidebar/useSessionPinFlight.ts b/src/renderer/src/composables/sidebar/useSessionPinFlight.ts new file mode 100644 index 0000000000..a0608c6885 --- /dev/null +++ b/src/renderer/src/composables/sidebar/useSessionPinFlight.ts @@ -0,0 +1,348 @@ +import { nextTick, ref, type Ref } from 'vue' +import { tryOnScopeDispose, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core' +import type { UISession, useSessionStore } from '@/stores/ui/session' +import { restoreSessionListScrollTop } from './useSessionListAutoFill' + +export type SessionItemRegion = 'pinned' | 'grouped' +export type PinFeedbackMode = 'pinning' | 'unpinning' + +const PIN_FEEDBACK_DURATION_MS: Record = { + pinning: 560, + unpinning: 460 +} +const PIN_FLIGHT_DURATION_MS = 460 +const PIN_TARGET_SETTLE_MAX_FRAMES = 10 +const PIN_TARGET_SETTLE_EPSILON_PX = 0.5 + +const getPinFeedbackMode = (nextPinned: boolean): PinFeedbackMode => + nextPinned ? 'pinning' : 'unpinning' + +type SessionItemRect = { + left: number + top: number + width: number + height: number +} + +interface UseSessionPinFlightOptions { + sessionStore: ReturnType + sessionListRef: Ref +} + +/** + * Animates a session row flying between the pinned section and its group when its pinned + * state toggles, then plays a short feedback pulse on the landed row. Respects the + * user's reduced-motion preference by committing the toggle without animation. + */ +export function useSessionPinFlight(options: UseSessionPinFlightOptions) { + const { sessionStore, sessionListRef } = options + + const pinFlightSessionId = ref(null) + const pinDockedSessionId = ref(null) + const pinFeedbackSessionId = ref(null) + const pinFeedbackMode = ref(null) + + const reducedMotion = usePreferredReducedMotion() + const prefersReducedMotion = () => reducedMotion.value === 'reduce' + + const pinFeedbackTimeout = useTimeoutFn( + () => { + pinFeedbackSessionId.value = null + pinFeedbackMode.value = null + }, + () => PIN_FEEDBACK_DURATION_MS[pinFeedbackMode.value ?? 'pinning'], + { immediate: false } + ) + + const clearPinFeedback = () => { + pinFeedbackTimeout.stop() + pinFeedbackSessionId.value = null + pinFeedbackMode.value = null + } + + const applyPinFeedback = (sessionId: string, nextPinned: boolean) => { + if (prefersReducedMotion()) { + clearPinFeedback() + return + } + + pinFeedbackTimeout.stop() + pinFeedbackSessionId.value = sessionId + pinFeedbackMode.value = getPinFeedbackMode(nextPinned) + pinFeedbackTimeout.start() + } + + const commitPinToggle = async (session: UISession, nextPinned: boolean, withFeedback = true) => { + await sessionStore.toggleSessionPinned(session.id, nextPinned) + if (withFeedback) { + applyPinFeedback(session.id, nextPinned) + } + await nextTick() + } + + const waitForAnimationFrame = () => + new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()) + }) + + const getSessionItemElement = (sessionId: string, region: SessionItemRegion) => + document.querySelector( + `.session-item[data-session-id="${sessionId}"][data-session-region="${region}"]` + ) + + const getPinPlaceholderElement = (sessionId: string, region: SessionItemRegion) => + document.querySelector( + `.session-item[data-session-id="${sessionId}"][data-session-region="${region}"][data-pin-placeholder="true"]` + ) + + const captureSessionItemRect = (element: HTMLElement | null): SessionItemRect | null => { + if (!element) { + return null + } + + const rect = element.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) { + return null + } + + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height + } + } + + const areSessionItemRectsEqual = (left: SessionItemRect, right: SessionItemRect) => + Math.abs(left.left - right.left) <= PIN_TARGET_SETTLE_EPSILON_PX && + Math.abs(left.top - right.top) <= PIN_TARGET_SETTLE_EPSILON_PX && + Math.abs(left.width - right.width) <= PIN_TARGET_SETTLE_EPSILON_PX && + Math.abs(left.height - right.height) <= PIN_TARGET_SETTLE_EPSILON_PX + + const waitForPinTargetPlaceholder = async ( + sessionId: string, + region: SessionItemRegion + ): Promise<{ element: HTMLElement; rect: SessionItemRect } | null> => { + let previousRect: SessionItemRect | null = null + + for (let frame = 0; frame < PIN_TARGET_SETTLE_MAX_FRAMES; frame += 1) { + await waitForAnimationFrame() + const element = getPinPlaceholderElement(sessionId, region) + const rect = captureSessionItemRect(element) + + if (!element || !rect) { + previousRect = null + continue + } + + if (previousRect && areSessionItemRectsEqual(previousRect, rect)) { + return { element, rect } + } + + previousRect = rect + } + + const fallbackElement = + getPinPlaceholderElement(sessionId, region) ?? getSessionItemElement(sessionId, region) + const fallbackRect = captureSessionItemRect(fallbackElement) + if (!fallbackElement || !fallbackRect) { + return null + } + + return { + element: fallbackElement, + rect: fallbackRect + } + } + + const getPinFlightAnimationOptions = (nextPinned: boolean) => + nextPinned + ? { + duration: PIN_FLIGHT_DURATION_MS, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)' + } + : { + duration: PIN_FLIGHT_DURATION_MS + 20, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)' + } + + const createPinFlightKeyframes = ( + deltaX: number, + deltaY: number, + scaleX: number, + scaleY: number, + nextPinned: boolean + ): Keyframe[] => { + const leadX = nextPinned ? deltaX * 0.82 : deltaX * 0.9 + const leadY = nextPinned ? deltaY * 0.78 : deltaY * 0.86 + const leadScaleX = nextPinned ? 1.018 : 1.008 + const leadScaleY = nextPinned ? 1.018 : 1.008 + + return [ + { + transform: 'translate3d(0, 0, 0) scale(1)', + opacity: 1, + offset: 0 + }, + { + transform: `translate3d(${leadX}px, ${leadY}px, 0) scale(${leadScaleX}, ${leadScaleY})`, + opacity: 1, + offset: nextPinned ? 0.68 : 0.74 + }, + { + transform: `translate3d(${deltaX}px, ${deltaY}px, 0) scale(${scaleX}, ${scaleY})`, + opacity: 1, + offset: 1 + } + ] + } + + const createPinFlightClone = (sourceElement: HTMLElement, sourceRect: DOMRect) => { + const clone = sourceElement.cloneNode(true) as HTMLElement + + clone.removeAttribute('style') + clone.classList.remove('is-hero-hidden') + delete clone.dataset.pinFx + delete clone.dataset.heroHidden + clone.setAttribute('aria-hidden', 'true') + clone.classList.add('sidebar-pin-flight') + Object.assign(clone.style, { + position: 'fixed', + left: `${sourceRect.left}px`, + top: `${sourceRect.top}px`, + width: `${sourceRect.width}px`, + height: `${sourceRect.height}px`, + margin: '0', + pointerEvents: 'none', + zIndex: '2147483647', + transformOrigin: 'top left', + willChange: 'transform', + contain: 'layout style paint' + }) + + return clone + } + + const animatePinFlight = async (session: UISession, nextPinned: boolean) => { + const sourceRegion: SessionItemRegion = session.isPinned ? 'pinned' : 'grouped' + const targetRegion: SessionItemRegion = nextPinned ? 'pinned' : 'grouped' + const sourceElement = getSessionItemElement(session.id, sourceRegion) + const sourceRect = sourceElement?.getBoundingClientRect() + const preservedScrollTop = sessionListRef.value?.scrollTop ?? null + + if (!sourceElement || !sourceRect || sourceRect.width === 0 || sourceRect.height === 0) { + await commitPinToggle(session, nextPinned) + return + } + + const clone = createPinFlightClone(sourceElement, sourceRect) + document.body.appendChild(clone) + pinFlightSessionId.value = session.id + if (!nextPinned) { + pinDockedSessionId.value = session.id + } + await nextTick() + + try { + await waitForAnimationFrame() + clone.dataset.pinState = 'docked' + await waitForAnimationFrame() + + await commitPinToggle(session, nextPinned, false) + restoreSessionListScrollTop(sessionListRef.value, preservedScrollTop) + await waitForAnimationFrame() + restoreSessionListScrollTop(sessionListRef.value, preservedScrollTop) + await waitForAnimationFrame() + + const targetSettledState = await waitForPinTargetPlaceholder(session.id, targetRegion) + const targetElement = targetSettledState?.element + const targetRect = targetSettledState?.rect + + if (!targetElement || !targetRect) { + clone.remove() + if (pinDockedSessionId.value === session.id) { + pinDockedSessionId.value = null + } + applyPinFeedback(session.id, nextPinned) + if (pinFlightSessionId.value === session.id) { + pinFlightSessionId.value = null + } + await nextTick() + return + } + + const deltaX = targetRect.left - sourceRect.left + const deltaY = targetRect.top - sourceRect.top + const scaleX = targetRect.width / sourceRect.width + const scaleY = targetRect.height / sourceRect.height + + const animation = clone.animate( + createPinFlightKeyframes(deltaX, deltaY, scaleX, scaleY, nextPinned), + { + ...getPinFlightAnimationOptions(nextPinned), + fill: 'forwards' + } + ) + + await animation.finished.catch(() => undefined) + clone.remove() + if (pinDockedSessionId.value === session.id) { + pinDockedSessionId.value = null + } + applyPinFeedback(session.id, nextPinned) + if (pinFlightSessionId.value === session.id) { + pinFlightSessionId.value = null + } + await nextTick() + } finally { + if (pinDockedSessionId.value === session.id) { + pinDockedSessionId.value = null + } + if (pinFlightSessionId.value === session.id) { + pinFlightSessionId.value = null + } + clone.remove() + } + } + + /** + * Pin toggles are serialized: a toggle requested while a flight is still animating + * starts only after that flight settles. The flight/docked ownership refs hold a + * single id, so concurrent flights would fight over them and unhide each other's + * source rows mid-animation. + */ + let pinToggleChain: Promise = Promise.resolve() + + const handleTogglePin = (session: UISession) => { + const queued = pinToggleChain.then(async () => { + const nextPinned = !session.isPinned + + try { + if (prefersReducedMotion()) { + await commitPinToggle(session, nextPinned) + return + } + + await animatePinFlight(session, nextPinned) + } catch (error) { + console.error('Failed to toggle pin status:', error) + } + }) + pinToggleChain = queued + return queued + } + + tryOnScopeDispose(() => { + pinFlightSessionId.value = null + pinDockedSessionId.value = null + clearPinFeedback() + }) + + return { + pinFlightSessionId, + pinDockedSessionId, + pinFeedbackSessionId, + pinFeedbackMode, + handleTogglePin + } +} diff --git a/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts b/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts new file mode 100644 index 0000000000..5a421d81dd --- /dev/null +++ b/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts @@ -0,0 +1,220 @@ +import { computed, ref, watch } from 'vue' +import { storeToRefs } from 'pinia' +import { tryOnMounted, tryOnScopeDispose, useDocumentVisibility, useTimeoutFn } from '@vueuse/core' +import type { Router } from 'vue-router' +import { createRemoteControlClient } from '@api/RemoteControlClient' +import type { createSettingsClient } from '@api/SettingsClient' +import type { RemoteChannel, RemoteRuntimeState } from '@shared/types/remote' +import type { usePluginCatalogStore } from '@/stores/pluginCatalog' + +const REMOTE_STATUS_ACTIVE_POLL_MS = 2_000 +const REMOTE_STATUS_IDLE_POLL_MS = 30_000 + +/** + * 'applied' committed a fresh snapshot; 'discarded' fetched fine but lost to a newer + * refresh or a concurrent store mutation (not an error, no backoff); 'failed' is a + * fetch error that counts toward backoff. + */ +type RemoteRefreshOutcome = 'applied' | 'discarded' | 'failed' + +interface UseSidebarRemoteControlOptions { + pluginCatalogStore: ReturnType + settingsClient: ReturnType + router: Router | undefined + t: (key: string) => string +} + +/** + * Remote-control button state for the sidebar rail: polls channel statuses (fast while a + * channel is enabled, slow otherwise, exponential backoff on errors, paused while the + * document is hidden) and derives the aggregate button presentation. + */ +export function useSidebarRemoteControl(options: UseSidebarRemoteControlOptions) { + const { pluginCatalogStore, settingsClient, router, t } = options + const remoteControlClient = createRemoteControlClient() + const { remoteChannels: remoteChannelDescriptors, remoteStatuses: remoteControlStatus } = + storeToRefs(pluginCatalogStore) + + let statusRefreshErrors = 0 + /** + * Increments per refresh run. A run that is no longer current (e.g. superseded by a + * hide/show-triggered refresh) must not write its snapshot, count toward backoff, + * or schedule the next poll — the newer run owns the outcome. + */ + let statusRefreshSequence = 0 + let disposed = false + + const remoteChannelIds = computed(() => + remoteChannelDescriptors.value.map((descriptor) => descriptor.id) + ) + const getRemoteChannelStatus = (channel: RemoteChannel) => remoteControlStatus.value[channel] + const showRemoteControlButton = computed(() => + remoteChannelIds.value.some((channel) => Boolean(getRemoteChannelStatus(channel)?.enabled)) + ) + const firstEnabledRemoteChannel = computed( + () => + remoteChannelIds.value.find((channel) => Boolean(getRemoteChannelStatus(channel)?.enabled)) ?? + null + ) + const aggregatedRemoteControlState = computed(() => { + const states = remoteChannelIds.value + .map((channel) => getRemoteChannelStatus(channel)) + .filter((status) => status?.enabled) + .map((status) => status?.state as RemoteRuntimeState) + + if (states.length === 0) { + return 'disabled' + } + if (states.includes('error')) { + return 'error' + } + if (states.includes('backoff')) { + return 'backoff' + } + if (states.includes('starting')) { + return 'starting' + } + if (states.includes('running')) { + return 'running' + } + if (states.includes('stopped')) { + return 'stopped' + } + return 'disabled' + }) + const remoteControlTooltip = computed(() => { + return remoteChannelIds.value + .map((channel) => { + const descriptor = remoteChannelDescriptors.value.find((item) => item.id === channel) + const title = descriptor ? t(descriptor.titleKey) : channel + const status = getRemoteChannelStatus(channel) + const statusText = + status?.enabled && status.state + ? t(`chat.sidebar.remoteControlStatus.${status.state}`) + : t('chat.sidebar.remoteControlDisabled') + return `${title}: ${statusText}` + }) + .join('\n') + }) + const remoteControlButtonClass = computed(() => { + const state = aggregatedRemoteControlState.value + + if (state === 'error') { + return 'border-red-500/40 bg-red-500/10 hover:bg-red-500/15' + } + + return 'border-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/15' + }) + const remoteControlIconClass = computed(() => { + const state = aggregatedRemoteControlState.value + + if (state === 'error') { + return 'text-red-600 dark:text-red-400' + } + + return ['text-emerald-600 dark:text-emerald-400', state === 'starting' ? 'animate-pulse' : ''] + }) + + const hasEnabledRemoteChannelStatus = () => + Object.values(remoteControlStatus.value).some((status) => status?.enabled === true) + + const refreshRemoteControlStatus = async (requestId: number): Promise => { + const version = pluginCatalogStore.captureRemoteRefresh() + try { + const descriptors = await remoteControlClient.listRemoteChannels() + const channels = descriptors.map((descriptor) => descriptor.id) + const statuses = await Promise.all( + channels.map((channel) => remoteControlClient.getChannelStatus(channel)) + ) + if (requestId !== statusRefreshSequence) { + return 'discarded' + } + return pluginCatalogStore.replaceRemoteSnapshot(descriptors, statuses, version) + ? 'applied' + : 'discarded' + } catch (error) { + console.warn('[WindowSideBar] Failed to refresh remote control status:', error) + return 'failed' + } + } + + const documentVisibility = useDocumentVisibility() + const pollDelayMs = ref(0) + const pollTimeout = useTimeoutFn(() => void runStatusRefresh(), pollDelayMs, { immediate: false }) + + const scheduleStatusRefresh = (delayMs = 0) => { + pollTimeout.stop() + if (disposed || documentVisibility.value === 'hidden') { + return + } + + if (delayMs <= 0) { + void runStatusRefresh() + return + } + + pollDelayMs.value = delayMs + pollTimeout.start() + } + + async function runStatusRefresh(): Promise { + const requestId = ++statusRefreshSequence + const outcome = await refreshRemoteControlStatus(requestId) + if (requestId !== statusRefreshSequence) { + return + } + if (outcome !== 'discarded') { + statusRefreshErrors = outcome === 'applied' ? 0 : statusRefreshErrors + 1 + } + + if (disposed || documentVisibility.value === 'hidden') { + return + } + const backoffMs = Math.min(30_000, 2_000 * 2 ** statusRefreshErrors) + scheduleStatusRefresh( + hasEnabledRemoteChannelStatus() + ? outcome === 'failed' + ? backoffMs + : REMOTE_STATUS_ACTIVE_POLL_MS + : REMOTE_STATUS_IDLE_POLL_MS + ) + } + + watch(documentVisibility, (visibility) => { + if (visibility === 'hidden') { + pollTimeout.stop() + return + } + + scheduleStatusRefresh() + }) + + const openRemoteSettings = async () => { + if (router?.hasRoute?.('plugins-detail') && firstEnabledRemoteChannel.value) { + await router.push({ + name: 'plugins-detail', + params: { pluginId: `remote:${firstEnabledRemoteChannel.value}` } + }) + return + } + + await settingsClient.openSettings({ routeName: 'settings-remote' }) + } + + tryOnMounted(() => { + scheduleStatusRefresh() + }) + + tryOnScopeDispose(() => { + disposed = true + pollTimeout.stop() + }) + + return { + showRemoteControlButton, + remoteControlTooltip, + remoteControlButtonClass, + remoteControlIconClass, + openRemoteSettings + } +} diff --git a/src/renderer/src/composables/sidebar/useSidebarSessionShortcuts.ts b/src/renderer/src/composables/sidebar/useSidebarSessionShortcuts.ts new file mode 100644 index 0000000000..621a7d5e21 --- /dev/null +++ b/src/renderer/src/composables/sidebar/useSidebarSessionShortcuts.ts @@ -0,0 +1,255 @@ +import { computed, ref, watch, type MaybeRefOrGetter, toValue } from 'vue' +import { + tryOnScopeDispose, + useDocumentVisibility, + useEventListener, + useTimeoutFn +} from '@vueuse/core' +import { createDeviceClient } from '@api/DeviceClient' +import type { SessionGroup, UISession } from '@/stores/ui/session' + +const SIDEBAR_SHORTCUT_BADGE_DELAY_MS = 500 +const SIDEBAR_SHORTCUT_MAX_ROWS = 10 + +type ShortcutPlatform = 'mac' | 'other' + +interface UseSidebarSessionShortcutsOptions { + collapsed: MaybeRefOrGetter + pinnedSessions: MaybeRefOrGetter + visibleGroups: MaybeRefOrGetter + isPinnedSectionCollapsed: MaybeRefOrGetter + isGroupCollapsed: (group: SessionGroup) => boolean + /** Session currently animated by the pin flight; excluded from the shortcut rows. */ + excludedSessionId: MaybeRefOrGetter + /** App-level overlays (spotlight, dialogs owned by the caller) that own the keyboard. */ + hasOwnOverlayOpen: () => boolean + selectSession: (sessionId: string) => void +} + +/** + * Cmd/Alt+digit shortcuts for the first visible sidebar rows: tracks the platform + * modifier, shows numbered badges after a short hold, and activates the matching + * session on digit press. All listeners are window-level and auto-disposed. + */ +export function useSidebarSessionShortcuts(options: UseSidebarSessionShortcutsOptions) { + const deviceClient = createDeviceClient() + + const shortcutPlatform = ref( + navigator.platform.toLowerCase().includes('mac') ? 'mac' : 'other' + ) + const shortcutModifierDown = ref(false) + const showShortcutBadges = ref(false) + + const visibleShortcutSessions = computed(() => { + if (toValue(options.collapsed)) { + return [] + } + + const sessions: UISession[] = [] + + if (!toValue(options.isPinnedSectionCollapsed)) { + sessions.push(...toValue(options.pinnedSessions)) + } + + for (const group of toValue(options.visibleGroups)) { + if (!options.isGroupCollapsed(group)) { + sessions.push(...group.sessions) + } + } + + return sessions + .filter((session) => session.id !== toValue(options.excludedSessionId)) + .slice(0, SIDEBAR_SHORTCUT_MAX_ROWS) + }) + + const getShortcutDigitForIndex = (index: number) => (index === 9 ? '0' : String(index + 1)) + + const getShortcutIndexForDigit = (digit: string) => (digit === '0' ? 9 : Number(digit) - 1) + + const getShortcutBadgeLabelForIndex = (index: number) => { + const digit = getShortcutDigitForIndex(index) + return shortcutPlatform.value === 'mac' ? `⌘${digit}` : `Alt+${digit}` + } + + const shortcutBadgeLabelBySessionId = computed(() => { + const labels = new Map() + + visibleShortcutSessions.value.forEach((session, index) => { + labels.set(session.id, getShortcutBadgeLabelForIndex(index)) + }) + + return labels + }) + + const getShortcutBadgeLabelForSession = (sessionId: string) => + shortcutBadgeLabelBySessionId.value.get(sessionId) ?? null + + const hasShortcutBadgeForSession = (sessionId: string) => + showShortcutBadges.value && shortcutBadgeLabelBySessionId.value.has(sessionId) + + const loadShortcutPlatform = async () => { + try { + const deviceInfo = await deviceClient.getDeviceInfo() + shortcutPlatform.value = deviceInfo.platform === 'darwin' ? 'mac' : 'other' + } catch (error) { + console.warn('[WindowSideBar] Failed to resolve shortcut platform:', error) + } + } + + const isEditableShortcutTarget = (target: EventTarget | null) => { + const element = target instanceof HTMLElement ? target : null + if (!element) { + return false + } + + return Boolean( + element.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') + ) + } + + const hasKeyboardOwningOverlay = () => + options.hasOwnOverlayOpen() || + document.querySelector('.chat-search-bar') !== null || + document.querySelector('[role="dialog"][aria-modal="true"]') !== null + + const shouldIgnoreSidebarShortcutEvent = (event: KeyboardEvent) => + toValue(options.collapsed) || + isEditableShortcutTarget(event.target) || + hasKeyboardOwningOverlay() + + const getPlatformModifierKey = () => (shortcutPlatform.value === 'mac' ? 'Meta' : 'Alt') + + const isPlatformModifierPressed = (event: KeyboardEvent) => + shortcutPlatform.value === 'mac' ? event.metaKey : event.altKey + + const isPlatformModifierOnlyKeydown = (event: KeyboardEvent) => { + if (event.repeat || shouldIgnoreSidebarShortcutEvent(event)) { + return false + } + + if (shortcutPlatform.value === 'mac') { + return ( + event.key === 'Meta' && event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey + ) + } + + return ( + event.key === 'Alt' && event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey + ) + } + + const isSidebarShortcutDigitEvent = (event: KeyboardEvent) => { + if (event.repeat || !/^[0-9]$/.test(event.key) || shouldIgnoreSidebarShortcutEvent(event)) { + return false + } + + if (shortcutPlatform.value === 'mac') { + return event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey + } + + return event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey + } + + const badgeRevealTimeout = useTimeoutFn( + () => { + if ( + shortcutModifierDown.value && + !toValue(options.collapsed) && + !hasKeyboardOwningOverlay() && + visibleShortcutSessions.value.length > 0 + ) { + showShortcutBadges.value = true + } + }, + SIDEBAR_SHORTCUT_BADGE_DELAY_MS, + { immediate: false } + ) + + const hideShortcutBadges = () => { + badgeRevealTimeout.stop() + shortcutModifierDown.value = false + showShortcutBadges.value = false + } + + const startShortcutBadgeTimer = () => { + if (badgeRevealTimeout.isPending.value || showShortcutBadges.value) { + return + } + + shortcutModifierDown.value = true + badgeRevealTimeout.start() + } + + const selectShortcutSession = (digit: string) => { + const shortcutIndex = getShortcutIndexForDigit(digit) + const targetSession = visibleShortcutSessions.value[shortcutIndex] + + if (targetSession) { + options.selectSession(targetSession.id) + } + } + + const handleWindowShortcutKeydown = (event: KeyboardEvent) => { + if (isPlatformModifierOnlyKeydown(event)) { + if (shortcutPlatform.value !== 'mac') { + event.preventDefault() + } + startShortcutBadgeTimer() + return + } + + if (badgeRevealTimeout.isPending.value && event.key !== getPlatformModifierKey()) { + badgeRevealTimeout.stop() + } + + if (!isSidebarShortcutDigitEvent(event)) { + return + } + + event.preventDefault() + event.stopPropagation() + selectShortcutSession(event.key) + } + + const handleWindowShortcutKeyup = (event: KeyboardEvent) => { + const modifierKey = getPlatformModifierKey() + if (event.key === modifierKey || !isPlatformModifierPressed(event)) { + if (shortcutPlatform.value !== 'mac' && event.key === modifierKey) { + event.preventDefault() + } + hideShortcutBadges() + } + } + + useEventListener(window, 'keydown', handleWindowShortcutKeydown) + useEventListener(window, 'keyup', handleWindowShortcutKeyup) + useEventListener(window, 'blur', hideShortcutBadges) + + const documentVisibility = useDocumentVisibility() + watch(documentVisibility, (visibility) => { + if (visibility === 'hidden') { + hideShortcutBadges() + } + }) + + watch( + () => toValue(options.collapsed), + (isCollapsed) => { + if (isCollapsed) { + hideShortcutBadges() + } + } + ) + + void loadShortcutPlatform() + + tryOnScopeDispose(() => { + hideShortcutBadges() + }) + + return { + getShortcutBadgeLabelForSession, + hasShortcutBadgeForSession, + hideShortcutBadges + } +} diff --git a/src/renderer/src/composables/sidebar/useSidebarWorkspaceActions.ts b/src/renderer/src/composables/sidebar/useSidebarWorkspaceActions.ts new file mode 100644 index 0000000000..709811111e --- /dev/null +++ b/src/renderer/src/composables/sidebar/useSidebarWorkspaceActions.ts @@ -0,0 +1,156 @@ +import { computed, nextTick, ref, type MaybeRefOrGetter, type Ref, toValue } from 'vue' +import { tryOnScopeDispose, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core' +import type { EnvironmentSummary } from '@shared/types/agent-interface' +import { normalizeWorkspacePath } from '@shared/utils/filesystem' +import { notifyRenderer } from '@renderer-notifications/rendererNotificationPort' +import type { useProjectStore } from '@/stores/ui/project' +import type { SessionGroup, useSessionStore } from '@/stores/ui/session' +import { CHAT_SECTION_GROUP_ID } from './useSidebarWorkspaceGroups' + +const WORKSPACE_REVEAL_HIGHLIGHT_MS = 900 + +type WorkspaceArchiveTarget = Pick + +interface UseSidebarWorkspaceActionsOptions { + sessionStore: ReturnType + projectStore: ReturnType + sessionListRef: Ref + searchQuery: Ref + defaultChatWorkspacePath: MaybeRefOrGetter + getWorkspaceEnvironment: (group: SessionGroup) => EnvironmentSummary | undefined + t: (key: string, values?: Record) => string +} + +/** + * Workspace management actions surfaced in the sidebar: registering a directory via the + * folder picker (with a scroll-into-view highlight on the new group) and archiving an + * active workspace behind a confirm dialog. + */ +export function useSidebarWorkspaceActions(options: UseSidebarWorkspaceActionsOptions) { + const { sessionStore, projectStore, sessionListRef, t } = options + + const isAddingWorkspace = ref(false) + const revealedWorkspaceGroupId = ref(null) + const archiveTargetWorkspace = ref(null) + const isArchivingWorkspace = ref(false) + + const archiveWorkspaceDialogOpen = computed({ + get: () => archiveTargetWorkspace.value !== null, + set: (open: boolean) => { + if (!open && !isArchivingWorkspace.value) { + archiveTargetWorkspace.value = null + } + } + }) + + const reducedMotion = usePreferredReducedMotion() + const revealHighlightTimeout = useTimeoutFn( + () => { + revealedWorkspaceGroupId.value = null + }, + WORKSPACE_REVEAL_HIGHLIGHT_MS, + { immediate: false } + ) + + const revealWorkspaceGroup = async (projectPath: string) => { + await nextTick() + const pathIdentity = normalizeWorkspacePath(projectPath) + const isChatWorkspace = pathIdentity === toValue(options.defaultChatWorkspacePath) + const groupId = isChatWorkspace ? CHAT_SECTION_GROUP_ID : pathIdentity + const groupTarget = Array.from( + sessionListRef.value?.querySelectorAll('[data-group-id]') ?? [] + ).find((element) => element.dataset.groupId === groupId) + const target = + groupTarget ?? + (isChatWorkspace + ? sessionListRef.value + ?.closest('[data-testid="window-sidebar"]') + ?.querySelector('[data-testid="app-new-chat-button"]') + : null) + + target?.scrollIntoView?.({ block: 'nearest' }) + target?.focus() + if (target && reducedMotion.value !== 'reduce') { + revealHighlightTimeout.stop() + revealedWorkspaceGroupId.value = groupId + revealHighlightTimeout.start() + } + } + + const handleAddWorkspace = async () => { + if (isAddingWorkspace.value) { + return + } + + isAddingWorkspace.value = true + try { + const selectedPath = await projectStore.openFolderPicker({ select: false }) + if (!selectedPath) { + return + } + + options.searchQuery.value = '' + await sessionStore.setGroupMode('project') + await revealWorkspaceGroup(selectedPath) + } catch (error) { + console.warn('[WindowSideBar] Failed to add workspace:', error) + notifyRenderer({ + kind: 'error', + code: 'chat.workspace.registrationFailed', + title: t('common.error.operationFailed'), + description: t('chat.sidebar.addWorkspaceFailed') + }) + } finally { + isAddingWorkspace.value = false + } + } + + const requestWorkspaceArchive = (group: SessionGroup) => { + const environment = options.getWorkspaceEnvironment(group) + if (environment?.status !== 'active' || isArchivingWorkspace.value) { + return + } + + archiveTargetWorkspace.value = { + path: environment.path, + name: environment.name + } + } + + const handleArchiveWorkspaceConfirm = async () => { + const target = archiveTargetWorkspace.value + if (!target || isArchivingWorkspace.value) { + return + } + + isArchivingWorkspace.value = true + try { + await projectStore.archiveEnvironment(target.path) + archiveTargetWorkspace.value = null + } catch (error) { + console.warn('[WindowSideBar] Failed to archive workspace:', error) + notifyRenderer({ + kind: 'error', + code: 'chat.workspace.archive.failed', + title: t('settings.environments.errors.archiveTitle') + }) + } finally { + isArchivingWorkspace.value = false + } + } + + tryOnScopeDispose(() => { + revealedWorkspaceGroupId.value = null + }) + + return { + isAddingWorkspace, + revealedWorkspaceGroupId, + archiveTargetWorkspace, + isArchivingWorkspace, + archiveWorkspaceDialogOpen, + handleAddWorkspace, + requestWorkspaceArchive, + handleArchiveWorkspaceConfirm + } +} diff --git a/src/renderer/src/composables/sidebar/useSidebarWorkspaceGroups.ts b/src/renderer/src/composables/sidebar/useSidebarWorkspaceGroups.ts new file mode 100644 index 0000000000..bb5102e7d4 --- /dev/null +++ b/src/renderer/src/composables/sidebar/useSidebarWorkspaceGroups.ts @@ -0,0 +1,429 @@ +import { computed, ref, watch, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue' +import type { EnvironmentSummary } from '@shared/types/agent-interface' +import { normalizeWorkspacePath } from '@shared/utils/filesystem' +import { disambiguateWorkspaceLabels } from '@shared/utils/workspaceLabels' +import type { useProjectStore } from '@/stores/ui/project' +import type { SessionGroup, UISession, useSessionStore } from '@/stores/ui/session' + +export const CHAT_SECTION_GROUP_ID = '__chat__' +export const NO_PROJECT_GROUP_ID = '__no_project__' + +export type SidebarWorkspaceGroup = SessionGroup & { + environment?: EnvironmentSummary +} + +interface UseSidebarWorkspaceGroupsOptions { + sessionStore: ReturnType + projectStore: ReturnType + selectedAgentId: MaybeRefOrGetter + searchQuery: MaybeRefOrGetter + /** While true (e.g. during a group drag) the collapse-state sync watcher is paused. */ + suspendCollapseSync: MaybeRefOrGetter +} + +/** + * Derives every sidebar grouping projection from the session and project stores: pinned + * rows, the chat section, workspace groups (environment merge, ordering, duplicate-label + * disambiguation) and the per-group collapse state. + */ +export function useSidebarWorkspaceGroups(options: UseSidebarWorkspaceGroupsOptions) { + const { sessionStore, projectStore } = options + + const isPinnedSectionCollapsed = ref(false) + const collapsedGroupIds = ref>(new Set()) + + const normalizedSessionSearchQuery = computed(() => + toValue(options.searchQuery).trim().toLowerCase() + ) + const matchesSessionSearch = (session: UISession) => { + if (!normalizedSessionSearchQuery.value) { + return true + } + + return session.title.toLowerCase().includes(normalizedSessionSearchQuery.value) + } + + const pinnedSessions = computed(() => + sessionStore.getPinnedSessions(toValue(options.selectedAgentId)).filter(matchesSessionSearch) + ) + const baseFilteredGroups = computed(() => + sessionStore + .getFilteredGroups(toValue(options.selectedAgentId)) + .map((group) => ({ + id: group.id, + label: group.label, + labelKey: group.labelKey, + sessions: group.sessions.filter(matchesSessionSearch) + })) + .filter((group) => group.sessions.length > 0) + ) + const defaultChatWorkspacePath = computed(() => + normalizeWorkspacePath(projectStore.defaultChatWorkspacePath) + ) + const projectOrderIndex = computed( + () => + new Map( + projectStore.environments.map((environment, index) => [ + normalizeWorkspacePath(environment.path), + index + ]) + ) + ) + const activeProjectEnvironmentByPath = computed( + () => + new Map( + projectStore.environments.map((environment) => [ + normalizeWorkspacePath(environment.path), + environment + ]) + ) + ) + const historicalProjectEnvironmentByPath = computed( + () => + new Map( + [...projectStore.archivedEnvironments, ...projectStore.removedEnvironments].map( + (environment) => [normalizeWorkspacePath(environment.path), environment] + ) + ) + ) + const selectableProjectPathSet = computed( + () => new Set(projectStore.projects.map((project) => normalizeWorkspacePath(project.path))) + ) + const isChatSession = (session: UISession) => { + const projectPath = normalizeWorkspacePath(session.projectDir) + return ( + projectPath.length === 0 || + (defaultChatWorkspacePath.value.length > 0 && projectPath === defaultChatWorkspacePath.value) + ) + } + const isChatProjectGroup = (group: SessionGroup) => + group.id === NO_PROJECT_GROUP_ID || + (defaultChatWorkspacePath.value.length > 0 && + normalizeWorkspacePath(group.id) === defaultChatWorkspacePath.value) + const isProjectDirectoryGroup = (group: SessionGroup) => + sessionStore.groupMode === 'project' && + group.id !== NO_PROJECT_GROUP_ID && + !group.labelKey && + !isChatProjectGroup(group) + const getWorkspaceEnvironment = (group: SessionGroup) => + (group as SidebarWorkspaceGroup).environment + const isActiveProjectDirectoryGroup = (group: SessionGroup) => + isProjectDirectoryGroup(group) && getWorkspaceEnvironment(group)?.status === 'active' + const isWorkspaceUnavailable = (group: SessionGroup) => + isActiveProjectDirectoryGroup(group) && getWorkspaceEnvironment(group)?.exists === false + const canStartConversationInProjectGroup = (group: SessionGroup) => + isActiveProjectDirectoryGroup(group) && !isWorkspaceUnavailable(group) + const isTrueEmptyWorkspaceGroup = (group: SessionGroup) => { + const environment = getWorkspaceEnvironment(group) + return ( + canStartConversationInProjectGroup(group) && + environment?.sessionCount === 0 && + group.sessions.length === 0 + ) + } + const compareProjectGroups = (left: SessionGroup, right: SessionGroup) => { + const leftRank = isActiveProjectDirectoryGroup(left) ? 0 : 1 + const rightRank = isActiveProjectDirectoryGroup(right) ? 0 : 1 + + if (leftRank !== rightRank) { + return leftRank - rightRank + } + + const leftOrder = + projectOrderIndex.value.get(normalizeWorkspacePath(left.id)) ?? Number.MAX_SAFE_INTEGER + const rightOrder = + projectOrderIndex.value.get(normalizeWorkspacePath(right.id)) ?? Number.MAX_SAFE_INTEGER + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder + } + + return 0 + } + const decorateWorkspaceGroup = ( + group: SessionGroup, + environment: EnvironmentSummary | undefined + ): SidebarWorkspaceGroup => ({ + ...group, + ...(environment ? { environment } : {}) + }) + const sortProjectGroups = (groups: SidebarWorkspaceGroup[]) => + [...groups].sort(compareProjectGroups) + const mergeProjectWorkspaceGroups = (sessionGroups: SessionGroup[]) => { + const decoratedSessionGroups = sessionGroups.map((group) => { + const pathIdentity = normalizeWorkspacePath(group.id) + const environment = + activeProjectEnvironmentByPath.value.get(pathIdentity) ?? + historicalProjectEnvironmentByPath.value.get(pathIdentity) + return decorateWorkspaceGroup(group, environment) + }) + + if (!projectStore.snapshotReady || normalizedSessionSearchQuery.value.length > 0) { + return sortProjectGroups(decoratedSessionGroups) + } + + const sessionGroupByPath = new Map( + decoratedSessionGroups.map((group) => [normalizeWorkspacePath(group.id), group]) + ) + const activeGroups = projectStore.environments + .filter( + (environment) => + normalizeWorkspacePath(environment.path) !== defaultChatWorkspacePath.value && + (!environment.isTemp || + selectableProjectPathSet.value.has(normalizeWorkspacePath(environment.path)) || + sessionGroupByPath.has(normalizeWorkspacePath(environment.path))) + ) + .map((environment): SidebarWorkspaceGroup => { + const pathIdentity = normalizeWorkspacePath(environment.path) + const sessionGroup = sessionGroupByPath.get(pathIdentity) + sessionGroupByPath.delete(pathIdentity) + return { + id: environment.path, + label: sessionGroup?.label ?? environment.name, + labelKey: sessionGroup?.labelKey, + sessions: sessionGroup?.sessions ?? [], + environment + } + }) + const historicalGroups = decoratedSessionGroups.filter((group) => + sessionGroupByPath.has(normalizeWorkspacePath(group.id)) + ) + + return [...activeGroups, ...historicalGroups] + } + const orderedFilteredGroups = computed(() => { + const groups = baseFilteredGroups.value + if (sessionStore.groupMode !== 'project') { + return groups + } + + const chatGroups = groups.filter(isChatProjectGroup) + const workspaceSessionGroups = groups.filter(isProjectDirectoryGroup) + return [...chatGroups, ...mergeProjectWorkspaceGroups(workspaceSessionGroups)] + }) + const compareSidebarSessions = (left: UISession, right: UISession) => { + const leftUpdatedAt = Number.isFinite(left.updatedAt) ? left.updatedAt : 0 + const rightUpdatedAt = Number.isFinite(right.updatedAt) ? right.updatedAt : 0 + if (leftUpdatedAt !== rightUpdatedAt) { + return rightUpdatedAt - leftUpdatedAt + } + + return left.title.localeCompare(right.title) || left.id.localeCompare(right.id) + } + const ensureSortedSessions = ( + sessions: UISession[], + compare: (left: UISession, right: UISession) => number + ) => { + for (let index = 1; index < sessions.length; index += 1) { + if (compare(sessions[index - 1], sessions[index]) > 0) { + return [...sessions].sort(compare) + } + } + + return sessions + } + const sessionSections = computed(() => { + if (sessionStore.groupMode === 'project') { + const chatSessions = ensureSortedSessions( + orderedFilteredGroups.value.filter(isChatProjectGroup).flatMap((group) => group.sessions), + compareSidebarSessions + ) + + return { + chatSessions, + workspaceGroups: orderedFilteredGroups.value + .filter(isProjectDirectoryGroup) + .map((group) => { + const sessions = ensureSortedSessions(group.sessions, compareSidebarSessions) + return sessions === group.sessions ? group : { ...group, sessions } + }) + } + } + + const chatSessions: UISession[] = [] + const workspaceGroups: SessionGroup[] = [] + for (const group of orderedFilteredGroups.value) { + const workspaceSessions: UISession[] = [] + for (const session of group.sessions) { + if (isChatSession(session)) { + chatSessions.push(session) + } else { + workspaceSessions.push(session) + } + } + + if (workspaceSessions.length > 0) { + workspaceGroups.push({ + ...group, + sessions: ensureSortedSessions(workspaceSessions, compareSidebarSessions) + }) + } + } + + return { + chatSessions: ensureSortedSessions(chatSessions, compareSidebarSessions), + workspaceGroups + } + }) + const chatSectionGroup = computed(() => { + const sessions = sessionSections.value.chatSessions + if (sessions.length === 0) { + return null + } + + return { + id: CHAT_SECTION_GROUP_ID, + label: 'chat.sidebar.chats', + labelKey: 'chat.sidebar.chats', + sessions + } + }) + const workspaceGroups = computed(() => { + const groups = sessionSections.value.workspaceGroups + // Duplicate basenames get the shortest parent suffix over the currently visible set, so + // `.../team-a/app` and `.../archive/app` render as `app · team-a` and `app · archive`. + const labelOverrides = disambiguateWorkspaceLabels( + groups + .filter((group) => isProjectDirectoryGroup(group)) + .map((group) => ({ id: normalizeWorkspacePath(group.id), label: group.label })) + ) + if (labelOverrides.size === 0) { + return groups + } + + return groups.map((group) => { + const label = labelOverrides.get(normalizeWorkspacePath(group.id)) + return label ? { ...group, label } : group + }) + }) + const visibleGroups = computed(() => [ + ...(chatSectionGroup.value ? [chatSectionGroup.value] : []), + ...workspaceGroups.value + ]) + + const getGroupIdentifier = (group: SessionGroup) => normalizeWorkspacePath(group.id) + const getWorkspacePath = (group: SessionGroup) => getWorkspaceEnvironment(group)?.path ?? group.id + + const getGroupIcon = (group: SessionGroup) => + isTrueEmptyWorkspaceGroup(group) + ? 'lucide:folder' + : isGroupCollapsed(group) + ? 'lucide:folder-closed' + : 'lucide:folder-open' + + const isGroupCollapsed = (group: SessionGroup) => + collapsedGroupIds.value.has(getGroupIdentifier(group)) + const getWorkspaceGroupAriaExpanded = (group: SessionGroup) => + isTrueEmptyWorkspaceGroup(group) ? undefined : !isGroupCollapsed(group) + + const canAutoFillSessionList = computed( + () => + normalizedSessionSearchQuery.value.length === 0 && + !isPinnedSectionCollapsed.value && + !visibleGroups.value.some(isGroupCollapsed) + ) + + const visibleSessionFingerprint = computed(() => + [ + isPinnedSectionCollapsed.value ? 'pinned:collapsed' : 'pinned:expanded', + ...pinnedSessions.value.map((session) => `pinned:${session.id}`), + ...visibleGroups.value.flatMap((group) => [ + `group:${getGroupIdentifier(group)}:${isGroupCollapsed(group) ? 'collapsed' : 'expanded'}`, + ...(!isGroupCollapsed(group) ? group.sessions.map((session) => session.id) : []) + ]) + ].join('|') + ) + + const togglePinnedSection = () => { + isPinnedSectionCollapsed.value = !isPinnedSectionCollapsed.value + } + + const toggleGroup = (group: SessionGroup) => { + const groupId = getGroupIdentifier(group) + const nextCollapsedGroupIds = new Set(collapsedGroupIds.value) + + if (nextCollapsedGroupIds.has(groupId)) { + nextCollapsedGroupIds.delete(groupId) + } else { + nextCollapsedGroupIds.add(groupId) + } + + collapsedGroupIds.value = nextCollapsedGroupIds + } + + watch( + [pinnedSessions, () => sessionStore.activeSessionId], + ([sessions, activeSessionId]) => { + if (sessions.length === 0) { + isPinnedSectionCollapsed.value = false + return + } + + if (activeSessionId && sessions.some((session) => session.id === activeSessionId)) { + isPinnedSectionCollapsed.value = false + } + }, + { immediate: true } + ) + + watch( + [visibleGroups, () => sessionStore.activeSessionId], + ([groups, activeSessionId]) => { + if (toValue(options.suspendCollapseSync)) { + return + } + + const validGroupIds = new Set( + groups.filter((group) => !isTrueEmptyWorkspaceGroup(group)).map(getGroupIdentifier) + ) + const nextCollapsedGroupIds = new Set( + [...collapsedGroupIds.value].filter((groupId) => validGroupIds.has(groupId)) + ) + + if (activeSessionId) { + const activeGroup = groups.find((group) => + group.sessions.some((session) => session.id === activeSessionId) + ) + + if (activeGroup) { + nextCollapsedGroupIds.delete(getGroupIdentifier(activeGroup)) + } + } + + const stateChanged = + nextCollapsedGroupIds.size !== collapsedGroupIds.value.size || + [...nextCollapsedGroupIds].some((groupId) => !collapsedGroupIds.value.has(groupId)) + + if (stateChanged) { + collapsedGroupIds.value = nextCollapsedGroupIds + } + }, + { immediate: true } + ) + + return { + normalizedSessionSearchQuery, + matchesSessionSearch, + pinnedSessions, + defaultChatWorkspacePath, + chatSectionGroup, + workspaceGroups: workspaceGroups as ComputedRef, + visibleGroups, + isPinnedSectionCollapsed, + isChatProjectGroup, + isProjectDirectoryGroup, + isActiveProjectDirectoryGroup, + isWorkspaceUnavailable, + canStartConversationInProjectGroup, + isTrueEmptyWorkspaceGroup, + getWorkspaceEnvironment, + getGroupIdentifier, + getWorkspacePath, + getGroupIcon, + isGroupCollapsed, + getWorkspaceGroupAriaExpanded, + canAutoFillSessionList, + visibleSessionFingerprint, + togglePinnedSection, + toggleGroup + } +} diff --git a/src/shared/utils/workspaceLabels.ts b/src/shared/utils/workspaceLabels.ts new file mode 100644 index 0000000000..b75745b53e --- /dev/null +++ b/src/shared/utils/workspaceLabels.ts @@ -0,0 +1,82 @@ +export interface WorkspaceLabelItem { + /** Unique workspace identity — the normalized directory path. */ + id: string + /** Compact basename-derived label currently displayed. */ + label: string +} + +/** + * Returns display-label overrides that keep duplicate workspace labels distinguishable by + * appending the shortest parent-path suffix, e.g. two `app` groups become `app · team-a` + * and `app · archive`. Labels that are already unique receive no override, so callers can + * keep their compact form. Windows and POSIX separators are treated interchangeably; the + * suffix always renders with `/`. + */ +export function disambiguateWorkspaceLabels(items: WorkspaceLabelItem[]): Map { + const overrides = new Map() + const buckets = new Map() + for (const item of items) { + const bucket = buckets.get(item.label) + if (bucket) { + bucket.push(item) + } else { + buckets.set(item.label, [item]) + } + } + + for (const bucket of buckets.values()) { + if (bucket.length < 2) { + continue + } + + const parentSegments = bucket.map((item) => + item.id + .split(/[\\/]+/) + .filter(Boolean) + .slice(0, -1) + ) + const maxDepth = Math.max(...parentSegments.map((segments) => segments.length)) + + // Resolve each workspace at the first depth where its own suffix is unique among the + // workspaces still colliding, so one deep collision cannot lengthen every label. + const resolvedContexts: (string | null)[] = parentSegments.map(() => null) + let unresolvedIndexes = bucket.map((_, index) => index) + for (let depth = 1; depth <= maxDepth && unresolvedIndexes.length > 0; depth += 1) { + const depthContexts = new Map() + const contextCounts = new Map() + for (const index of unresolvedIndexes) { + const context = parentSegments[index].slice(-depth).join('/') + depthContexts.set(index, context) + contextCounts.set(context, (contextCounts.get(context) ?? 0) + 1) + } + unresolvedIndexes = unresolvedIndexes.filter((index) => { + const context = depthContexts.get(index) ?? '' + if (context.length === 0 || contextCounts.get(context) !== 1) { + return true + } + resolvedContexts[index] = context + return false + }) + } + + const unresolvedParentlessCount = unresolvedIndexes.filter( + (index) => parentSegments[index].length === 0 + ).length + + bucket.forEach((item, index) => { + let context = resolvedContexts[index] + if (context === null) { + // Identical parent chains cannot be separated by a suffix; fall back to the full + // normalized path so every rendered label stays unique. A single parentless + // duplicate keeps its compact label instead. + context = + parentSegments[index].length === 0 && unresolvedParentlessCount === 1 ? '' : item.id + } + if (context) { + overrides.set(item.id, `${item.label} · ${context}`) + } + }) + } + + return overrides +} diff --git a/test/main/shared/workspaceLabels.test.ts b/test/main/shared/workspaceLabels.test.ts new file mode 100644 index 0000000000..4bee34ced9 --- /dev/null +++ b/test/main/shared/workspaceLabels.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { disambiguateWorkspaceLabels } from '@shared/utils/workspaceLabels' + +describe('disambiguateWorkspaceLabels', () => { + it('keeps unique labels untouched', () => { + const overrides = disambiguateWorkspaceLabels([ + { id: '/work/app', label: 'app' }, + { id: '/work/design', label: 'design' } + ]) + + expect(overrides.size).toBe(0) + }) + + it('appends the immediate parent for duplicate labels only', () => { + const overrides = disambiguateWorkspaceLabels([ + { id: '/work/team-a/app', label: 'app' }, + { id: '/work/archive/app', label: 'app' }, + { id: '/work/design', label: 'design' } + ]) + + expect(overrides.get('/work/team-a/app')).toBe('app · team-a') + expect(overrides.get('/work/archive/app')).toBe('app · archive') + expect(overrides.has('/work/design')).toBe(false) + }) + + it('walks up shared parent segments until every label is unique', () => { + const overrides = disambiguateWorkspaceLabels([ + { id: '/team-a/x/app', label: 'app' }, + { id: '/team-b/x/app', label: 'app' }, + { id: '/c/app', label: 'app' } + ]) + + expect(overrides.get('/team-a/x/app')).toBe('app · team-a/x') + expect(overrides.get('/team-b/x/app')).toBe('app · team-b/x') + expect(overrides.get('/c/app')).toBe('app · c') + }) + + it('resolves each workspace at its own shortest unique suffix', () => { + const overrides = disambiguateWorkspaceLabels([ + { id: '/root/team/shared/app', label: 'app' }, + { id: '/root/archive/shared/app', label: 'app' }, + { id: '/root/client/unique/app', label: 'app' } + ]) + + expect(overrides.get('/root/team/shared/app')).toBe('app · team/shared') + expect(overrides.get('/root/archive/shared/app')).toBe('app · archive/shared') + expect(overrides.get('/root/client/unique/app')).toBe('app · unique') + }) + + it('treats Windows and POSIX separators consistently', () => { + const overrides = disambiguateWorkspaceLabels([ + { id: 'C:\\work\\app', label: 'app' }, + { id: '/work/app', label: 'app' } + ]) + + expect(overrides.get('C:\\work\\app')).toBe('app · C:/work') + expect(overrides.get('/work/app')).toBe('app · work') + }) + + it('keeps a parentless duplicate on its compact label', () => { + const overrides = disambiguateWorkspaceLabels([ + { id: '/app', label: 'app' }, + { id: '/work/app', label: 'app' } + ]) + + expect(overrides.has('/app')).toBe(false) + expect(overrides.get('/work/app')).toBe('app · work') + }) + + it('drops overrides once a removed environment ends the collision', () => { + const before = disambiguateWorkspaceLabels([ + { id: '/work/team-a/app', label: 'app' }, + { id: '/work/archive/app', label: 'app' } + ]) + expect(before.size).toBe(2) + + const after = disambiguateWorkspaceLabels([{ id: '/work/team-a/app', label: 'app' }]) + expect(after.size).toBe(0) + }) +}) diff --git a/test/renderer/components/WindowSideBar.test.ts b/test/renderer/components/WindowSideBar.test.ts index 13fd076de9..65ce6a7873 100644 --- a/test/renderer/components/WindowSideBar.test.ts +++ b/test/renderer/components/WindowSideBar.test.ts @@ -491,6 +491,11 @@ const setup = async (options: SetupOptions = {}) => { template: '
' }) + // Mirrors reka-ui renderless roots / as-child triggers, which add no DOM wrapper. + const slotOnlyStub = defineComponent({ + template: '' + }) + const dialogStub = defineComponent({ props: { open: { @@ -577,9 +582,9 @@ const setup = async (options: SetupOptions = {}) => { plugins: [createPinia()], stubs: { TooltipProvider: passthrough, - Tooltip: passthrough, + Tooltip: slotOnlyStub, TooltipContent: passthrough, - TooltipTrigger: passthrough, + TooltipTrigger: slotOnlyStub, ContextMenu: passthrough, ContextMenuTrigger: passthrough, ContextMenuContent: passthrough, @@ -970,6 +975,67 @@ describe('WindowSideBar agent switch', () => { expect(wrapper.findAll('[data-group-id="/work/new"]')).toHaveLength(1) }) + it('disambiguates duplicate workspace labels with minimal parent context', async () => { + const { wrapper, projectStore } = await setup({ + groupMode: 'project', + groups: [ + { + id: '/work/team-a/app', + label: 'app', + sessions: [ + { + id: 'session-a', + title: 'Session A', + status: 'none', + projectDir: '/work/team-a/app', + updatedAt: 100 + } + ] + }, + { + id: '/work/design', + label: 'design', + sessions: [ + { + id: 'session-d', + title: 'Session D', + status: 'none', + projectDir: '/work/design', + updatedAt: 90 + } + ] + } + ], + projectEnvironments: [ + { path: '/work/team-a/app', sessionCount: 1 }, + { path: '/work/archive/app', sessionCount: 0 }, + { path: '/work/design', sessionCount: 1 } + ] + }) + + const teamGroup = wrapper.get('[data-group-id="/work/team-a/app"]') + const archiveGroup = wrapper.get('[data-group-id="/work/archive/app"]') + const designGroup = wrapper.get('[data-group-id="/work/design"]') + expect(teamGroup.text()).toContain('app · team-a') + expect(archiveGroup.text()).toContain('app · archive') + expect(designGroup.text()).not.toContain('design ·') + + // The full path lives in the focus/hover tooltip, not in title or the accessible name. + expect(teamGroup.attributes('title')).toBeUndefined() + expect(teamGroup.text()).not.toContain('/work/team-a/app') + const pathTooltips = wrapper + .findAll('[data-testid="workspace-path-tooltip"]') + .map((tooltip) => tooltip.text()) + expect(pathTooltips).toContain('/work/team-a/app') + expect(pathTooltips).toContain('/work/archive/app') + + projectStore.environments.splice(1, 1) + await flushPromises() + + expect(wrapper.find('[data-group-id="/work/archive/app"]').exists()).toBe(false) + expect(wrapper.get('[data-group-id="/work/team-a/app"]').text()).not.toContain('app · team-a') + }) + it('merges a project snapshot into an earlier session-derived row without duplication', async () => { const { wrapper, projectStore } = await setup({ groupMode: 'project', @@ -1241,6 +1307,48 @@ describe('WindowSideBar agent switch', () => { TEST_TIMEOUT_MS ) + it( + 'serializes overlapping pin toggles across two sessions', + async () => { + const sessionA = { id: 'pin-a', title: 'Pin A', status: 'none', isPinned: false } + const sessionB = { id: 'pin-b', title: 'Pin B', status: 'none', isPinned: false } + const { wrapper, sessionStore } = await setup({ + groups: [ + { + id: 'common.time.today', + label: 'common.time.today', + labelKey: 'common.time.today', + sessions: [sessionA, sessionB] + } + ] + }) + + let resolveFirstToggle: (() => void) | undefined + sessionStore.toggleSessionPinned.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstToggle = () => resolve() + }) + ) + + const items = wrapper.findAllComponents({ name: 'WindowSideBarSessionItem' }) + items[0].vm.$emit('toggle-pin', sessionA) + items[1].vm.$emit('toggle-pin', sessionB) + await flushPromises() + + // The second toggle must wait until the first flight fully settles. + expect(sessionStore.toggleSessionPinned).toHaveBeenCalledTimes(1) + expect(sessionStore.toggleSessionPinned).toHaveBeenCalledWith('pin-a', true) + + resolveFirstToggle?.() + await flushPromises() + + expect(sessionStore.toggleSessionPinned).toHaveBeenCalledTimes(2) + expect(sessionStore.toggleSessionPinned).toHaveBeenLastCalledWith('pin-b', true) + }, + TEST_TIMEOUT_MS + ) + it( 'toggles spotlight from the expanded sidebar search command', async () => { @@ -1494,20 +1602,22 @@ describe('WindowSideBar agent switch', () => { } ] }) - const event = new KeyboardEvent('keydown', { - key: '1', - metaKey: true, - bubbles: true, - cancelable: true - }) const input = document.createElement('input') - Object.defineProperty(event, 'target', { value: input }) - - ;(wrapper.vm as any).handleWindowShortcutKeydown(event) + document.body.appendChild(input) + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: '1', + metaKey: true, + bubbles: true, + cancelable: true + }) + ) await flushPromises() + input.remove() expect(sessionStore.selectSession).not.toHaveBeenCalled() + expect(wrapper.find('[data-testid="sidebar-session-shortcut-badge"]').exists()).toBe(false) }, TEST_TIMEOUT_MS ) @@ -2545,7 +2655,11 @@ describe('WindowSideBar agent switch', () => { ]) remoteControlClient.getChannelStatus.mockRejectedValueOnce(new Error('IPC unavailable')) - await expect((wrapper.vm as any).refreshRemoteControlStatus()).resolves.toBe(false) + // Advance to the next active poll tick so the failing refresh runs through the + // real scheduling path instead of a direct internal call. + vi.advanceTimersByTime(2_000) + await flushPromises() + await wrapper.find('[data-testid="remote-control-button"]').trigger('click') expect(router.push).toHaveBeenLastCalledWith({ @@ -2561,6 +2675,66 @@ describe('WindowSideBar agent switch', () => { wrapper.unmount() }) + it('drops a stale overlapping refresh so it cannot overwrite newer status', async () => { + const { wrapper, remoteControlClient } = await setup({ + remoteStatus: { + enabled: true, + state: 'running' + } + }) + + let resolveStaleTelegramStatus: (() => void) | undefined + remoteControlClient.getChannelStatus.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStaleTelegramStatus = () => + resolve({ + channel: 'telegram' as const, + enabled: true, + state: 'stopped' as const, + pollOffset: 0, + bindingCount: 0, + allowedUserCount: 0, + lastError: null, + botUser: null + }) + }) + ) + + // Poll tick starts a refresh whose telegram status request hangs. + vi.advanceTimersByTime(2_000) + await flushPromises() + + const setDocumentVisibility = (state: DocumentVisibilityState) => { + Object.defineProperty(document, 'visibilityState', { configurable: true, value: state }) + document.dispatchEvent(new Event('visibilitychange')) + } + + try { + // Hiding and showing the document starts a second refresh while the first is pending. + setDocumentVisibility('hidden') + await flushPromises() + setDocumentVisibility('visible') + await flushPromises() + + expect(wrapper.find('[data-testid="remote-control-button"]').attributes('title')).toContain( + 'chat.sidebar.remoteControlStatus.running' + ) + + // The first refresh resolves last with stale data; its snapshot must be discarded. + resolveStaleTelegramStatus?.() + await flushPromises() + + expect(wrapper.find('[data-testid="remote-control-button"]').attributes('title')).toContain( + 'chat.sidebar.remoteControlStatus.running' + ) + } finally { + Reflect.deleteProperty(document, 'visibilityState') + } + + wrapper.unmount() + }) + it('routes to the first enabled remote plugin when remote button is clicked', async () => { const { wrapper, settingsClient, router } = await setup({ remoteStatus: { diff --git a/test/renderer/composables/chat/chatScrollArchitecture.test.ts b/test/renderer/composables/chat/chatScrollArchitecture.test.ts index 620ab874df..780980f2b5 100644 --- a/test/renderer/composables/chat/chatScrollArchitecture.test.ts +++ b/test/renderer/composables/chat/chatScrollArchitecture.test.ts @@ -21,7 +21,6 @@ const scrollWritePatterns: ReadonlyArray<[ScrollWriteKind, RegExp]> = [ // These target independent surfaces such as the sidebar, editor, popovers, page capture, // or document anchors. Any new direct renderer scroll API must be reviewed explicitly. const allowedDirectScrollWrites: Record = { - 'src/renderer/src/components/WindowSideBar.vue': ['scrollTop'], 'src/renderer/src/components/chat/ChatInputBox.vue': ['scrollIntoView', 'scrollIntoView'], 'src/renderer/src/components/chat/mentions/SuggestionList.vue': ['scrollIntoView'], 'src/renderer/src/components/markdown/useMarkdownLinkNavigation.ts': [ @@ -29,6 +28,7 @@ const allowedDirectScrollWrites: Record = { 'scrollIntoView' ], 'src/renderer/src/components/spotlight/SpotlightOverlay.vue': ['scrollIntoView'], + 'src/renderer/src/composables/sidebar/useSessionListAutoFill.ts': ['scrollTop'], 'src/renderer/src/composables/usePageCapture.ts': ['scrollTop', 'scrollTo'], 'src/renderer/src/lib/chatSearch.ts': ['scrollIntoView', 'scrollIntoView'] }