From 6e5c278bd52d73213204d5ecc3634d6979a45cbf Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 11 Aug 2026 18:47:42 +0800 Subject: [PATCH 1/5] feat(chat): add workspace label disambiguation util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate workspace basenames get the shortest parent-path suffix (app · team-a / app · archive) computed over any given set of labels. Windows and POSIX separators are handled consistently; unique labels keep their compact form. (#2121) --- src/shared/utils/workspaceLabels.ts | 65 ++++++++++++++++++++++ test/main/shared/workspaceLabels.test.ts | 68 ++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/shared/utils/workspaceLabels.ts create mode 100644 test/main/shared/workspaceLabels.test.ts diff --git a/src/shared/utils/workspaceLabels.ts b/src/shared/utils/workspaceLabels.ts new file mode 100644 index 0000000000..c95b1b8f0e --- /dev/null +++ b/src/shared/utils/workspaceLabels.ts @@ -0,0 +1,65 @@ +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)) + + let contexts = parentSegments.map(() => '') + for (let depth = 1; depth <= maxDepth; depth += 1) { + contexts = parentSegments.map((segments) => segments.slice(-depth).join('/')) + if (new Set(contexts).size === bucket.length) { + break + } + } + + const contextCounts = new Map() + for (const context of contexts) { + contextCounts.set(context, (contextCounts.get(context) ?? 0) + 1) + } + + bucket.forEach((item, index) => { + const context = contexts[index] + // Identical parent chains cannot be separated by a suffix; fall back to the full + // normalized path so every rendered label stays unique. + const uniqueContext = contextCounts.get(context) === 1 ? context : item.id + if (uniqueContext) { + overrides.set(item.id, `${item.label} · ${uniqueContext}`) + } + }) + } + + return overrides +} diff --git a/test/main/shared/workspaceLabels.test.ts b/test/main/shared/workspaceLabels.test.ts new file mode 100644 index 0000000000..3df850c3a8 --- /dev/null +++ b/test/main/shared/workspaceLabels.test.ts @@ -0,0 +1,68 @@ +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('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) + }) +}) From 29f89c102b0c83a83de93980fbb71ccf58bcfd2b Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 11 Aug 2026 18:48:58 +0800 Subject: [PATCH 2/5] refactor(chat): split sidebar into composables Extract the 2.5k-line WindowSideBar script into seven sidebar composables (workspace groups, pin flight, list auto-fill, session shortcuts, group reorder, workspace actions, remote control) and replace raw window/document API usage with VueUse primitives (useEventListener, useDocumentVisibility, useResizeObserver, useTimeoutFn, usePreferredReducedMotion). Also lands in the sidebar itself: - duplicate workspace labels rendered with minimal parent context over the visible group set, full normalized path on the group header title and an sr-only span (#2121) - a subtle chat-section card background plus a divider above the workspace section so the two lists read separately Template and DOM structure are unchanged. The two tests that poked component internals via wrapper.vm now go through real window events and the poll timer instead. --- src/renderer/src/components/WindowSideBar.vue | 1597 ++--------------- .../sidebar/useProjectGroupReorder.ts | 176 ++ .../sidebar/useSessionListAutoFill.ts | 169 ++ .../sidebar/useSessionPinFlight.ts | 330 ++++ .../sidebar/useSidebarRemoteControl.ts | 197 ++ .../sidebar/useSidebarSessionShortcuts.ts | 255 +++ .../sidebar/useSidebarWorkspaceActions.ts | 156 ++ .../sidebar/useSidebarWorkspaceGroups.ts | 429 +++++ .../renderer/components/WindowSideBar.test.ts | 79 +- 9 files changed, 1937 insertions(+), 1451 deletions(-) create mode 100644 src/renderer/src/composables/sidebar/useProjectGroupReorder.ts create mode 100644 src/renderer/src/composables/sidebar/useSessionListAutoFill.ts create mode 100644 src/renderer/src/composables/sidebar/useSessionPinFlight.ts create mode 100644 src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts create mode 100644 src/renderer/src/composables/sidebar/useSidebarSessionShortcuts.ts create mode 100644 src/renderer/src/composables/sidebar/useSidebarWorkspaceActions.ts create mode 100644 src/renderer/src/composables/sidebar/useSidebarWorkspaceGroups.ts diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index 6efd895545..df62ba274e 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -284,7 +284,7 @@ -
+
+
{{ t('chat.sidebar.workspace') }}
@@ -414,6 +421,7 @@ : '' " :data-group-id="getGroupIdentifier(group)" + :title="isProjectDirectoryGroup(group) ? getGroupIdentifier(group) : undefined" :aria-expanded="getWorkspaceGroupAriaExpanded(group)" @click="handleWorkspaceGroupClick(group)" > @@ -428,6 +436,9 @@ {{ getGroupLabel(group) }} + + {{ getGroupIdentifier(group) }} + diff --git a/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts b/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts new file mode 100644 index 0000000000..8ce891d74f --- /dev/null +++ b/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts @@ -0,0 +1,176 @@ +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 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..a344f5d412 --- /dev/null +++ b/src/renderer/src/composables/sidebar/useSessionPinFlight.ts @@ -0,0 +1,330 @@ +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) + 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) + pinFlightSessionId.value = null + await nextTick() + } finally { + if (pinDockedSessionId.value === session.id) { + pinDockedSessionId.value = null + } + pinFlightSessionId.value = null + clone.remove() + } + } + + const handleTogglePin = async (session: UISession) => { + 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) + } + } + + 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..6c168ce451 --- /dev/null +++ b/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts @@ -0,0 +1,197 @@ +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 + +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 + 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 (): 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)) + ) + pluginCatalogStore.replaceRemoteSnapshot(descriptors, statuses, version) + return true + } catch (error) { + console.warn('[WindowSideBar] Failed to refresh remote control status:', error) + return false + } + } + + 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 refreshed = await refreshRemoteControlStatus() + statusRefreshErrors = refreshed ? 0 : statusRefreshErrors + 1 + + if (disposed || documentVisibility.value === 'hidden') { + return + } + const backoffMs = Math.min(30_000, 2_000 * 2 ** statusRefreshErrors) + scheduleStatusRefresh( + hasEnabledRemoteChannelStatus() + ? refreshed + ? REMOTE_STATUS_ACTIVE_POLL_MS + : backoffMs + : 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/test/renderer/components/WindowSideBar.test.ts b/test/renderer/components/WindowSideBar.test.ts index 13fd076de9..0a2e5b1dc1 100644 --- a/test/renderer/components/WindowSideBar.test.ts +++ b/test/renderer/components/WindowSideBar.test.ts @@ -970,6 +970,59 @@ 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 ·') + expect(teamGroup.attributes('title')).toBe('/work/team-a/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', @@ -1494,20 +1547,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 +2600,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({ From dd088d93df42d790c3c4d5c2d035b3b986ffe81f Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 11 Aug 2026 20:18:11 +0800 Subject: [PATCH 3/5] test(renderer): update scroll write allowlist The sidebar's only scrollTop assignment moved from WindowSideBar.vue into useSessionListAutoFill.ts during the composable split; re-register it under the new path. --- test/renderer/composables/chat/chatScrollArchitecture.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'] } From a0ea81cb944d92ca1e2786e2f34612dddb819a92 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Wed, 12 Aug 2026 13:35:13 +0800 Subject: [PATCH 4/5] fix(renderer): harden sidebar composables against races - Skip the project group reorder commit when the visible path count mismatches, so a short list cannot persist a duplicated order - Only clear pinFlightSessionId when still owned by the finishing flight, mirroring the pinDockedSessionId guards - Drop superseded remote status refreshes so a stale overlapping request cannot replace a newer snapshot, count toward backoff, or fork the poll chain; add a regression test for the overlap Addresses CodeRabbit review feedback on #2142. Co-Authored-By: Claude Fable 5 --- .../sidebar/useProjectGroupReorder.ts | 4 ++ .../sidebar/useSessionPinFlight.ts | 12 +++- .../sidebar/useSidebarRemoteControl.ts | 17 +++++- .../renderer/components/WindowSideBar.test.ts | 60 +++++++++++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts b/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts index 8ce891d74f..c3250214fa 100644 --- a/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts +++ b/src/renderer/src/composables/sidebar/useProjectGroupReorder.ts @@ -62,6 +62,10 @@ export function useProjectGroupReorder(options: UseProjectGroupReorderOptions) { const commitVisibleProjectGroupOrder = async (nextVisiblePaths: string[]) => { const currentOrder = getCurrentProjectOrderPaths() const previousVisiblePaths = projectReorderableGroups.value.map(options.getWorkspacePath) + if (nextVisiblePaths.length !== previousVisiblePaths.length) { + console.warn('[WindowSideBar] Skipping project group reorder: visible group count mismatch') + return + } const previousVisiblePathSet = new Set(previousVisiblePaths) const nextOrder = [...currentOrder] let nextVisibleIndex = 0 diff --git a/src/renderer/src/composables/sidebar/useSessionPinFlight.ts b/src/renderer/src/composables/sidebar/useSessionPinFlight.ts index a344f5d412..421a4bdc65 100644 --- a/src/renderer/src/composables/sidebar/useSessionPinFlight.ts +++ b/src/renderer/src/composables/sidebar/useSessionPinFlight.ts @@ -264,7 +264,9 @@ export function useSessionPinFlight(options: UseSessionPinFlightOptions) { pinDockedSessionId.value = null } applyPinFeedback(session.id, nextPinned) - pinFlightSessionId.value = null + if (pinFlightSessionId.value === session.id) { + pinFlightSessionId.value = null + } await nextTick() return } @@ -288,13 +290,17 @@ export function useSessionPinFlight(options: UseSessionPinFlightOptions) { pinDockedSessionId.value = null } applyPinFeedback(session.id, nextPinned) - pinFlightSessionId.value = null + if (pinFlightSessionId.value === session.id) { + pinFlightSessionId.value = null + } await nextTick() } finally { if (pinDockedSessionId.value === session.id) { pinDockedSessionId.value = null } - pinFlightSessionId.value = null + if (pinFlightSessionId.value === session.id) { + pinFlightSessionId.value = null + } clone.remove() } } diff --git a/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts b/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts index 6c168ce451..a20d684f3d 100644 --- a/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts +++ b/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts @@ -29,6 +29,12 @@ export function useSidebarRemoteControl(options: UseSidebarRemoteControlOptions) 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(() => @@ -105,7 +111,7 @@ export function useSidebarRemoteControl(options: UseSidebarRemoteControlOptions) const hasEnabledRemoteChannelStatus = () => Object.values(remoteControlStatus.value).some((status) => status?.enabled === true) - const refreshRemoteControlStatus = async (): Promise => { + const refreshRemoteControlStatus = async (requestId: number): Promise => { const version = pluginCatalogStore.captureRemoteRefresh() try { const descriptors = await remoteControlClient.listRemoteChannels() @@ -113,6 +119,9 @@ export function useSidebarRemoteControl(options: UseSidebarRemoteControlOptions) const statuses = await Promise.all( channels.map((channel) => remoteControlClient.getChannelStatus(channel)) ) + if (requestId !== statusRefreshSequence) { + return true + } pluginCatalogStore.replaceRemoteSnapshot(descriptors, statuses, version) return true } catch (error) { @@ -141,7 +150,11 @@ export function useSidebarRemoteControl(options: UseSidebarRemoteControlOptions) } async function runStatusRefresh(): Promise { - const refreshed = await refreshRemoteControlStatus() + const requestId = ++statusRefreshSequence + const refreshed = await refreshRemoteControlStatus(requestId) + if (requestId !== statusRefreshSequence) { + return + } statusRefreshErrors = refreshed ? 0 : statusRefreshErrors + 1 if (disposed || documentVisibility.value === 'hidden') { diff --git a/test/renderer/components/WindowSideBar.test.ts b/test/renderer/components/WindowSideBar.test.ts index 0a2e5b1dc1..77b145ad95 100644 --- a/test/renderer/components/WindowSideBar.test.ts +++ b/test/renderer/components/WindowSideBar.test.ts @@ -2620,6 +2620,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: { From d6bb5057d77339a50d25b8d0d183fb404977b18a Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Wed, 12 Aug 2026 17:16:25 +0800 Subject: [PATCH 5/5] fix(renderer): address second-round review on sidebar PR - workspaceLabels: resolve each duplicate at its own shortest unique parent suffix instead of one depth per collision bucket - WindowSideBar: replace the group header title/sr-only path with a hover+focus tooltip associated to the button - useSessionPinFlight: serialize pin toggles so overlapping flights cannot fight over the flight/docked ownership refs - useProjectGroupReorder: reject non-permutation visible path lists, not just count mismatches - useSidebarRemoteControl: distinguish applied/discarded/failed refresh outcomes so a discarded snapshot neither resets nor grows backoff --- src/renderer/src/components/WindowSideBar.vue | 129 ++++++++++-------- .../sidebar/useProjectGroupReorder.ts | 12 +- .../sidebar/useSessionPinFlight.ts | 34 +++-- .../sidebar/useSidebarRemoteControl.ts | 30 ++-- src/shared/utils/workspaceLabels.ts | 47 +++++-- test/main/shared/workspaceLabels.test.ts | 12 ++ .../renderer/components/WindowSideBar.test.ts | 61 ++++++++- 7 files changed, 225 insertions(+), 100 deletions(-) diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index df62ba274e..40b90c7f90 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -412,64 +412,72 @@ : '' ]" > - + + - - - {{ - t('chat.input.workspaceUnavailableTooltip', { - path: getWorkspacePath(group) - }) - }} - - + {{ getGroupIdentifier(group) }} + + { const currentOrder = getCurrentProjectOrderPaths() const previousVisiblePaths = projectReorderableGroups.value.map(options.getWorkspacePath) - if (nextVisiblePaths.length !== previousVisiblePaths.length) { - console.warn('[WindowSideBar] Skipping project group reorder: visible group count mismatch') + 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 previousVisiblePathSet = new Set(previousVisiblePaths) const nextOrder = [...currentOrder] let nextVisibleIndex = 0 diff --git a/src/renderer/src/composables/sidebar/useSessionPinFlight.ts b/src/renderer/src/composables/sidebar/useSessionPinFlight.ts index 421a4bdc65..a0608c6885 100644 --- a/src/renderer/src/composables/sidebar/useSessionPinFlight.ts +++ b/src/renderer/src/composables/sidebar/useSessionPinFlight.ts @@ -305,19 +305,31 @@ export function useSessionPinFlight(options: UseSessionPinFlightOptions) { } } - const handleTogglePin = async (session: UISession) => { - const nextPinned = !session.isPinned + /** + * 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 + } - try { - if (prefersReducedMotion()) { - await commitPinToggle(session, nextPinned) - return + await animatePinFlight(session, nextPinned) + } catch (error) { + console.error('Failed to toggle pin status:', error) } - - await animatePinFlight(session, nextPinned) - } catch (error) { - console.error('Failed to toggle pin status:', error) - } + }) + pinToggleChain = queued + return queued } tryOnScopeDispose(() => { diff --git a/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts b/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts index a20d684f3d..5a421d81dd 100644 --- a/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts +++ b/src/renderer/src/composables/sidebar/useSidebarRemoteControl.ts @@ -10,6 +10,13 @@ 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 @@ -111,7 +118,7 @@ export function useSidebarRemoteControl(options: UseSidebarRemoteControlOptions) const hasEnabledRemoteChannelStatus = () => Object.values(remoteControlStatus.value).some((status) => status?.enabled === true) - const refreshRemoteControlStatus = async (requestId: number): Promise => { + const refreshRemoteControlStatus = async (requestId: number): Promise => { const version = pluginCatalogStore.captureRemoteRefresh() try { const descriptors = await remoteControlClient.listRemoteChannels() @@ -120,13 +127,14 @@ export function useSidebarRemoteControl(options: UseSidebarRemoteControlOptions) channels.map((channel) => remoteControlClient.getChannelStatus(channel)) ) if (requestId !== statusRefreshSequence) { - return true + return 'discarded' } - pluginCatalogStore.replaceRemoteSnapshot(descriptors, statuses, version) - return true + return pluginCatalogStore.replaceRemoteSnapshot(descriptors, statuses, version) + ? 'applied' + : 'discarded' } catch (error) { console.warn('[WindowSideBar] Failed to refresh remote control status:', error) - return false + return 'failed' } } @@ -151,11 +159,13 @@ export function useSidebarRemoteControl(options: UseSidebarRemoteControlOptions) async function runStatusRefresh(): Promise { const requestId = ++statusRefreshSequence - const refreshed = await refreshRemoteControlStatus(requestId) + const outcome = await refreshRemoteControlStatus(requestId) if (requestId !== statusRefreshSequence) { return } - statusRefreshErrors = refreshed ? 0 : statusRefreshErrors + 1 + if (outcome !== 'discarded') { + statusRefreshErrors = outcome === 'applied' ? 0 : statusRefreshErrors + 1 + } if (disposed || documentVisibility.value === 'hidden') { return @@ -163,9 +173,9 @@ export function useSidebarRemoteControl(options: UseSidebarRemoteControlOptions) const backoffMs = Math.min(30_000, 2_000 * 2 ** statusRefreshErrors) scheduleStatusRefresh( hasEnabledRemoteChannelStatus() - ? refreshed - ? REMOTE_STATUS_ACTIVE_POLL_MS - : backoffMs + ? outcome === 'failed' + ? backoffMs + : REMOTE_STATUS_ACTIVE_POLL_MS : REMOTE_STATUS_IDLE_POLL_MS ) } diff --git a/src/shared/utils/workspaceLabels.ts b/src/shared/utils/workspaceLabels.ts index c95b1b8f0e..b75745b53e 100644 --- a/src/shared/utils/workspaceLabels.ts +++ b/src/shared/utils/workspaceLabels.ts @@ -37,26 +37,43 @@ export function disambiguateWorkspaceLabels(items: WorkspaceLabelItem[]): Map segments.length)) - let contexts = parentSegments.map(() => '') - for (let depth = 1; depth <= maxDepth; depth += 1) { - contexts = parentSegments.map((segments) => segments.slice(-depth).join('/')) - if (new Set(contexts).size === bucket.length) { - break + // 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 contextCounts = new Map() - for (const context of contexts) { - contextCounts.set(context, (contextCounts.get(context) ?? 0) + 1) - } + const unresolvedParentlessCount = unresolvedIndexes.filter( + (index) => parentSegments[index].length === 0 + ).length bucket.forEach((item, index) => { - const context = contexts[index] - // Identical parent chains cannot be separated by a suffix; fall back to the full - // normalized path so every rendered label stays unique. - const uniqueContext = contextCounts.get(context) === 1 ? context : item.id - if (uniqueContext) { - overrides.set(item.id, `${item.label} · ${uniqueContext}`) + 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}`) } }) } diff --git a/test/main/shared/workspaceLabels.test.ts b/test/main/shared/workspaceLabels.test.ts index 3df850c3a8..4bee34ced9 100644 --- a/test/main/shared/workspaceLabels.test.ts +++ b/test/main/shared/workspaceLabels.test.ts @@ -35,6 +35,18 @@ describe('disambiguateWorkspaceLabels', () => { 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' }, diff --git a/test/renderer/components/WindowSideBar.test.ts b/test/renderer/components/WindowSideBar.test.ts index 77b145ad95..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, @@ -1014,7 +1019,15 @@ describe('WindowSideBar agent switch', () => { expect(teamGroup.text()).toContain('app · team-a') expect(archiveGroup.text()).toContain('app · archive') expect(designGroup.text()).not.toContain('design ·') - expect(teamGroup.attributes('title')).toBe('/work/team-a/app') + + // 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() @@ -1294,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 () => {