diff --git a/.agents/notes/implemented/feature/2026-09-11-workspace-switch-shortcuts.zh.md b/.agents/notes/implemented/feature/2026-09-11-workspace-switch-shortcuts.zh.md new file mode 100644 index 000000000..a87ec481a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-workspace-switch-shortcuts.zh.md @@ -0,0 +1,25 @@ +# 固定工作区 ID 的数字切换命令 + +Status: implemented +Translation: pending +PR: [#585](https://github.com/LodyAI/Lody/pull/585) + +## 摘要 + +工作区选择此前在三个 UI 入口分别组合 organization 激活、Jotai identity、preferred slug 和路由写入,无法可靠复用于数字快捷键或后续 swipe。现在普通切换统一由 workspace-ID hook 执行,1–9 slot 作为按用户、按设备的稳定 ID 映射保存,并由 `useCommand` 注册 Electron `Mod+1..9`。设置页复用既有快捷键冲突系统并单独编辑 slot 目标;公开 OSS E2E 受单一本地工作区限制,真实双工作区跳转由组件集成测试验证。 + +## 决策 + +首次初始化使用当前可导航 workspace 目录的前九项,是为了让功能无需设置即可使用。初始化后不再从列表位置推导编号:成员列表重排保留 ID,删除或失去权限仅留下空 slot,同一 ID 被重新分配时从旧 slot 移走。选择“每次按当前列表取第 N 项”虽然不需要存储,却会让排序变化在用户不知情时改变快捷键目标,因此拒绝。 + +Slot 存储按 user ID 分 key,避免同一设备切换账号时相互清空;它与键位覆盖一样是设备偏好,不进入 Loro 或云端 workspace 数据。模块内一个引用计数 storage listener 同步所有 renderer,本窗口写入直接发布快照。没有增加 Electron IPC,因为同一 session 的 localStorage 事件已经覆盖窗口一致性。 + +`useWorkspaceSwitcher` 统一普通用户导航的顺序:验证 ID 与 slug、当前 workspace 无操作、写 preferred slug、原子发布 slug/ID、请求 organization 激活、导航当前窗口到 workspace home。侧栏、mobile home 和 organization selector 改用该入口;onboarding 恢复、创建和删除补偿仍保留自己的事务,因为它们不仅是普通导航。 + +九个命令的默认键位限制在 Electron,避免 Web 浏览器吞掉 `Mod+1..9`。只有目录中仍可访问且已分配的 slot 挂载命令;`useCommand` 让命令继续使用 #572 建立的统一 capture host、设置录制、用户覆盖和 canonical 冲突索引。slot 目标选择与键位编辑分开:前者保存 workspace ID,后者仍完整替换命令默认绑定。 + +## 验证与限制 + +纯逻辑和 React 集成测试覆盖首次分配、目录重排、删除留空、唯一分配、账号隔离、另一 renderer storage 更新、非法目标、当前 workspace 无操作,以及切换后的 preferred slug、Jotai context、active organization 和路由结果。现有桌面 P0 shortcut journey 在主窗口与辅助窗口中增加 slot 命令及 `Mod+1` 展示检查。公开 local-only Electron 只有一个隐式 workspace,不能在不引入被禁止的云端请求或测试后门时执行 A→B,因此真实跨 workspace 跳转保留为组件级边界证据。 + +本决策延续[统一快捷键窗口生命周期](../bug-fix/2026-09-10-shortcut-window-lifecycle.zh.md),产品契约见[工作区切换快捷键](../../../../specs/workspace-switching-shortcuts.zh.md)。需求:[Issue #288](https://github.com/LodyAI/Lody/issues/288)。 diff --git a/e2e/COVERAGE.md b/e2e/COVERAGE.md index 5d7ecd9f5..faa4062d2 100644 --- a/e2e/COVERAGE.md +++ b/e2e/COVERAGE.md @@ -8,12 +8,12 @@ Backlog rows are evidence-backed gaps, not executable or promised scenarios. ## Active P0 journeys -| Stable id | Journey | Renderer | Electron / IPC | Bundled CLI | Durable state | External wire | -| --------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ----------------------------------------------- | ------------- | -| `LODY-ONBOARDING-001` | New user enters an isolated local workspace through the bundled CLI | Intro and local entry | Real window and invoke bridge | Real owned runtime | Isolated workspace catalog and onboarding state | None | -| `LODY-SESSION-001` | Stop and permanently delete a running ACP Session | Session lifecycle | Real window and invoke bridge | Real owned runtime | Create, stop, archive, and permanent delete | Scripted ACP | -| `LODY-SHORTCUT-001` | Synchronize user-rebound desktop shortcuts across windows and renderer reload | Two command hosts, settings recorder, sidebars, and secondary renderer reload | Real primary and auxiliary Electron windows through app.openWindow IPC | Real owned runtime for local workspace entry | User shortcut override in isolated localStorage | None | -| `LODY-WORK-001` | Delete a worktree Session with ACP and Terminal resources | Work lifecycle | Real window, IPC, and Terminal | Real owned runtime | Session, worktree, and terminal cleanup | Scripted ACP | +| Stable id | Journey | Renderer | Electron / IPC | Bundled CLI | Durable state | External wire | +| --------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ----------------------------------------------- | ------------- | +| `LODY-ONBOARDING-001` | New user enters an isolated local workspace through the bundled CLI | Intro and local entry | Real window and invoke bridge | Real owned runtime | Isolated workspace catalog and onboarding state | None | +| `LODY-SESSION-001` | Stop and permanently delete a running ACP Session | Session lifecycle | Real window and invoke bridge | Real owned runtime | Create, stop, archive, and permanent delete | Scripted ACP | +| `LODY-SHORTCUT-001` | Synchronize user-rebound desktop shortcuts across windows and renderer reload | Two command hosts, workspace slot registration, settings recorder, sidebars, and secondary renderer reload | Real primary and auxiliary Electron windows through app.openWindow IPC | Real owned runtime for local workspace entry | User shortcut override in isolated localStorage | None | +| `LODY-WORK-001` | Delete a worktree Session with ACP and Terminal resources | Work lifecycle | Real window, IPC, and Terminal | Real owned runtime | Session, worktree, and terminal cleanup | Scripted ACP | ## Active P1 journeys diff --git a/e2e/journeys/registry.json b/e2e/journeys/registry.json index f28f37276..d1ae1973e 100644 --- a/e2e/journeys/registry.json +++ b/e2e/journeys/registry.json @@ -111,15 +111,20 @@ "owner": "desktop-shortcuts", "feature": "src/features/shortcuts.feature", "fixture": "fresh-local-workspace", - "fingerprint": "ca40a75b29ba41bb0e96b2e86fbbff3d18b42f40878b35d631857d2addb3844f", + "fingerprint": "b3f36c25d4099d068177be72520777f9ce192d60a76aac31dfb1dff6fd67c753", "ownerPaths": [ "packages/components/src/lib/commands/", + "packages/components/src/lib/workspace-shortcut-slots.ts", + "packages/components/src/hooks/use-workspace-shortcut-slots.ts", + "packages/components/src/hooks/use-workspace-switcher.ts", + "packages/components/src/components/app-commands.tsx", "packages/components/src/components/settings/keyboard-shortcuts-setting.tsx", "packages/components/src/hooks/use-keyboard-navigation.ts" ], "actions": [ { "id": "shortcut.openPaletteWithDefaultBinding" }, { "id": "shortcut.openSettingsWithPhysicalPunctuation" }, + { "id": "shortcut.verifyWorkspaceSlotDefault" }, { "id": "window.openSecondaryWorkspace" }, { "id": "shortcut.verifySecondaryDefaults" }, { "id": "shortcut.rebindSidebarWithShiftedDigit" }, @@ -128,13 +133,13 @@ { "id": "shortcut.verifyUserBindingRestoredInSecondary" } ], "checkpoints": [ - "default command palette and settings bindings execute in both Electron windows", + "default command palette, settings bindings, and the workspace slot command are available in both Electron windows", "the old sidebar binding stops while the shifted-digit physical key works immediately in both windows", "the user binding is restored in the secondary window after its renderer reload" ], "cleanup": ["isolated desktop profile and shortcut overrides are deleted"], "coverage": { - "renderer": "Two command hosts, settings recorder, sidebars, and secondary renderer reload", + "renderer": "Two command hosts, workspace slot registration, settings recorder, sidebars, and secondary renderer reload", "electronIpc": "Real primary and auxiliary Electron windows through app.openWindow IPC", "bundledCli": "Real owned runtime for local workspace entry", "durableState": "User shortcut override in isolated localStorage", diff --git a/e2e/src/support/pages/shortcut-page.ts b/e2e/src/support/pages/shortcut-page.ts index a9258b1b1..c830de043 100644 --- a/e2e/src/support/pages/shortcut-page.ts +++ b/e2e/src/support/pages/shortcut-page.ts @@ -42,6 +42,13 @@ export class ShortcutPage { await page.keyboard.press(`${PRIMARY_MODIFIER}+,`); const settings = this.settingsDialog(page); await expect(settings).toBeVisible(); + await settings.locator('[data-settings-tab-id="keyboard-shortcuts"]').click(); + const workspaceSlotLabel = settings.getByText( + /^(Switch to workspace 1:|切换到工作区 1:)/u + ); + const workspaceSlotRow = workspaceSlotLabel.locator('..').locator('..'); + await expect(workspaceSlotLabel).toBeVisible(); + await expect(workspaceSlotRow.locator('[data-slot="kbd"]', { hasText: '1' })).toBeVisible(); await this.closeSettings(page, settings); } diff --git a/locales/en.json b/locales/en.json index 43cf301fc..24cc9ea45 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3387,6 +3387,9 @@ "settings.keyboardShortcuts.globalHint": "Works system-wide, even when Lody isn't focused", "settings.keyboardShortcuts.globalConflict": "That combo is already taken by the system or another app", "settings.keyboardShortcuts.globalNeedsModifier": "Add Command, Control, or Alt — Shift alone would capture normal typing system-wide", + "settings.keyboardShortcuts.workspaceSlots": "Workspace slots", + "settings.keyboardShortcuts.workspaceSlot": "Workspace {{slot}}", + "settings.keyboardShortcuts.workspaceSlotUnassigned": "Unassigned", "commands.app.focus": "Bring Lody to Front", "commands.app.cycleTheme": "Cycle Theme", "agents.disableReason.binaryUnsupported": "This agent isn't available for this machine's platform", @@ -3433,6 +3436,8 @@ "agents.acpBinary.installError": "Download failed", "commands.workspace.openSettings": "Open Settings", "commands.workspace.openAboutSettings": "About Lody", + "commands.workspace.switchSlot": "Switch to workspace {{slot}}", + "commands.workspace.switchSlotWithName": "Switch to workspace {{slot}}: {{workspace}}", "commands.layout.toggleZenMode": "Toggle Zen Layout", "commands.session.new": "New Chat", "commands.project.importLocal": "Import Local Project", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 18ed6acae..b2458a15e 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -3387,6 +3387,9 @@ "settings.keyboardShortcuts.globalHint": "全局生效,即使 Lody 未聚焦也能触发", "settings.keyboardShortcuts.globalConflict": "该组合已被系统或其他应用占用", "settings.keyboardShortcuts.globalNeedsModifier": "需要加 Command、Control 或 Alt;只按 Shift 会全局拦截正常输入", + "settings.keyboardShortcuts.workspaceSlots": "工作区序号", + "settings.keyboardShortcuts.workspaceSlot": "工作区 {{slot}}", + "settings.keyboardShortcuts.workspaceSlotUnassigned": "未分配", "commands.app.focus": "将 Lody 唤醒到最前", "commands.app.cycleTheme": "循环切换主题", "agents.disableReason.binaryUnsupported": "该 Agent 不支持当前机器的平台", @@ -3433,6 +3436,8 @@ "agents.acpBinary.installError": "下载失败", "commands.workspace.openSettings": "打开设置", "commands.workspace.openAboutSettings": "关于 Lody", + "commands.workspace.switchSlot": "切换到工作区 {{slot}}", + "commands.workspace.switchSlotWithName": "切换到工作区 {{slot}}:{{workspace}}", "commands.layout.toggleZenMode": "切换 Zen 布局", "commands.session.new": "新建对话", "commands.project.importLocal": "导入本地项目", diff --git a/packages/components/src/components/app-commands.tsx b/packages/components/src/components/app-commands.tsx index 0a4d8c507..88100f5e0 100644 --- a/packages/components/src/components/app-commands.tsx +++ b/packages/components/src/components/app-commands.tsx @@ -1,14 +1,32 @@ import { useRouter } from '@tanstack/react-router'; import { useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; -import { currentWorkspaceSlugAtom, settingsDialogOpenAtom, toggleZenLayoutModeAtom } from '@/atoms'; +import { + currentWorkspaceIdAtom, + currentWorkspaceSlugAtom, + settingsDialogOpenAtom, + toggleZenLayoutModeAtom, + userAtom, +} from '@/atoms'; import { taskQuickAddOpenAtom } from '@/atoms/tasks'; import { tasksFeatureEnabledAtom } from '@/atoms/settings'; -import { getCommandKeybindings, useCommand } from '@/lib/commands'; +import { + getCommandKeybindings, + getRuntime, + getWorkspaceShortcutCommandId, + useCommand, +} from '@/lib/commands'; +import { + WORKSPACE_SHORTCUT_SLOT_NUMBERS, + type WorkspaceShortcutSlotNumber, +} from '@/lib/workspace-shortcut-slots'; import { getAppCurrentPathWithSearch } from '@/lib/app-location'; import { isSettingsPath, resolveSettingsCloseTo } from '@/lib/settings-navigation'; +import { isNavigableWorkspaceSlug } from '@/lib/workspace'; import { useIsMobile } from '@/hooks/use-mobile'; import { useOpenSettings } from '@/hooks/use-open-settings'; +import { useWorkspaceShortcutSlots } from '@/hooks/use-workspace-shortcut-slots'; +import { useWorkspaceSwitcher } from '@/hooks/use-workspace-switcher'; import { nextCycledTheme, useTheme } from '../theme-provider'; /** @@ -145,5 +163,77 @@ export function AppCommands() { }, }); + return ; +} + +export function WorkspaceShortcutCommands() { + const { t } = useTranslation(); + const userId = useAtomValue(userAtom)?.id ?? null; + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const { organizations, switchWorkspace } = useWorkspaceSwitcher(); + const availableWorkspaceIds = organizations + ?.filter( + (organization) => organization.slug && isNavigableWorkspaceSlug(organization.slug) + ) + .map((organization) => organization.id); + const { slots } = useWorkspaceShortcutSlots(userId, availableWorkspaceIds); + + return ( + <> + {WORKSPACE_SHORTCUT_SLOT_NUMBERS.map((slot) => { + const workspaceId = slots[slot - 1] ?? null; + const workspace = organizations?.find((organization) => organization.id === workspaceId); + return ( + + ); + })} + + ); +} + +function WorkspaceShortcutCommand({ + slot, + title, + workspaceId, + currentWorkspaceId, + enabled, + onSwitch, +}: { + slot: WorkspaceShortcutSlotNumber; + title: string; + workspaceId: string | null; + currentWorkspaceId: string | null; + enabled: boolean; + onSwitch: (workspaceId: string) => boolean; +}) { + const commandId = getWorkspaceShortcutCommandId(slot); + useCommand( + { + id: commandId, + title, + category: 'Workspace', + keybindings: getCommandKeybindings(commandId), + when: () => workspaceId !== null && workspaceId !== currentWorkspaceId, + run: () => { + if (workspaceId) onSwitch(workspaceId); + }, + }, + enabled + ); return null; } diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index 65111d598..2a02885bf 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -146,7 +146,7 @@ import { useWorkspaceAgentRoles, } from '@/hooks/use-workspace-agent-roles'; import { useAvailableCommands } from '@/hooks/use-available-commands'; -import { useOrganization } from '@/hooks/useOrganization'; +import { useWorkspaceSwitcher } from '@/hooks/use-workspace-switcher'; import { useResolvedTheme } from '../../theme-provider'; import { cloudOperations } from '@/lib/cloud-api-operations'; import { @@ -577,7 +577,7 @@ function WorkspaceChatLanding({ const currentUser = useAtomValue(userAtom); const userId = currentUser?.id; const tasksFeatureEnabled = useAtomValue(tasksFeatureEnabledAtom); - const { activeOrganization, organizations, switchOrganization } = useOrganization({ + const { activeOrganization, organizations, switchWorkspace } = useWorkspaceSwitcher({ targetSlug: workspaceSlug, }); const isMobile = useIsMobile(); @@ -5294,19 +5294,9 @@ function WorkspaceChatLanding({ const selectedProjectsSubTab: MobileProjectsSubTab = persistedProjectsSubTab; const handleMobileHomeWorkspaceSelect = useCallback( (nextWorkspaceId: string) => { - const targetOrganization = organizations?.find( - (organization) => organization.id === nextWorkspaceId - ); - if (!targetOrganization) return; - void switchOrganization(targetOrganization.id); - if (targetOrganization.slug) { - void navigate({ - to: '/$workspaceName/chat', - params: { workspaceName: targetOrganization.slug }, - }); - } + switchWorkspace(nextWorkspaceId); }, - [navigate, organizations, switchOrganization] + [switchWorkspace] ); const handleMobileHomeTabSelect = useCallback( (nextTab: MobileHomeTab) => { diff --git a/packages/components/src/components/loro-app-sidebar.tsx b/packages/components/src/components/loro-app-sidebar.tsx index b3184230e..793bdcf61 100644 --- a/packages/components/src/components/loro-app-sidebar.tsx +++ b/packages/components/src/components/loro-app-sidebar.tsx @@ -17,7 +17,6 @@ import { type SessionId, type SessionMeta, type SessionStatus, - type WorkspaceId, } from '@lody/shared'; import { useTranslation } from 'react-i18next'; import { cloudOperations } from '@/lib/cloud-api-operations'; @@ -50,7 +49,6 @@ import { joinCommunityDialogOpenAtom, currentWorkspaceIdAtom, currentWorkspaceSlugAtom, - setWorkspaceContextAtom, } from '@/atoms'; import { docMetaCacheScopeAtom } from '@/atoms/doc-meta'; import { useWorkspaceRouteTargetSlug } from '../providers/workspace-route-target'; @@ -91,7 +89,7 @@ import { pickLocalizedReleaseNotes, readUpdateBannerState } from '@/lib/electron import { useElectronUpdaterState } from '@/hooks/use-electron-updater-state'; import { useIsMobile } from '@/hooks/use-mobile'; import { useOpenSettings } from '@/hooks/use-open-settings'; -import { useOrganization } from '@/hooks/useOrganization'; +import { useWorkspaceSwitcher } from '@/hooks/use-workspace-switcher'; import { useVisibleSessionMetas } from '@/hooks/use-visible-session-metas'; import { useReportVisibleSessionsForEagerSync } from '@/hooks/use-report-visible-sessions-for-eager-sync'; import { @@ -166,7 +164,6 @@ import { import { toast } from 'sonner'; import { useOnlineMachineIds } from '@/hooks/use-machine-online-status'; import { useStableNow } from '@/hooks/use-stable-now'; -import { writePreferredWorkspaceSlug } from '@/lib/workspace'; import { SessionOpenedByTreeRow, SessionPrIcon, @@ -1410,7 +1407,6 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { const atomWorkspaceSlug = useAtomValue(currentWorkspaceSlugAtom); const routeTargetSlug = useWorkspaceRouteTargetSlug(); const workspaceSlug = routeTargetSlug ?? atomWorkspaceSlug; - const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom); const connectionUiState = useAtomValue(lodyConnectionUiStateAtom); const setMobileDrawerOpen = useSetAtom(setMobileDrawerOpenAtom); const language = useAtomValue(languageAtom); @@ -1448,7 +1444,7 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { [updaterState, resolvedLanguage] ); - const { organizations, activeOrganization, switchOrganization } = useOrganization(); + const { organizations, activeOrganization, switchWorkspace } = useWorkspaceSwitcher(); const runtime = useAtomValue(activeWorkspaceRuntimeAtom); const docMetaScope = useAtomValue(docMetaCacheScopeAtom); const organizationsReady = Array.isArray(organizations); @@ -2753,21 +2749,10 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { const handleWorkspaceSelected = useCallback( (nextWorkspaceId: string) => { - const target = (organizations ?? []).find((org) => org.id === nextWorkspaceId); - const slug = target?.slug; - if (!slug) { - return; - } - writePreferredWorkspaceSlug(slug); - setWorkspaceContext({ - slug, - workspaceId: target.id as WorkspaceId, - }); - void switchOrganization(target.id); + if (!switchWorkspace(nextWorkspaceId)) return; closeMobileDrawer(); - void router.navigate({ to: '/$workspaceName/chat', params: { workspaceName: slug } }); }, - [closeMobileDrawer, organizations, router, setWorkspaceContext, switchOrganization] + [closeMobileDrawer, switchWorkspace] ); const labels: Partial = useMemo(() => { diff --git a/packages/components/src/components/organization-switcher.tsx b/packages/components/src/components/organization-switcher.tsx index 86512bd94..48df740f1 100644 --- a/packages/components/src/components/organization-switcher.tsx +++ b/packages/components/src/components/organization-switcher.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'; import { Check, ChevronsUpDown, Plus, Building2, LogOut } from 'lucide-react'; import { cloudOperations } from '@/lib/cloud-api-operations'; import { useCloudQuery } from '@lody/platform/react'; -import { useOrganization } from '../hooks/useOrganization'; +import { useWorkspaceSwitcher } from '../hooks/use-workspace-switcher'; import { useWorkspaceSlugField } from '../hooks/useWorkspaceSlugField'; import { useTranslation } from 'react-i18next'; import { @@ -76,8 +76,8 @@ function LocalWorkspaceNameplate() { function CloudOrganizationSwitcher() { const { t } = useTranslation(); const signOut = useAuthSignOut(); - const { activeOrganization, organizations, loading, switchOrganization, createOrganization } = - useOrganization(); + const { activeOrganization, organizations, loading, switchWorkspace, createOrganization } = + useWorkspaceSwitcher(); const navigate = useNavigate(); const isMobile = useIsMobile(); // Paid workspaces only (free ones are omitted by the query); keyed by the @@ -194,14 +194,8 @@ function CloudOrganizationSwitcher() { { - void switchOrganization(org.id); + switchWorkspace(org.id); setOpen(false); - if (org.slug) { - void navigate({ - to: '/$workspaceName/chat', - params: { workspaceName: org.slug }, - }); - } }} >
diff --git a/packages/components/src/components/settings/AGENTS.md b/packages/components/src/components/settings/AGENTS.md index 808b1e924..163ec760a 100644 --- a/packages/components/src/components/settings/AGENTS.md +++ b/packages/components/src/components/settings/AGENTS.md @@ -28,6 +28,8 @@ rolls back — is in the root [AGENTS.md](../../../../../AGENTS.md). - Interface and terminal font choices exclude the known symbol families in `lib/local-fonts.ts`; persisted selections use the same filter. Font option names use the default interface font so they remain readable. +- Keyboard Shortcut workspace selectors assign stable workspace ids to numbered slots; + shortcut recording and collision policy remain owned by `lib/commands`. - The Codex reset forecast chip in the provider row must not fetch on mount and must pass `nestedInDialog` for its dialog: [../codex-reset/AGENTS.md](../codex-reset/AGENTS.md). - The usage share card is a fixed-format report, not a second `ChatShareCard`: its two diff --git a/packages/components/src/components/settings/keyboard-shortcuts-setting.tsx b/packages/components/src/components/settings/keyboard-shortcuts-setting.tsx index 09d178a9a..c945d6921 100644 --- a/packages/components/src/components/settings/keyboard-shortcuts-setting.tsx +++ b/packages/components/src/components/settings/keyboard-shortcuts-setting.tsx @@ -1,13 +1,18 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Trash2 } from 'lucide-react'; +import { useAtomValue } from 'jotai'; import { globalShortcutBindingHasModifier, type GlobalShortcutId, type GlobalShortcutSetError, } from '@lody/shared'; import { Button } from '@/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/ui/select'; +import { userAtom } from '@/atoms'; import { cn } from '@/lib/utils'; +import { isNavigableWorkspaceSlug } from '@/lib/workspace'; +import { WORKSPACE_SHORTCUT_SLOT_NUMBERS } from '@/lib/workspace-shortcut-slots'; import { canonicalizeBinding, commands, @@ -21,6 +26,8 @@ import { } from '@/lib/commands'; import type { GlobalShortcutBinding } from '@lody/shared'; import { useGlobalShortcuts } from '@/hooks/use-global-shortcuts'; +import { useOrganization } from '@/hooks/useOrganization'; +import { useWorkspaceShortcutSlots } from '@/hooks/use-workspace-shortcut-slots'; import { Kbd } from '@/components/commands/kbd'; import { CompactRow, CompactSection } from './compact-layout'; import { settingContainerClass } from '.'; @@ -40,6 +47,7 @@ const CATEGORY_ORDER: CommandCategory[] = [ // nothing to delete so the column doesn't shift when neighbors do. const SHORTCUT_SLOT_CLASS = 'flex w-36 justify-end'; const TRASH_SLOT_CLASS = 'flex w-9 justify-center'; +const UNASSIGNED_WORKSPACE_VALUE = '__unassigned__'; export function KeyboardShortcutsSetting() { const { t } = useTranslation(); @@ -110,6 +118,8 @@ export function KeyboardShortcutsSetting() {
)} + + {grouped.map(([category, items]) => ( + organizations?.filter( + (organization) => + organization.slug && isNavigableWorkspaceSlug(organization.slug) + ), + [organizations] + ); + const availableWorkspaceIds = useMemo( + () => assignableWorkspaces?.map((workspace) => workspace.id), + [assignableWorkspaces] + ); + const { slots, setSlot } = useWorkspaceShortcutSlots(userId, availableWorkspaceIds); + + if ( + getRuntime() !== 'electron' || + !assignableWorkspaces || + assignableWorkspaces.length <= 1 + ) { + return null; + } + + return ( + + {WORKSPACE_SHORTCUT_SLOT_NUMBERS.map((slot) => ( + + + + ))} + + ); +} + /** * Editable row for an OS-level global shortcut. Records a new combo (click → useKeyCapture), * persists it through the main process over IPC, and surfaces failures: combos without a diff --git a/packages/components/src/hooks/README.md b/packages/components/src/hooks/README.md index f6b842ced..47c99035e 100644 --- a/packages/components/src/hooks/README.md +++ b/packages/components/src/hooks/README.md @@ -12,6 +12,20 @@ ownership transfer, before organization permissions refresh. The membership hook therefore calls it directly and separately notifies `$activeOrgSignal`. Its tests use the plugin's actual action so a Promise-returning mock cannot hide this error. +## Workspace switching + +`use-workspace-switcher.ts` is the ordinary user-navigation boundary shared by sidebar, +mobile, selector, shortcut, and gesture entry points. It publishes the preferred slug and +the slug/id pair before requesting organization activation and routing the receiving window +to workspace home. Keeping those writes together prevents an entry point from navigating +with the previous workspace runtime still visible; onboarding recovery and workspace +creation retain their separate transactional flows. + +Numbered shortcuts resolve through `use-workspace-shortcut-slots.ts`. The underlying store +is keyed by user because a device may host multiple accounts, while storage events refresh +every renderer for the same account. Slot identity follows workspace id rather than catalog +order so later reordering cannot silently change what `Mod+1..9` opens. + ## Conversation scrolling (`use-sticky-scroll.ts`) `virtua` owns mounted rows, measurement, and index navigation. `use-stick-to-bottom` diff --git a/packages/components/src/hooks/use-workspace-shortcut-slots.ts b/packages/components/src/hooks/use-workspace-shortcut-slots.ts new file mode 100644 index 000000000..f37f66a7a --- /dev/null +++ b/packages/components/src/hooks/use-workspace-shortcut-slots.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react'; +import { + getWorkspaceShortcutSlots, + reconcileWorkspaceShortcutSlots, + setWorkspaceShortcutSlot, + subscribeWorkspaceShortcutSlots, + type WorkspaceShortcutSlotNumber, + type WorkspaceShortcutSlots, +} from '@/lib/workspace-shortcut-slots'; + +const EMPTY_SLOTS: WorkspaceShortcutSlots = Object.freeze([ + null, + null, + null, + null, + null, + null, + null, + null, + null, +]); + +export function useWorkspaceShortcutSlots( + userId: string | null, + availableWorkspaceIds: readonly string[] | undefined +): { + slots: WorkspaceShortcutSlots; + setSlot: (slot: WorkspaceShortcutSlotNumber, workspaceId: string | null) => void; +} { + const subscribe = useCallback( + (listener: () => void) => + userId ? subscribeWorkspaceShortcutSlots(userId, listener) : () => {}, + [userId] + ); + const getSnapshot = useCallback( + () => (userId ? getWorkspaceShortcutSlots(userId) : EMPTY_SLOTS), + [userId] + ); + const slots = useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_SLOTS); + const availableKey = availableWorkspaceIds?.join('\0') ?? null; + + useEffect(() => { + if (!userId || !availableWorkspaceIds) return; + reconcileWorkspaceShortcutSlots(userId, availableWorkspaceIds); + // The scalar key avoids re-running for a catalog array with unchanged ids. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableKey, userId]); + + const setSlot = useCallback( + (slot: WorkspaceShortcutSlotNumber, workspaceId: string | null) => { + if (!userId) return; + setWorkspaceShortcutSlot(userId, slot, workspaceId); + }, + [userId] + ); + + return { slots, setSlot }; +} diff --git a/packages/components/src/hooks/use-workspace-switcher.ts b/packages/components/src/hooks/use-workspace-switcher.ts new file mode 100644 index 000000000..0c49e0406 --- /dev/null +++ b/packages/components/src/hooks/use-workspace-switcher.ts @@ -0,0 +1,43 @@ +import { useCallback } from 'react'; +import { useRouter } from '@tanstack/react-router'; +import { useAtomValue, useSetAtom } from 'jotai'; +import type { WorkspaceId } from '@lody/shared'; +import { currentWorkspaceIdAtom, setWorkspaceContextAtom } from '@/atoms'; +import { isNavigableWorkspaceSlug, writePreferredWorkspaceSlug } from '@/lib/workspace'; +import { useOrganization } from './useOrganization'; + +type UseWorkspaceSwitcherOptions = { + targetSlug?: string; +}; + +/** One entry point for workspace identity, active-organization, and route transitions. */ +export function useWorkspaceSwitcher(options?: UseWorkspaceSwitcherOptions) { + const organizationState = useOrganization(options); + const { organizations, switchOrganization } = organizationState; + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom); + const router = useRouter(); + + const switchWorkspace = useCallback( + (workspaceId: string): boolean => { + const target = organizations?.find((organization) => organization.id === workspaceId); + if (!target?.slug || !isNavigableWorkspaceSlug(target.slug)) return false; + if (target.id === currentWorkspaceId) return true; + + writePreferredWorkspaceSlug(target.slug); + setWorkspaceContext({ + slug: target.slug, + workspaceId: target.id as WorkspaceId, + }); + void switchOrganization(target.id); + void router.navigate({ + to: '/$workspaceName/chat', + params: { workspaceName: target.slug }, + }); + return true; + }, + [currentWorkspaceId, organizations, router, setWorkspaceContext, switchOrganization] + ); + + return { ...organizationState, switchWorkspace }; +} diff --git a/packages/components/src/lib/commands/AGENTS.md b/packages/components/src/lib/commands/AGENTS.md index ac446fbbf..1ee499c2f 100644 --- a/packages/components/src/lib/commands/AGENTS.md +++ b/packages/components/src/lib/commands/AGENTS.md @@ -42,6 +42,9 @@ only tells global dispatch to yield for events originating in its subtree. `CommandShortcutHost` owns the renderer lifecycle, TanStack Hotkeys owns parsing/matching, and `user-bindings.ts` owns the storage subscription so changes take effect in all open windows. `[]` explicitly unbinds all defaults and `null` restores defaults. +- Desktop workspace slots are per-user, per-device workspace-id assignments. Catalog reorder + never renumbers them; removal clears only the missing id. Slot commands use `useCommand` + with Electron-only `Mod+1..9` defaults and the ordinary conflict index. ## File responsibilities diff --git a/packages/components/src/lib/commands/built-ins.ts b/packages/components/src/lib/commands/built-ins.ts index 1e3edabfa..23873ae9a 100644 --- a/packages/components/src/lib/commands/built-ins.ts +++ b/packages/components/src/lib/commands/built-ins.ts @@ -20,11 +20,9 @@ type BuiltInCommandDefinition = { // keeps them non-executable; a mounted real command replaces the placeholder by id. Titles // carry a `titleKey` (not a pre-translated string) because this module registers outside // React and can't call `t()` — display surfaces translate via the key instead. -// The Tasks commands (`tasks.quickAdd` / `tasks.open`) are deliberately absent: a -// placeholder here would list them in the palette and keyboard settings even for -// users who never enabled the Tasks beta. `app-commands.tsx` registers the real -// ones behind `tasksFeatureEnabledAtom`, and it is mounted workspace-wide, so -// nothing is lost once the beta is on. +// The Tasks commands (`tasks.quickAdd` / `tasks.open`) and workspace-slot commands are +// deliberately absent: placeholders would list unavailable features. `app-commands.tsx` +// registers their real definitions when the relevant catalog/feature is available. const UNAVAILABLE_COMMANDS: BuiltInCommandDefinition[] = [ { id: 'nav.back', diff --git a/packages/components/src/lib/commands/index.ts b/packages/components/src/lib/commands/index.ts index 4078bbf7c..1de006052 100644 --- a/packages/components/src/lib/commands/index.ts +++ b/packages/components/src/lib/commands/index.ts @@ -18,9 +18,14 @@ export { GLOBAL_SHORTCUTS, UNINTERCEPTABLE_WEB_KEYS, getCommandKeybindings, + getWorkspaceShortcutCommandId, keybindingAppliesToEnvironment, } from './shortcuts'; -export type { GlobalShortcut, ShortcutCommandId } from './shortcuts'; +export type { + GlobalShortcut, + ShortcutCommandId, + WorkspaceShortcutCommandId, +} from './shortcuts'; export { commandPaletteOpenAtom, useCommandPaletteState, diff --git a/packages/components/src/lib/commands/shortcuts.ts b/packages/components/src/lib/commands/shortcuts.ts index d16341b5f..552be6642 100644 --- a/packages/components/src/lib/commands/shortcuts.ts +++ b/packages/components/src/lib/commands/shortcuts.ts @@ -1,6 +1,9 @@ import { GLOBAL_SHORTCUT_DEFAULTS, type GlobalShortcutId } from '@lody/shared'; +import type { WorkspaceShortcutSlotNumber } from '../workspace-shortcut-slots'; import type { KeyBinding, Platform, Runtime } from './types'; +export type WorkspaceShortcutCommandId = `workspace.switchSlot.${WorkspaceShortcutSlotNumber}`; + export type ShortcutCommandId = | 'palette.toggle' | 'nav.back' @@ -8,6 +11,7 @@ export type ShortcutCommandId = | 'app.cycleTheme' | 'layout.toggleZenMode' | 'workspace.openSettings' + | WorkspaceShortcutCommandId | 'session.new' | 'session.archiveCurrent' | 'sidebar.toggle' @@ -59,6 +63,15 @@ export const COMMAND_SHORTCUTS: Record = // registry binding — a single source that also shows + is rebindable on the // keyboard-shortcuts settings page (instead of an invisible native-menu accelerator). 'workspace.openSettings': ['Mod+,'], + 'workspace.switchSlot.1': [electron('Mod+1')], + 'workspace.switchSlot.2': [electron('Mod+2')], + 'workspace.switchSlot.3': [electron('Mod+3')], + 'workspace.switchSlot.4': [electron('Mod+4')], + 'workspace.switchSlot.5': [electron('Mod+5')], + 'workspace.switchSlot.6': [electron('Mod+6')], + 'workspace.switchSlot.7': [electron('Mod+7')], + 'workspace.switchSlot.8': [electron('Mod+8')], + 'workspace.switchSlot.9': [electron('Mod+9')], 'session.new': [electron('Mod+n'), web('Mod+Alt+n')], 'session.archiveCurrent': ['Mod+Alt+a'], 'sidebar.toggle': ['Mod+b'], @@ -100,6 +113,12 @@ export function getCommandKeybindings(id: ShortcutCommandId): CommandKeybindings return COMMAND_SHORTCUTS[id].map(cloneKeybinding); } +export function getWorkspaceShortcutCommandId( + slot: WorkspaceShortcutSlotNumber +): WorkspaceShortcutCommandId { + return `workspace.switchSlot.${slot}`; +} + /** * OS-level global shortcuts registered in the Electron main process via * `globalShortcut.register` — NOT through this in-renderer command registry. They fire diff --git a/packages/components/src/lib/workspace-shortcut-slots.ts b/packages/components/src/lib/workspace-shortcut-slots.ts new file mode 100644 index 000000000..210613124 --- /dev/null +++ b/packages/components/src/lib/workspace-shortcut-slots.ts @@ -0,0 +1,187 @@ +import { z } from 'zod'; + +export const WORKSPACE_SHORTCUT_SLOT_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; +export type WorkspaceShortcutSlotNumber = (typeof WORKSPACE_SHORTCUT_SLOT_NUMBERS)[number]; + +export type WorkspaceShortcutSlots = readonly (string | null)[]; + +type WorkspaceShortcutSlotState = { + initialized: boolean; + slots: WorkspaceShortcutSlots; +}; + +const STORAGE_KEY_PREFIX = 'lody.workspaceShortcutSlots.v1:'; +const EMPTY_SLOTS: WorkspaceShortcutSlots = Object.freeze( + WORKSPACE_SHORTCUT_SLOT_NUMBERS.map(() => null) +); +const EMPTY_STATE: WorkspaceShortcutSlotState = Object.freeze({ + initialized: false, + slots: EMPTY_SLOTS, +}); +const storedStateSchema = z.object({ + version: z.literal(1), + slots: z.array(z.string().min(1).nullable()).length(WORKSPACE_SHORTCUT_SLOT_NUMBERS.length), +}); + +const snapshots = new Map(); +const listeners = new Map void>>(); +let storageSubscriberCount = 0; + +export function getWorkspaceShortcutSlotsStorageKey(userId: string): string { + return `${STORAGE_KEY_PREFIX}${encodeURIComponent(userId)}`; +} + +function readState(userId: string): WorkspaceShortcutSlotState { + if (typeof localStorage === 'undefined') return EMPTY_STATE; + const storageKey = getWorkspaceShortcutSlotsStorageKey(userId); + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return EMPTY_STATE; + const parsed = storedStateSchema.safeParse(JSON.parse(raw)); + if (parsed.success) { + return { initialized: true, slots: Object.freeze([...parsed.data.slots]) }; + } + localStorage.removeItem(storageKey); + } catch { + try { + localStorage.removeItem(storageKey); + } catch { + // Ignore unavailable storage. The in-memory snapshot still works for this renderer. + } + } + return EMPTY_STATE; +} + +function getState(userId: string): WorkspaceShortcutSlotState { + const current = snapshots.get(userId); + if (current) return current; + const loaded = readState(userId); + snapshots.set(userId, loaded); + return loaded; +} + +function notify(userId: string): void { + for (const listener of listeners.get(userId) ?? []) listener(); +} + +function writeState(userId: string, slots: WorkspaceShortcutSlots): WorkspaceShortcutSlots { + const nextSlots = Object.freeze([...slots]); + const current = getState(userId); + if ( + current.initialized && + current.slots.every((workspaceId, index) => workspaceId === nextSlots[index]) + ) { + return current.slots; + } + + snapshots.set(userId, { initialized: true, slots: nextSlots }); + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem( + getWorkspaceShortcutSlotsStorageKey(userId), + JSON.stringify({ version: 1, slots: nextSlots }) + ); + } catch { + // Keep the in-memory preference when storage is unavailable. + } + } + notify(userId); + return nextSlots; +} + +function handleStorage(event: StorageEvent): void { + if ( + event.storageArea && + typeof localStorage !== 'undefined' && + event.storageArea !== localStorage + ) { + return; + } + if (event.key === null) { + for (const [userId] of listeners) { + snapshots.set(userId, readState(userId)); + notify(userId); + } + return; + } + if (!event.key.startsWith(STORAGE_KEY_PREFIX)) return; + for (const [userId] of listeners) { + if (event.key !== getWorkspaceShortcutSlotsStorageKey(userId)) continue; + snapshots.set(userId, readState(userId)); + notify(userId); + return; + } +} + +export function getWorkspaceShortcutSlots(userId: string): WorkspaceShortcutSlots { + return getState(userId).slots; +} + +export function subscribeWorkspaceShortcutSlots(userId: string, listener: () => void): () => void { + let userListeners = listeners.get(userId); + if (!userListeners) { + userListeners = new Set(); + listeners.set(userId, userListeners); + } + userListeners.add(listener); + storageSubscriberCount += 1; + if (storageSubscriberCount === 1 && typeof window !== 'undefined') { + window.addEventListener('storage', handleStorage); + } + + return () => { + const currentListeners = listeners.get(userId); + currentListeners?.delete(listener); + if (currentListeners?.size === 0) listeners.delete(userId); + storageSubscriberCount -= 1; + if (storageSubscriberCount === 0 && typeof window !== 'undefined') { + window.removeEventListener('storage', handleStorage); + } + }; +} + +/** Initialize once from catalog order, then preserve slot identity across later reordering. */ +export function reconcileWorkspaceShortcutSlots( + userId: string, + availableWorkspaceIds: readonly string[] +): WorkspaceShortcutSlots { + const state = getState(userId); + const uniqueAvailableIds = [...new Set(availableWorkspaceIds.filter(Boolean))]; + if (!state.initialized) { + return writeState( + userId, + WORKSPACE_SHORTCUT_SLOT_NUMBERS.map((_, index) => uniqueAvailableIds[index] ?? null) + ); + } + + const available = new Set(uniqueAvailableIds); + const seen = new Set(); + const next = state.slots.map((workspaceId) => { + if (workspaceId === null || !available.has(workspaceId) || seen.has(workspaceId)) { + return null; + } + seen.add(workspaceId); + return workspaceId; + }); + return writeState(userId, next); +} + +/** Assign a stable workspace id to a slot, moving it out of any previous slot. */ +export function setWorkspaceShortcutSlot( + userId: string, + slot: WorkspaceShortcutSlotNumber, + workspaceId: string | null +): WorkspaceShortcutSlots { + const next = [...getState(userId).slots]; + if (workspaceId !== null) { + for (let index = 0; index < next.length; index += 1) { + if (next[index] === workspaceId) next[index] = null; + } + } + next[slot - 1] = workspaceId; + return writeState(userId, next); +} + +export function __resetWorkspaceShortcutSlotsForTests(): void { + snapshots.clear(); +} diff --git a/packages/components/tests/commands-built-ins.test.ts b/packages/components/tests/commands-built-ins.test.ts index 98e39a13f..acd5f4e1a 100644 --- a/packages/components/tests/commands-built-ins.test.ts +++ b/packages/components/tests/commands-built-ins.test.ts @@ -1,6 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { commands, registerBuiltInCommands, unregisterBuiltInCommands } from '../src/lib/commands'; +import { + commands, + getCommandKeybindings, + getWorkspaceShortcutCommandId, + registerBuiltInCommands, + unregisterBuiltInCommands, +} from '../src/lib/commands'; +import { WORKSPACE_SHORTCUT_SLOT_NUMBERS } from '../src/lib/workspace-shortcut-slots'; import { __resetPlatformCacheForTests } from '../src/lib/commands/platform'; beforeEach(() => { @@ -68,5 +75,14 @@ describe('built-in commands', () => { // ⌘, but registerAccelerator:false leaves the key to the registry), so it shows here too. expect(commands.getDefaultKeybindingsFor('workspace.openSettings')).toEqual(['Mod+,']); expect(commands.getDefaultKeybindingsFor('layout.toggleZenMode')).toEqual(['Mod+.']); + expect( + WORKSPACE_SHORTCUT_SLOT_NUMBERS.map((slot) => + getCommandKeybindings(getWorkspaceShortcutCommandId(slot)) + ) + ).toEqual( + WORKSPACE_SHORTCUT_SLOT_NUMBERS.map((slot) => [ + { key: `Mod+${slot}`, runtimes: ['electron'] }, + ]) + ); }); }); diff --git a/packages/components/tests/workspace-shortcut-switching.test.tsx b/packages/components/tests/workspace-shortcut-switching.test.tsx new file mode 100644 index 000000000..081145118 --- /dev/null +++ b/packages/components/tests/workspace-shortcut-switching.test.tsx @@ -0,0 +1,365 @@ +// @vitest-environment jsdom +// @vitest-environment-options { "url": "https://lody.local/" } + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider, createStore } from 'jotai'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkspaceId } from '@lody/shared'; +import { + currentWorkspaceIdAtom, + currentWorkspaceSlugAtom, +} from '../src/atoms/workspace-context'; +import { userAtom } from '../src/atoms'; +import { KeyboardShortcutsSetting } from '../src/components/settings/keyboard-shortcuts-setting'; +import { WorkspaceShortcutCommands } from '../src/components/app-commands'; +import { initI18n } from '../src/i18n'; +import { __resetPlatformCacheForTests } from '../src/lib/commands/platform'; +import { commands } from '../src/lib/commands'; +import { CommandShortcutHost } from '../src/lib/commands/shortcut-host'; +import { readPreferredWorkspaceSlug } from '../src/lib/workspace'; +import { + __resetWorkspaceShortcutSlotsForTests, + getWorkspaceShortcutSlots, + getWorkspaceShortcutSlotsStorageKey, + reconcileWorkspaceShortcutSlots, + setWorkspaceShortcutSlot, + subscribeWorkspaceShortcutSlots, +} from '../src/lib/workspace-shortcut-slots'; +import { useWorkspaceSwitcher } from '../src/hooks/use-workspace-switcher'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +const workspaceState = vi.hoisted(() => ({ + activeId: 'workspace-a', + path: '/alpha/sessions/current', + organizations: [ + { id: 'workspace-a', name: 'Alpha', slug: 'alpha' }, + { id: 'workspace-b', name: 'Beta', slug: 'beta' }, + ], +})); + +vi.mock('@tanstack/react-router', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useRouter: () => ({ + navigate: ({ params }: { params: { workspaceName: string } }) => { + workspaceState.path = `/${params.workspaceName}/chat`; + }, + }), + }; +}); + +vi.mock('../src/hooks/useOrganization', () => ({ + useOrganization: () => ({ + organizations: workspaceState.organizations, + switchOrganization: async (workspaceId: string) => { + workspaceState.activeId = workspaceId; + }, + }), +})); + +vi.mock('../src/hooks/use-global-shortcuts', () => ({ + useGlobalShortcuts: () => ({ + shortcuts: [], + setBinding: async () => ({ ok: true }), + }), +})); + +describe('workspace shortcut slots', () => { + beforeEach(() => { + localStorage.clear(); + __resetWorkspaceShortcutSlotsForTests(); + }); + + it('pins initial catalog order to stable ids without shifting surviving slots', () => { + expect(reconcileWorkspaceShortcutSlots('user-a', ['a', 'b', 'c'])).toEqual([ + 'a', + 'b', + 'c', + null, + null, + null, + null, + null, + null, + ]); + + expect(reconcileWorkspaceShortcutSlots('user-a', ['c', 'a', 'b'])).toEqual([ + 'a', + 'b', + 'c', + null, + null, + null, + null, + null, + null, + ]); + expect(reconcileWorkspaceShortcutSlots('user-a', ['c', 'a'])).toEqual([ + 'a', + null, + 'c', + null, + null, + null, + null, + null, + null, + ]); + }); + + it('moves a workspace between slots and keeps different users isolated', () => { + reconcileWorkspaceShortcutSlots('user-a', ['a', 'b']); + reconcileWorkspaceShortcutSlots('user-b', ['x', 'y']); + + expect(setWorkspaceShortcutSlot('user-a', 4, 'a')).toEqual([ + null, + 'b', + null, + 'a', + null, + null, + null, + null, + null, + ]); + expect(getWorkspaceShortcutSlots('user-b')).toEqual([ + 'x', + 'y', + null, + null, + null, + null, + null, + null, + null, + ]); + }); + + it('publishes a storage change from another renderer to live subscribers', () => { + reconcileWorkspaceShortcutSlots('user-a', ['a', 'b']); + let observed = getWorkspaceShortcutSlots('user-a'); + const unsubscribe = subscribeWorkspaceShortcutSlots('user-a', () => { + observed = getWorkspaceShortcutSlots('user-a'); + }); + const remoteSlots = ['b', 'a', null, null, null, null, null, null, null]; + const storageKey = getWorkspaceShortcutSlotsStorageKey('user-a'); + + sessionStorage.setItem(storageKey, JSON.stringify({ version: 1, slots: remoteSlots })); + window.dispatchEvent( + new StorageEvent('storage', { key: storageKey, storageArea: sessionStorage }) + ); + expect(observed).toEqual(['a', 'b', null, null, null, null, null, null, null]); + + localStorage.setItem(storageKey, JSON.stringify({ version: 1, slots: remoteSlots })); + window.dispatchEvent( + new StorageEvent('storage', { key: storageKey, storageArea: localStorage }) + ); + + expect(observed).toEqual(remoteSlots); + + localStorage.clear(); + window.dispatchEvent(new StorageEvent('storage', { key: null })); + expect(observed).toEqual([null, null, null, null, null, null, null, null, null]); + unsubscribe(); + }); + + it('clears invalid and duplicate ids without shifting another slot', () => { + const storageKey = getWorkspaceShortcutSlotsStorageKey('user-a'); + localStorage.setItem( + storageKey, + JSON.stringify({ + version: 1, + slots: ['a', 'missing', 'a', 'b', null, null, null, null, null], + }) + ); + + expect(reconcileWorkspaceShortcutSlots('user-a', ['b', 'a'])).toEqual([ + 'a', + null, + null, + 'b', + null, + null, + null, + null, + null, + ]); + }); +}); + +describe('unified workspace switching', () => { + let container: HTMLDivElement; + let root: Root; + const store = createStore(); + + beforeEach(() => { + localStorage.clear(); + workspaceState.activeId = 'workspace-a'; + workspaceState.path = '/alpha/sessions/current'; + store.set(currentWorkspaceSlugAtom, 'alpha'); + store.set(currentWorkspaceIdAtom, 'workspace-a' as WorkspaceId); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + function Harness({ targetId }: { targetId: string }) { + const { switchWorkspace } = useWorkspaceSwitcher(); + return ; + } + + it('publishes one coherent identity and route transition for every entry point', async () => { + await act(async () => { + root.render( + + + + ); + }); + await act(async () => { + container.querySelector('button')?.click(); + }); + + expect({ + workspaceId: store.get(currentWorkspaceIdAtom), + workspaceSlug: store.get(currentWorkspaceSlugAtom), + preferredSlug: readPreferredWorkspaceSlug(), + activeId: workspaceState.activeId, + path: workspaceState.path, + }).toEqual({ + workspaceId: 'workspace-b', + workspaceSlug: 'beta', + preferredSlug: 'beta', + activeId: 'workspace-b', + path: '/beta/chat', + }); + }); + + it('leaves the current route and identity untouched for the active or unknown workspace', async () => { + await act(async () => { + root.render( + + + + ); + }); + act(() => container.querySelector('button')?.click()); + + await act(async () => { + root.render( + + + + ); + }); + act(() => container.querySelector('button')?.click()); + + expect({ + workspaceId: store.get(currentWorkspaceIdAtom), + workspaceSlug: store.get(currentWorkspaceSlugAtom), + preferredSlug: readPreferredWorkspaceSlug(), + activeId: workspaceState.activeId, + path: workspaceState.path, + }).toEqual({ + workspaceId: 'workspace-a', + workspaceSlug: 'alpha', + preferredSlug: null, + activeId: 'workspace-a', + path: '/alpha/sessions/current', + }); + }); +}); + +describe('workspace shortcut settings', () => { + let container: HTMLDivElement; + let root: Root; + const store = createStore(); + + beforeEach(async () => { + await initI18n('en'); + localStorage.clear(); + __resetWorkspaceShortcutSlotsForTests(); + Object.assign(window, { + __LODY_ELECTRON__: true, + __LODY_PLATFORM__: { os: 'darwin' }, + }); + __resetPlatformCacheForTests(); + store.set(userAtom, { id: 'user-a', email: 'user@example.test', name: 'User' }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + for (const command of commands.list()) commands.unregister(command.id); + delete window.__LODY_ELECTRON__; + delete window.__LODY_PLATFORM__; + __resetPlatformCacheForTests(); + }); + + it('shows stable workspace assignments for all nine desktop slots', async () => { + await act(async () => { + root.render( + + + + ); + }); + + const selectors = container.querySelectorAll('[data-workspace-shortcut-slot]'); + expect(selectors).toHaveLength(9); + expect(selectors[0]?.textContent).toContain('Alpha'); + expect(selectors[1]?.textContent).toContain('Beta'); + expect(selectors[2]?.textContent).toContain('Unassigned'); + }); + + it('dispatches Mod+2 through the command host and unified switch boundary', async () => { + store.set(currentWorkspaceSlugAtom, 'alpha'); + store.set(currentWorkspaceIdAtom, 'workspace-a' as WorkspaceId); + workspaceState.activeId = 'workspace-a'; + workspaceState.path = '/alpha/sessions/current'; + + await act(async () => { + root.render( + + + + + ); + }); + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: '2', + code: 'Digit2', + metaKey: true, + bubbles: true, + cancelable: true, + }) + ); + }); + + expect({ + workspaceId: store.get(currentWorkspaceIdAtom), + workspaceSlug: store.get(currentWorkspaceSlugAtom), + activeId: workspaceState.activeId, + path: workspaceState.path, + }).toEqual({ + workspaceId: 'workspace-b', + workspaceSlug: 'beta', + activeId: 'workspace-b', + path: '/beta/chat', + }); + }); +}); diff --git a/specs/workspace-switching-shortcuts.zh.md b/specs/workspace-switching-shortcuts.zh.md new file mode 100644 index 000000000..be60e1764 --- /dev/null +++ b/specs/workspace-switching-shortcuts.zh.md @@ -0,0 +1,28 @@ +# 工作区切换快捷键 + +Status: draft +Translation: pending + +桌面多工作区用户可用一个数字快捷键进入指定工作区,无需展开侧栏。数字 1–9 是可配置 slot,默认绑定为 `Mod+1..9`;`Mod` 在 macOS 表示 Command,在其他桌面平台表示 Control。Web、移动端和公开 local-only 桌面组合不承诺多工作区切换能力。 + +## Slot 身份 + +首次得到可导航的工作区列表时,客户端按当时的目录顺序填充最多九个 slot,随后持久化 workspace ID。目录重排不得改变已有编号;失去访问权限或删除工作区只清空它所在的 slot,不得让后续编号前移。同一 workspace ID 最多占一个 slot,用户在键盘快捷键设置中把它分配到新 slot 时,旧 slot 同时清空。 + +Slot 是按用户隔离的设备偏好,不写入 workspace 数据或跨设备同步。同一桌面 session 的多个 renderer 共享 localStorage;任一窗口修改后,其他窗口必须在不重载的情况下使用新映射。损坏或引用不可用 workspace 的存储值被当作未分配,不触发未知路由。 + +## 命令与切换 + +只有已分配且当前可访问的 slot 注册应用命令。命令通过统一 registry 和 `useCommand` 接入,因此出现在键盘快捷键设置中,并复用普通应用命令的录制、解绑、平台标准化及应用内/操作系统全局冲突检测。默认数字绑定只在 Electron 生效;用户改绑完整替换该命令默认值。 + +选择当前工作区是成功的无操作,保留当前页面或 Session。选择其他工作区时,一个统一入口更新 preferred slug 和当前 slug/ID 上下文,请求激活对应 organization,并只把接收操作的窗口导航到目标工作区首页。侧栏、移动选择器、旧 organization selector、数字命令和后续 swipe 手势不得各自复制这组状态写入。 + +## 后续手势 + +Issue #288 的侧栏 swipe 仍是后续工作。手势只负责方向、阈值、防误触与边界判断;确定目标后必须调用相同的 workspace-ID 切换入口,不得按渲染位置直接拼路由。 + +## 验证边界 + +组件测试使用两个合成工作区覆盖真实状态边界、slot 稳定性、删除、重新分配、用户隔离和跨窗口 storage 更新。公开 OSS Electron E2E 只能启动一个隐式本地工作区,因此它验证真实 renderer 中 slot 命令注册、默认绑定和多窗口可见性,但不能伪造云端第二工作区;跨工作区导航由组件集成测试承担。 + +需求:[Issue #288](https://github.com/LodyAI/Lody/issues/288)。