+
{{ t('chat.sidebar.workspace') }}
@@ -405,60 +412,72 @@
: ''
]"
>
-
+ {{ 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..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']
}