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)。