Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)。
12 changes: 6 additions & 6 deletions e2e/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 8 additions & 3 deletions e2e/journeys/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions e2e/src/support/pages/shortcut-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
5 changes: 5 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions locales/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 不支持当前机器的平台",
Expand Down Expand Up @@ -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": "导入本地项目",
Expand Down
94 changes: 92 additions & 2 deletions packages/components/src/components/app-commands.tsx
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -145,5 +163,77 @@ export function AppCommands() {
},
});

return <WorkspaceShortcutCommands />;
}

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 (
<WorkspaceShortcutCommand
key={slot}
slot={slot}
title={
workspace
? t('commands.workspace.switchSlotWithName', {
slot,
workspace: workspace.name,
})
: t('commands.workspace.switchSlot', { slot })
}
workspaceId={workspace?.id ?? null}
currentWorkspaceId={currentWorkspaceId}
enabled={getRuntime() === 'electron' && Boolean(workspace)}
onSwitch={switchWorkspace}
/>
);
})}
</>
);
}

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;
}
18 changes: 4 additions & 14 deletions packages/components/src/components/chat/chat-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading