Skip to content

Commit abcdbdd

Browse files
committed
fix: keep workspace tabs stable when switching
1 parent 8160d89 commit abcdbdd

4 files changed

Lines changed: 80 additions & 38 deletions

File tree

apps/web/src/features/command-palette/actions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,15 @@ export const buildCommandPaletteActions = ({
107107
{
108108
id: "switch-prev-workspace",
109109
label: locale === "zh" ? "切换到上一个工作区" : "Switch To Previous Workspace",
110-
description: locale === "zh" ? "按时间序列回到上一个工作区" : "Jump to the previous workspace in the stack",
110+
description: locale === "zh" ? "按当前标签顺序回到上一个工作区" : "Jump to the previous workspace tab",
111111
shortcut: "⌘/Ctrl ⇧ [",
112112
keywords: "workspace previous back",
113113
run: () => onCycleWorkspace(-1),
114114
},
115115
{
116116
id: "switch-next-workspace",
117117
label: locale === "zh" ? "切换到下一个工作区" : "Switch To Next Workspace",
118-
description: locale === "zh" ? "按时间序列前往下一个工作区" : "Jump to the next workspace in the stack",
118+
description: locale === "zh" ? "按当前标签顺序前往下一个工作区" : "Jump to the next workspace tab",
119119
shortcut: "⌘/Ctrl ⇧ ]",
120120
keywords: "workspace next forward",
121121
run: () => onCycleWorkspace(1),

apps/web/src/features/workspace/WorkspaceScreen.tsx

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1959,34 +1959,24 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
19591959
["--right-split" as string]: `${state.layout.rightSplit}%`
19601960
};
19611961

1962-
const workspaceTabs = [...state.tabs]
1963-
.sort((left, right) => {
1962+
const workspaceTabs = state.tabs.map((tab) => {
1963+
const sessions = [...tab.sessions].sort((left, right) => {
19641964
if (sessionSort === "name") {
1965-
return (displayPathName(left.project?.path) || displayWorkspaceTitle(left.title))
1966-
.localeCompare(displayPathName(right.project?.path) || displayWorkspaceTitle(right.title), locale === "zh" ? "zh-CN" : "en");
1965+
return displaySessionTitle(left.title).localeCompare(displaySessionTitle(right.title), locale === "zh" ? "zh-CN" : "en");
19671966
}
1968-
const leftTime = Math.max(...left.sessions.map((session) => session.lastActiveAt));
1969-
const rightTime = Math.max(...right.sessions.map((session) => session.lastActiveAt));
1970-
return rightTime - leftTime;
1971-
})
1972-
.map((tab) => {
1973-
const sessions = [...tab.sessions].sort((left, right) => {
1974-
if (sessionSort === "name") {
1975-
return displaySessionTitle(left.title).localeCompare(displaySessionTitle(right.title), locale === "zh" ? "zh-CN" : "en");
1976-
}
1977-
return right.lastActiveAt - left.lastActiveAt;
1978-
});
1979-
const hasRunning = sessions.some((session) => ["running", "waiting", "background"].includes(session.status));
1980-
const unread = sessions.reduce((sum, session) => sum + session.unread, 0);
1981-
return {
1982-
id: tab.id,
1983-
label: displayPathName(tab.project?.path) || displayWorkspaceTitle(tab.title),
1984-
active: tab.id === state.activeTabId,
1985-
hasRunning,
1986-
unread,
1987-
sessions
1988-
};
1967+
return right.lastActiveAt - left.lastActiveAt;
19891968
});
1969+
const hasRunning = sessions.some((session) => ["running", "waiting", "background"].includes(session.status));
1970+
const unread = sessions.reduce((sum, session) => sum + session.unread, 0);
1971+
return {
1972+
id: tab.id,
1973+
label: displayPathName(tab.project?.path) || displayWorkspaceTitle(tab.title),
1974+
active: tab.id === state.activeTabId,
1975+
hasRunning,
1976+
unread,
1977+
sessions
1978+
};
1979+
});
19901980
const commandPaletteActions = buildCommandPaletteActions({
19911981
locale,
19921982
t,

apps/web/src/features/workspace/workspace-tabs.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,11 @@ export const buildWorkspaceTabItems = (
88
tabs: Tab[],
99
activeTabId: string,
1010
locale: Locale,
11-
sort: "time" | "name" = "time"
12-
): WorkspaceTabItem[] => [...tabs]
13-
.sort((left, right) => {
14-
if (sort === "name") {
15-
return (displayPathName(left.project?.path) || localizeWorkspaceTitle(left.title, locale))
16-
.localeCompare(displayPathName(right.project?.path) || localizeWorkspaceTitle(right.title, locale), locale === "zh" ? "zh-CN" : "en");
17-
}
18-
const leftTime = Math.max(...left.sessions.map((session) => session.lastActiveAt));
19-
const rightTime = Math.max(...right.sessions.map((session) => session.lastActiveAt));
20-
return rightTime - leftTime;
21-
})
11+
sort: "default" | "name" = "default"
12+
): WorkspaceTabItem[] => (sort === "name"
13+
? [...tabs].sort((left, right) => (displayPathName(left.project?.path) || localizeWorkspaceTitle(left.title, locale))
14+
.localeCompare(displayPathName(right.project?.path) || localizeWorkspaceTitle(right.title, locale), locale === "zh" ? "zh-CN" : "en"))
15+
: tabs)
2216
.map((tab) => ({
2317
id: tab.id,
2418
label: displayPathName(tab.project?.path) || localizeWorkspaceTitle(tab.title, locale),

tests/e2e/e2e.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import os from 'node:os';
22
import path from 'node:path';
3+
import fs from 'node:fs/promises';
34
import type { Page } from '@playwright/test';
45
import { expect, test } from '@playwright/test';
56

67
const HOME_DIR = os.homedir();
78
const HOME_LABEL = path.basename(HOME_DIR) || HOME_DIR;
9+
const TAB_STABILITY_DIRS = [
10+
path.join(HOME_DIR, 'coder-studio-e2e-tab-a'),
11+
path.join(HOME_DIR, 'coder-studio-e2e-tab-b'),
12+
];
13+
const TAB_STABILITY_LABELS = TAB_STABILITY_DIRS.map((dir) => path.basename(dir));
814

915
const openLaunchOverlay = async (page: Page) => {
1016
await page.goto('/');
@@ -34,6 +40,29 @@ const launchLocalWorkspace = async (page: Page) => {
3440
await expect(page.getByTestId('workspace-topbar')).toBeVisible();
3541
};
3642

43+
const invokeRpc = async <T>(page: Page, command: string, payload: Record<string, unknown> = {}) => {
44+
const response = await page.request.post(`/api/rpc/${command}`, { data: payload });
45+
expect(response.ok()).toBeTruthy();
46+
const body = await response.json();
47+
expect(body.ok).not.toBe(false);
48+
return body.data as T;
49+
};
50+
51+
const launchWorkspaceByPath = async (page: Page, workspacePath: string) => {
52+
await invokeRpc(page, 'launch_workspace', {
53+
source: {
54+
kind: 'local',
55+
pathOrUrl: workspacePath,
56+
target: { type: 'native' },
57+
},
58+
});
59+
};
60+
61+
const readWorkspaceTabLabels = async (page: Page) =>
62+
page.locator('.workspace-top-tab .session-top-label').evaluateAll((nodes) =>
63+
nodes.map((node) => node.textContent?.trim() ?? '').filter(Boolean)
64+
);
65+
3766
test.beforeEach(async ({ page }) => {
3867
await page.addInitScript(() => {
3968
if (!window.sessionStorage.getItem('coder-studio.test-init')) {
@@ -45,6 +74,14 @@ test.beforeEach(async ({ page }) => {
4574
});
4675
});
4776

77+
test.beforeAll(async () => {
78+
await Promise.all(TAB_STABILITY_DIRS.map((dir) => fs.mkdir(dir, { recursive: true })));
79+
});
80+
81+
test.afterAll(async () => {
82+
await Promise.all(TAB_STABILITY_DIRS.map((dir) => fs.rm(dir, { recursive: true, force: true })));
83+
});
84+
4885
test('local workspace flow opens the workspace shell', async ({ page }) => {
4986
await launchLocalWorkspace(page);
5087
await expect(page.getByTestId('workspace-topbar')).toContainText(HOME_LABEL);
@@ -95,3 +132,24 @@ test('restores the last workspace after reload', async ({ page }) => {
95132
await expect(page.getByTestId('overlay')).toHaveCount(0);
96133
await expect(page.getByTestId('workspace-topbar')).toContainText(HOME_LABEL);
97134
});
135+
136+
test('workspace tabs keep a stable order when switching between workspaces', async ({ page }) => {
137+
await launchWorkspaceByPath(page, TAB_STABILITY_DIRS[0]);
138+
await launchWorkspaceByPath(page, TAB_STABILITY_DIRS[1]);
139+
await page.goto('/');
140+
await expect(page.getByTestId('workspace-topbar')).toBeVisible();
141+
142+
const initialOrder = await readWorkspaceTabLabels(page);
143+
const initialFirstIndex = initialOrder.indexOf(TAB_STABILITY_LABELS[0]);
144+
const initialSecondIndex = initialOrder.indexOf(TAB_STABILITY_LABELS[1]);
145+
expect(initialFirstIndex).toBeGreaterThanOrEqual(0);
146+
expect(initialSecondIndex).toBeGreaterThan(initialFirstIndex);
147+
148+
await page.locator('.workspace-top-tab').filter({ hasText: TAB_STABILITY_LABELS[0] }).click();
149+
await expect(page.locator('.workspace-top-tab.active .session-top-label')).toHaveText(TAB_STABILITY_LABELS[0]);
150+
await expect.poll(() => readWorkspaceTabLabels(page)).toEqual(initialOrder);
151+
152+
await page.locator('.workspace-top-tab').filter({ hasText: TAB_STABILITY_LABELS[1] }).click();
153+
await expect(page.locator('.workspace-top-tab.active .session-top-label')).toHaveText(TAB_STABILITY_LABELS[1]);
154+
await expect.poll(() => readWorkspaceTabLabels(page)).toEqual(initialOrder);
155+
});

0 commit comments

Comments
 (0)