From 9d5f0db01866e3754107741b154c280273faeb44 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Wed, 3 Jun 2026 17:39:22 +0800 Subject: [PATCH 1/7] feat(sidebar): add chat number shortcuts (#1730) * docs(shortcuts): add sidebar shortcut spec * feat(sidebar): add chat number shortcuts * fix(sidebar): ignore repeated shortcut keys --- .../sidebar-chat-number-shortcuts/plan.md | 147 +++++ .../sidebar-chat-number-shortcuts/spec.md | 127 +++++ .../sidebar-chat-number-shortcuts/tasks.md | 17 + src/renderer/src/components/WindowSideBar.vue | 224 ++++++++ .../components/WindowSideBarSessionItem.vue | 55 +- src/renderer/src/i18n/da-DK/thread.json | 1 + src/renderer/src/i18n/de-DE/thread.json | 1 + src/renderer/src/i18n/en-US/thread.json | 1 + src/renderer/src/i18n/es-ES/thread.json | 1 + src/renderer/src/i18n/fa-IR/thread.json | 1 + src/renderer/src/i18n/fr-FR/thread.json | 1 + src/renderer/src/i18n/he-IL/thread.json | 1 + src/renderer/src/i18n/id-ID/thread.json | 1 + src/renderer/src/i18n/it-IT/thread.json | 1 + src/renderer/src/i18n/ja-JP/thread.json | 1 + src/renderer/src/i18n/ko-KR/thread.json | 1 + src/renderer/src/i18n/ms-MY/thread.json | 1 + src/renderer/src/i18n/pl-PL/thread.json | 1 + src/renderer/src/i18n/pt-BR/thread.json | 1 + src/renderer/src/i18n/ru-RU/thread.json | 1 + src/renderer/src/i18n/tr-TR/thread.json | 1 + src/renderer/src/i18n/vi-VN/thread.json | 1 + src/renderer/src/i18n/zh-CN/thread.json | 1 + src/renderer/src/i18n/zh-HK/thread.json | 1 + src/renderer/src/i18n/zh-TW/thread.json | 1 + .../renderer/components/WindowSideBar.test.ts | 521 +++++++++++++++++- .../WindowSideBarSessionItem.test.ts | 36 +- 27 files changed, 1120 insertions(+), 27 deletions(-) create mode 100644 docs/features/sidebar-chat-number-shortcuts/plan.md create mode 100644 docs/features/sidebar-chat-number-shortcuts/spec.md create mode 100644 docs/features/sidebar-chat-number-shortcuts/tasks.md diff --git a/docs/features/sidebar-chat-number-shortcuts/plan.md b/docs/features/sidebar-chat-number-shortcuts/plan.md new file mode 100644 index 000000000..b4a0f38e9 --- /dev/null +++ b/docs/features/sidebar-chat-number-shortcuts/plan.md @@ -0,0 +1,147 @@ +# Implementation Plan — Sidebar Chat Number Shortcuts + +## Touch Points + +### Sidebar container — `src/renderer/src/components/WindowSideBar.vue` + +- Compute a `numberedShortcutSessions` array from the same renderer state used by the visible list: + `pinnedSessions` when expanded, followed by each non-collapsed `filteredGroups[].sessions`. +- Limit numbering to the first 10 sessions and map row indexes to shortcut labels: + `1..9`, then `0`. +- Register window-level `keydown` / `keyup` listeners while the sidebar is mounted. +- Detect platform with existing renderer device information, preferring `useDeviceVersion()` or the + same `createDeviceClient().getDeviceInfo()` source used by existing components. +- On macOS, handle `event.metaKey`; on Windows/Linux, handle `event.altKey`. +- Start a 0.5 second timer when the platform modifier is pressed by itself. +- Show `showShortcutBadges` when the timer completes and the modifier is still down. +- Clear the timer and hide badges on modifier release, blur, visibility change, unmount, or sidebar + collapse. +- Ignore shortcut handling when focus is inside editable UI or when modal/overlay focus owns the + keyboard. +- On number shortcut, recompute the current mapping synchronously and call + `sessionStore.selectSession(target.id)` when a target exists. + +### Sidebar item — `src/renderer/src/components/WindowSideBarSessionItem.vue` + +- Add optional props: + - `shortcutBadgeLabel?: string | null` + - `shortcutBadgeVisible?: boolean` +- Render the badge inside the existing `.right-button` area. +- When the badge is visible, hide/disable the delete button in that same area so the badge covers + the hover delete affordance. +- Keep badge visibility controlled only by the explicit `shortcutBadgeVisible` prop. Do not make + shortcut badges appear through `.session-item:hover`, `group-hover`, or `focus-within` selectors. +- Keep the existing hover delete trigger intact for the normal state; the long-press overlay should + replace what is rendered in the right slot, not alter the row's hover state machine. +- Add ARIA/tooltip text using i18n, e.g. "Switch to this chat with {shortcut}". +- Keep row width stable; the badge must not shift the chat title or resize the row. + +### i18n — `src/renderer/src/i18n/*/thread.json` + +- Add labels under `thread.actions` or a sidebar-oriented namespace: + - `switchWithShortcut` + - `shortcutBadge` +- Include at least English and Chinese source strings in the implementation increment, then run + `pnpm run i18n` to synchronize locale files according to the repository workflow. + +### Tests + +- Add renderer unit coverage near the sidebar tests. If no direct sidebar suite exists, add + `test/renderer/components/WindowSideBar*.test.ts` with Vue Test Utils. +- Cover visible-row mapping: + - pinned expanded rows before grouped rows; + - collapsed pinned/group sections excluded; + - search-filtered rows excluded; + - `0` maps to the tenth row. +- Cover keyboard behavior: + - macOS uses `metaKey`; + - Windows/Linux uses `altKey`; + - missing index does nothing; + - editable focused elements suppress switching. +- Cover long-press behavior with fake timers: + - badges show after 0.5 seconds; + - badges hide on modifier release; + - delete button is not rendered/clickable while the badge is visible. +- Cover hover separation: + - hovering a row without long-press does not render a shortcut badge; + - long-press renders badges even when no row is hovered; + - after long-press ends, hover delete behavior still works. + +## Shortcut Mapping + +The mapping is intentionally not stored. It is derived from current computed values each time: + +```text +visibleShortcutRows = + expanded pinned sessions + + expanded grouped sessions in rendered group order + +keys: + 1 -> visibleShortcutRows[0] + 2 -> visibleShortcutRows[1] + ... + 9 -> visibleShortcutRows[8] + 0 -> visibleShortcutRows[9] +``` + +This keeps behavior aligned with sidebar search, agent filtering, lazy-loaded sessions, group +collapse state, and pin/unpin changes. + +## Decisions + +- **Renderer-only shortcut.** The feature depends on current sidebar presentation state, so global + Electron accelerators or main-process presenters would be the wrong source of truth. +- **Visible rows only.** Group headers are not selectable chats, and collapsed/filtered/unloaded + sessions are not part of the user's current visual list. +- **`0` means tenth.** This matches common numbered shortcut conventions and keeps the first ten rows + addressable. +- **Badges replace delete affordance.** The screenshot shows the shortcut label in the right-side + action slot; using the existing delete slot avoids adding another competing control. +- **Hover and shortcut states stay separate.** Hover remains responsible for delete affordance + visibility; modifier long-press is the only trigger for shortcut badge visibility. +- **No settings surface in first increment.** The requested shortcut is fixed and discoverable via + long-press, keeping the change small. + +## Event Flow + +```text +Window keydown + -> detect platform modifier + -> if modifier-only, start 0.5s badge timer + -> if modifier+digit, recompute visibleShortcutRows + -> select target session through sessionStore.selectSession() + +Window keyup / blur / visibility hidden / unmount + -> cancel badge timer + -> hide badges +``` + +## Compatibility + +- Existing session activation, route updates, message clearing, and selected agent sync remain + delegated to `sessionStore.selectSession()`. +- Existing pin/unpin animation and delete dialog behavior are unchanged. +- The shortcut should not conflict with `Command+F` chat search because it listens only for digits. +- The long-press overlay should respect reduced-motion preferences by avoiding nonessential + animation. + +## Risks And Mitigations + +- **Alt conflicts on Windows/Linux:** handle only `Alt+digit` and modifier-only hold. Avoid + preventing default for unrelated Alt combinations. +- **Input focus conflicts:** suppress the shortcut in editable elements and active overlays. +- **Stale timer state:** clear timers on keyup, blur, visibility change, unmount, and sidebar + collapse. +- **Layout regression:** keep badge rendering inside the current right action slot and verify desktop + plus narrow sidebar widths. + +## Validation + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- Targeted renderer tests for the sidebar shortcut behavior +- Manual check on macOS and Windows/Linux-equivalent platform mocks: + - press `Command+2` / `Alt+2`; + - hold modifier for 0.5 seconds; + - search/filter/collapse, then verify badge numbers recalculate. diff --git a/docs/features/sidebar-chat-number-shortcuts/spec.md b/docs/features/sidebar-chat-number-shortcuts/spec.md new file mode 100644 index 000000000..127608acd --- /dev/null +++ b/docs/features/sidebar-chat-number-shortcuts/spec.md @@ -0,0 +1,127 @@ +# Sidebar Chat Number Shortcuts + +## User Need + +Users with many chats in the left sidebar need a fast way to switch between the currently visible +chat rows without moving the pointer. The shortcut should be discoverable in the same place where +the action will happen, so users can learn the mapping while looking at the sidebar. + +## Goal + +Add renderer-local number shortcuts for the current left sidebar chat list: + +- macOS: `Command+1` through `Command+9`, plus `Command+0`. +- Windows/Linux: `Alt+1` through `Alt+9`, plus `Alt+0`. +- `1` maps to the first currently displayed chat row, `2` to the second, and so on. +- `0` maps to the tenth currently displayed chat row. +- The mapping is recalculated from the current renderer state every time the shortcut is pressed. +- Holding the platform modifier for 0.5 seconds shows shortcut badges on the first ten displayed chat + rows, matching the provided screenshot style. + +## Acceptance Criteria + +1. Pressing `Command+N` on macOS or `Alt+N` on Windows/Linux selects the Nth chat in the left + sidebar's current displayed order. +2. `N=1..9` selects rows 1 through 9; `N=0` selects row 10. +3. The displayed order is derived only from the renderer's current sidebar state: + - pinned chats first when the pinned section is expanded; + - grouped chats in the same order as `filteredGroups`; + - collapsed sections, filtered-out search results, empty group headers, and unloaded pages are + excluded; + - hidden pin-flight placeholders are excluded. +4. Shortcut selection calls the existing `sessionStore.selectSession(session.id)` path and does not + add main-process IPC or persisted shortcut settings. +5. If the requested index has no current chat row, the shortcut is ignored without UI noise. +6. The shortcut handler does not fire while typing in inputs, textareas, contenteditable editors, or + active command/search overlays. +7. Holding only the platform modifier for 0.5 seconds shows number badges for at most ten displayed + chat rows. Releasing the modifier hides them immediately. +8. While the badge overlay is visible, it occupies the same right-side area as the hover delete + button so the delete button is visually covered and cannot be clicked. +9. Badge visibility is independent from row hover/focus state: + - hovering a row never starts or reveals shortcut badges; + - long-pressing the platform modifier never forces the row into its hover visual state; + - when badges are hidden, existing hover delete behavior remains unchanged. +10. The overlay labels use `⌘1..⌘9`, `⌘0` on macOS and `Alt+1..Alt+9`, `Alt+0` on Windows/Linux. +11. The overlay updates from current renderer state when sidebar search, agent filter, pinned state, + collapse state, or session list data changes. +12. The sidebar collapsed state does not expose hidden chat shortcuts. If the sidebar is collapsed, + shortcut switching and badge rendering are disabled. +13. All user-facing tooltip/ARIA text uses vue-i18n keys. + +## ASCII UI + +Default row, no hover: + +```text ++------------------------------------------------+ +| [pin space] Chat title text ... | ++------------------------------------------------+ +``` + +Hover row before this feature: + +```text ++------------------------------------------------+ +| [pin] Chat title text [del]| ++------------------------------------------------+ +``` + +Modifier held for 0.5 seconds: + +```text ++------------------------------------------------+ +| [pin] Chat title text [⌘1] | +| [pin] Another title [⌘2] | +| [pin] Third title [⌘3] | +| ... | +| [pin] Tenth title [⌘0] | ++------------------------------------------------+ +``` + +Windows/Linux badge labels: + +```text ++------------------------------------------------+ +| [pin] Chat title text [Alt+1]| +| [pin] Another title [Alt+2]| ++------------------------------------------------+ +``` + +Collapsed and filtered rows do not receive numbers: + +```text ++------------------------------------------------+ +| Pinned [closed]| +| Today [open] | +| Visible chat A [⌘1]| +| Visible chat B [⌘2]| +| Older [closed]| ++------------------------------------------------+ +``` + +## Constraints + +- This is a renderer-only feature driven by the sidebar's current computed state. +- No stored preference, migration, menu item, global Electron accelerator, or main-process presenter + change is needed for the first increment. +- The implementation should stay inside existing sidebar boundaries and reuse the session store. +- Badge visuals should follow the current sidebar item styling: compact pill, right-aligned, no + layout jump, with the same default surface as the pin/delete action buttons. +- Badge display state must be driven by the modifier long-press state, not by CSS `:hover`, + `group-hover`, or row focus selectors. +- Do not change session sorting, grouping, pagination, pinning, deletion, or agent filter behavior. + +## Non-goals + +- No configurable keybinding UI in settings. +- No shortcuts for group headers, settings, remote controls, new chat, or non-chat sidebar items. +- No switching to sessions that are not currently loaded in the renderer. +- No mouse-only tutorial or onboarding modal. +- No changes to chat input shortcuts. + +## Business Value + +The feature reduces navigation friction for keyboard-heavy users and makes the shortcut self-teaching +through the sidebar badge overlay, while keeping the implementation local to the renderer and low +risk for session persistence. diff --git a/docs/features/sidebar-chat-number-shortcuts/tasks.md b/docs/features/sidebar-chat-number-shortcuts/tasks.md new file mode 100644 index 000000000..94f7d15df --- /dev/null +++ b/docs/features/sidebar-chat-number-shortcuts/tasks.md @@ -0,0 +1,17 @@ +# Tasks — Sidebar Chat Number Shortcuts + +- [x] Sidebar mapping: derive first ten visible chat sessions from expanded pinned and grouped + renderer state. +- [x] Platform handling: detect macOS vs Windows/Linux and build display labels (`⌘N` vs `Alt+N`). +- [x] Keyboard runtime: add mounted window listeners for digit switching and 0.5 second modifier hold. +- [x] Focus guards: suppress shortcuts in editable fields and active keyboard-owning overlays. +- [x] Badge rendering: add sidebar item props and render right-slot shortcut badges over the delete + button. +- [x] State separation: keep shortcut badge visibility independent from row hover/focus delete + triggers. +- [x] i18n: add shortcut badge aria/tooltip strings and synchronize locale files. +- [x] Tests: cover mapping, platform modifiers, focus suppression, long-press timer, and delete + button replacement, including hover/long-press separation. +- [x] Quality gates: run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. +- [ ] Manual QA: verify desktop behavior for normal, searched, collapsed, pinned, and less-than-ten + chat lists. diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index 0a8463eb9..b629f2c6e 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -304,6 +304,8 @@ :force-pin-docked="pinDockedSessionId === session.id" :pin-feedback-mode="pinFeedbackSessionId === session.id ? pinFeedbackMode : null" :search-query="sessionSearchQuery" + :shortcut-badge-label="getShortcutBadgeLabelForSession(session.id)" + :shortcut-badge-visible="hasShortcutBadgeForSession(session.id)" @select="handleSessionClick" @toggle-pin="handleTogglePin" @delete="openDeleteDialog" @@ -341,6 +343,8 @@ :force-pin-docked="pinDockedSessionId === session.id" :pin-feedback-mode="pinFeedbackSessionId === session.id ? pinFeedbackMode : null" :search-query="sessionSearchQuery" + :shortcut-badge-label="getShortcutBadgeLabelForSession(session.id)" + :shortcut-badge-visible="hasShortcutBadgeForSession(session.id)" @select="handleSessionClick" @toggle-pin="handleTogglePin" @delete="openDeleteDialog" @@ -398,6 +402,7 @@ import { } from '@shadcn/components/ui/dialog' import { createSettingsClient } from '@api/SettingsClient' import { createRemoteControlRuntime } from '@api/RemoteControlRuntime' +import { createDeviceClient } from '@api/DeviceClient' import { useAgentStore } from '@/stores/ui/agent' import { useSessionStore, type SessionGroup, type UISession } from '@/stores/ui/session' import { useSpotlightStore } from '@/stores/ui/spotlight' @@ -422,10 +427,13 @@ const PIN_FEEDBACK_DURATION_MS: Record = { const PIN_FLIGHT_DURATION_MS = 460 const PIN_TARGET_SETTLE_MAX_FRAMES = 10 const PIN_TARGET_SETTLE_EPSILON_PX = 0.5 +const SIDEBAR_SHORTCUT_BADGE_DELAY_MS = 500 +const SIDEBAR_SHORTCUT_MAX_ROWS = 10 const getPinFeedbackMode = (nextPinned: boolean): PinFeedbackMode => nextPinned ? 'pinning' : 'unpinning' type SessionItemRegion = 'pinned' | 'grouped' +type ShortcutPlatform = 'mac' | 'other' type SessionItemRect = { left: number top: number @@ -435,6 +443,7 @@ type SessionItemRect = { const settingsClient = createSettingsClient() const remoteControlRuntime = createRemoteControlRuntime() +const deviceClient = createDeviceClient() const { t } = useI18n() const agentStore = useAgentStore() const sessionStore = useSessionStore() @@ -533,6 +542,12 @@ let agentSwitchQueue: Promise = Promise.resolve() let remoteControlStatusTimer: ReturnType | null = null let pinFeedbackTimer: number | null = null let sessionListScrollFrame: number | null = null +let shortcutBadgeTimer: number | null = null +const shortcutPlatform = ref( + navigator.platform.toLowerCase().includes('mac') ? 'mac' : 'other' +) +const shortcutModifierDown = ref(false) +const showShortcutBadges = ref(false) const sidebarSelectedAgentId = computed(() => { const activeSessionAgentId = sessionStore.activeSession?.agentId?.trim() if (sessionStore.hasActiveSession && activeSessionAgentId) { @@ -676,6 +691,53 @@ const getGroupLabel = (group: SessionGroup) => (group.labelKey ? t(group.labelKe const isGroupCollapsed = (group: SessionGroup) => collapsedGroupIds.value.has(getGroupIdentifier(group)) +const visibleShortcutSessions = computed(() => { + if (collapsed.value) { + return [] + } + + const sessions: UISession[] = [] + + if (!isPinnedSectionCollapsed.value) { + sessions.push(...pinnedSessions.value) + } + + for (const group of filteredGroups.value) { + if (!isGroupCollapsed(group)) { + sessions.push(...group.sessions) + } + } + + return sessions + .filter((session) => session.id !== pinFlightSessionId.value) + .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 togglePinnedSection = () => { isPinnedSectionCollapsed.value = !isPinnedSectionCollapsed.value } @@ -829,6 +891,156 @@ const handleSessionClick = (session: { id: string }) => { void sessionStore.selectSession(session.id) } +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 = () => + spotlightStore.open || + deleteDialogOpen.value || + document.querySelector('.chat-search-bar') !== null || + document.querySelector('[role="dialog"][aria-modal="true"]') !== null + +const shouldIgnoreSidebarShortcutEvent = (event: KeyboardEvent) => + collapsed.value || 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 clearShortcutBadgeTimer = () => { + if (shortcutBadgeTimer !== null) { + window.clearTimeout(shortcutBadgeTimer) + shortcutBadgeTimer = null + } +} + +const hideShortcutBadges = () => { + clearShortcutBadgeTimer() + shortcutModifierDown.value = false + showShortcutBadges.value = false +} + +const startShortcutBadgeTimer = () => { + if (shortcutBadgeTimer !== null || showShortcutBadges.value) { + return + } + + shortcutModifierDown.value = true + shortcutBadgeTimer = window.setTimeout(() => { + shortcutBadgeTimer = null + + if ( + shortcutModifierDown.value && + !collapsed.value && + !hasKeyboardOwningOverlay() && + visibleShortcutSessions.value.length > 0 + ) { + showShortcutBadges.value = true + } + }, SIDEBAR_SHORTCUT_BADGE_DELAY_MS) +} + +const selectShortcutSession = (digit: string) => { + const shortcutIndex = getShortcutIndexForDigit(digit) + const targetSession = visibleShortcutSessions.value[shortcutIndex] + + if (targetSession) { + void sessionStore.selectSession(targetSession.id) + } +} + +const handleWindowShortcutKeydown = (event: KeyboardEvent) => { + if (isPlatformModifierOnlyKeydown(event)) { + if (shortcutPlatform.value !== 'mac') { + event.preventDefault() + } + startShortcutBadgeTimer() + return + } + + if (shortcutBadgeTimer !== null && event.key !== getPlatformModifierKey()) { + clearShortcutBadgeTimer() + } + + 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() + } +} + +const handleWindowShortcutBlur = () => { + hideShortcutBadges() +} + +const handleDocumentVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + hideShortcutBadges() + } +} + +watch(collapsed, (isCollapsed) => { + if (isCollapsed) { + hideShortcutBadges() + } +}) + const openDeleteDialog = (session: UISession) => { deleteTargetSession.value = session } @@ -1157,6 +1369,12 @@ const handleDeleteConfirm = async () => { } onMounted(() => { + void loadShortcutPlatform() + window.addEventListener('keydown', handleWindowShortcutKeydown) + window.addEventListener('keyup', handleWindowShortcutKeyup) + window.addEventListener('blur', handleWindowShortcutBlur) + document.addEventListener('visibilitychange', handleDocumentVisibilityChange) + void refreshRemoteControlStatus() remoteControlStatusTimer = setInterval(() => { void refreshRemoteControlStatus() @@ -1164,6 +1382,11 @@ onMounted(() => { }) onUnmounted(() => { + window.removeEventListener('keydown', handleWindowShortcutKeydown) + window.removeEventListener('keyup', handleWindowShortcutKeyup) + window.removeEventListener('blur', handleWindowShortcutBlur) + document.removeEventListener('visibilitychange', handleDocumentVisibilityChange) + if (remoteControlStatusTimer) { clearInterval(remoteControlStatusTimer) remoteControlStatusTimer = null @@ -1177,6 +1400,7 @@ onUnmounted(() => { pinFlightSessionId.value = null pinDockedSessionId.value = null clearPinFeedback() + hideShortcutBadges() }) diff --git a/src/renderer/src/components/WindowSideBarSessionItem.vue b/src/renderer/src/components/WindowSideBarSessionItem.vue index c6500ec84..55de6d8ce 100644 --- a/src/renderer/src/components/WindowSideBarSessionItem.vue +++ b/src/renderer/src/components/WindowSideBarSessionItem.vue @@ -25,6 +25,8 @@ const props = defineProps<{ forcePinDocked?: boolean pinFeedbackMode?: PinFeedbackMode | null searchQuery?: string + shortcutBadgeLabel?: string | null + shortcutBadgeVisible?: boolean }>() const emit = defineEmits<{ @@ -42,6 +44,15 @@ const pinActionLabel = computed(() => const deleteActionLabel = computed(() => t('thread.actions.delete')) +const shortcutBadgeTitle = computed(() => { + const shortcut = props.shortcutBadgeLabel + if (!shortcut) { + return '' + } + + return t('thread.actions.switchWithShortcut', { shortcut }) +}) + const isWorking = computed(() => session.value.status === 'working') const pinState = computed<'docked' | 'overlay'>(() => { @@ -164,8 +175,21 @@ const titleSegments = computed(() => { - + + + {{ shortcutBadgeLabel }} +