Skip to content
Draft
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
57 changes: 57 additions & 0 deletions e2e-tests/chat_tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,63 @@ test("right-click context menu: Close tabs to the right", async ({ po }) => {
}).toPass({ timeout: Timeout.MEDIUM });
});

test("adds, focuses, and removes chats in a workspace", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");

await po.sendPrompt("[dump] Workspace chat one");
await po.chatActions.waitForChatCompletion();
const firstActiveChatTab = po.page
.locator('[data-testid^="chat-tab-"][draggable="true"]')
.filter({ has: po.page.locator('button[aria-current="page"]') });
await expect(firstActiveChatTab).toBeVisible({ timeout: Timeout.MEDIUM });
const firstChatId = Number(
(await firstActiveChatTab.getAttribute("data-testid"))?.replace(
"chat-tab-",
"",
),
);

await po.chatActions.clickNewChat();
await po.sendPrompt("[dump] Workspace chat two");
await po.chatActions.waitForChatCompletion();
const secondChatTab = po.page
.locator('[data-testid^="chat-tab-"][draggable="true"]')
.filter({ has: po.page.locator('button[aria-current="page"]') });
await expect(secondChatTab).toBeVisible({ timeout: Timeout.MEDIUM });
const secondChatId = Number(
(await secondChatTab.getAttribute("data-testid"))?.replace("chat-tab-", ""),
);
expect(secondChatId).not.toBe(firstChatId);
const firstChatTab = po.page.getByTestId(`chat-tab-${firstChatId}`);
const secondOwnedChatTab = po.page.getByTestId(`chat-tab-${secondChatId}`);

await firstChatTab.click({ button: "right" });
await po.page.getByText("Add to workspace", { exact: true }).click();
await secondOwnedChatTab.click({ button: "right" });
await po.page.getByText("Add to workspace", { exact: true }).click();

const workspaceTab = po.page.locator(
'[data-testid^="chat-workspace-tab-"] button',
);
await expect(workspaceTab).toBeVisible({ timeout: Timeout.MEDIUM });
await workspaceTab.click();

const firstPane = po.page.getByTestId(`chat-workspace-pane-${firstChatId}`);
const secondPane = po.page.getByTestId(`chat-workspace-pane-${secondChatId}`);
await expect(firstPane).toBeVisible({ timeout: Timeout.MEDIUM });
await expect(secondPane).toBeVisible({ timeout: Timeout.MEDIUM });

await firstPane.click({ position: { x: 8, y: 8 } });
await expect(firstPane).toHaveAttribute("aria-label", /, focused$/);

await firstPane
.getByRole("button", { name: /^Remove .* from workspace$/ })
.click();
await expect(firstPane).toHaveCount(0);
await expect(secondPane).toBeVisible();
});

test("right-click context menu: Reopen closed tab", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
Expand Down
388 changes: 388 additions & 0 deletions plans/multi-chat-workspace.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions rules/e2e-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ If a targeted E2E fails before launch with `ENOENT: no such file or directory, s
- **After `page.reload()`**: Always add `await page.waitForLoadState("domcontentloaded")` before interacting with elements. Without this, the page may not have re-rendered yet.
- **Keyboard navigation events (ArrowUp/ArrowDown)**: Add `await page.waitForTimeout(100)` between sequential keyboard presses to let the UI state settle. Rapid keypresses can cause race conditions in menu navigation.
- **Navigation to tabs**: Use `await expect(link).toBeVisible({ timeout: Timeout.EXTRA_LONG })` before clicking tab links (especially in `goToAppsTab()`). Electron sidebar links can take time to render during app initialization.
- **Chat-tab test-id prefixes**: `[data-testid^="chat-tab-"]` also matches `chat-tab-drop-zone`. When a test needs actual tabs, restrict the locator to draggable elements: `[data-testid^="chat-tab-"][draggable="true"]`.
- **Collapsed sidebar app/chat lists**: App and chat sub-lists may be hidden until the sidebar rail item is hovered. Use page-object helpers such as `po.appManagement.showAppList()` or `po.chatActions.clickNewChat()` instead of asserting list items are visible immediately after navigation.
- **Starting a new chat during an active first turn**: Before calling `clickNewChat()`, wait until the first chat's ID is present in the URL. If the helper captures a null prior ID, the first chat's delayed navigation can satisfy its "some chat selected" assertion and leave the next prompt in the original chat.
- **Imported chat tab state**: Import flows should select the newly created chat through `useSelectChat().selectChat(...)`, not direct `navigate({ to: "/chat" })`, so current-session chat tabs are seeded consistently.
Expand Down
10 changes: 10 additions & 0 deletions rules/jotai-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ notification, reopen, and tab actions must not bypass the transition. Scope
delayed DOM restoration (for example scroll retries) to the selected entity and
a generation token so stale callbacks cannot overwrite a later selection.

Repeated entity panes must also isolate DOM focus: restore focus through a ref
owned by the pane instead of `document.querySelector`, and do not let
programmatic descendant focus change router/global selection. Gate pane focus
transitions on explicit pointer, keyboard-navigation, or activation intent.

## Entity Scoping

When state belongs to an entity, key it by that entity id instead of using a
Expand Down Expand Up @@ -90,6 +95,11 @@ Components should usually read `currentTestSpecsAtom` rather than repeat

- Use write-only atoms or domain helper hooks for repeated mutations such as
append, clear, set-for-id, or remove-for-id.
- When supplying a custom synchronous storage to `atomWithStorage`, declare its
structural `getItem`/`setItem`/`removeItem` type locally. `SyncStorage` appears
in Jotai's internal declarations but is not exported by the public
`jotai/vanilla/utils` barrel, and importing it there makes `tsgo` select the
async overload and spread `Promise<Value>` errors through atom consumers.
- Keep high-frequency state, such as logs, separate from slower state so a log
append does not rerender consumers of unrelated preview metadata.
- Combine fields only when they form one domain concept and are updated
Expand Down
178 changes: 178 additions & 0 deletions src/atoms/chatWorkspaceAtoms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { createStore } from "jotai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
chatWorkspaceStorageKey,
chatWorkspaceByAppIdAtom,
createChatWorkspaceStorage,
getVisibleChatViewIds,
getVisibleWorkspaceChatIds,
hideChatFromWorkspaceAtom,
pruneChatWorkspaceAtom,
pruneChatWorkspaceWindowSessions,
showChatInWorkspaceAtom,
} from "./chatWorkspaceAtoms";
import {
configureChatTabWindowSession,
getActiveWindowSessionId,
} from "@/window_infrastructure/chat_tab_session_storage";
import {
PRIMARY_WINDOW_SESSION_ID,
type WindowSessionId,
} from "@/window_infrastructure/types";

function memoryStorage(values = new Map<string, string>()): Storage {
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
removeItem: (key) => values.delete(key),
clear: () => values.clear(),
key: (index) => Array.from(values.keys())[index] ?? null,
get length() {
return values.size;
},
};
}

describe("chat workspace atoms", () => {
const store = createStore();

beforeEach(() => {
configureChatTabWindowSession(PRIMARY_WINDOW_SESSION_ID, {
mayMigrateLegacySession: false,
});
store.set(chatWorkspaceByAppIdAtom, {});
});

afterEach(() => {
configureChatTabWindowSession(PRIMARY_WINDOW_SESSION_ID, {
mayMigrateLegacySession: false,
});
});

it("adds chats once and preserves their order per app", () => {
store.set(showChatInWorkspaceAtom, { appId: 1, chatId: 10 });
store.set(showChatInWorkspaceAtom, { appId: 1, chatId: 20 });
store.set(showChatInWorkspaceAtom, { appId: 1, chatId: 10 });
store.set(showChatInWorkspaceAtom, { appId: 2, chatId: 30 });

expect(store.get(chatWorkspaceByAppIdAtom)).toEqual({
1: { visibleChatIds: [10, 20] },
2: { visibleChatIds: [30] },
});
});

it("removes and prunes unavailable chats", () => {
store.set(chatWorkspaceByAppIdAtom, {
1: { visibleChatIds: [10, 20, 30] },
});

store.set(hideChatFromWorkspaceAtom, { appId: 1, chatId: 20 });
store.set(pruneChatWorkspaceAtom, {
appId: 1,
validChatIds: new Set([30]),
});

expect(store.get(chatWorkspaceByAppIdAtom)[1]?.visibleChatIds).toEqual([
30,
]);
});

it("shows only explicit workspace members while filtering stale ids", () => {
expect(
getVisibleWorkspaceChatIds([10, 20, 99, 10], new Set([10, 20, 30])),
).toEqual([10, 20]);
});

it("keeps individual chat views separate from workspace membership", () => {
const validChatIds = new Set([10, 20, 30]);

expect(
getVisibleChatViewIds({
workspaceChatIds: [10, 20],
focusedChatId: 30,
validChatIds,
isWorkspaceView: false,
}),
).toEqual([30]);
expect(
getVisibleChatViewIds({
workspaceChatIds: [10, 20],
focusedChatId: 30,
validChatIds,
isWorkspaceView: true,
}),
).toEqual([10, 20]);
});

it("renders the route chat while the chats query is still loading", () => {
expect(
getVisibleChatViewIds({
workspaceChatIds: [],
focusedChatId: 42,
validChatIds: new Set(),
isWorkspaceView: false,
}),
).toEqual([42]);
});

it("loads only validated workspace entries from versioned storage", () => {
const values = new Map<string, string>();
const workspaceKey = chatWorkspaceStorageKey(getActiveWindowSessionId());
values.set(
workspaceKey,
JSON.stringify({
version: 1,
workspaces: {
1: { visibleChatIds: [10, 10, -1, "bad", 20] },
2: null,
invalid: { visibleChatIds: [30] },
},
}),
);
const storage = createChatWorkspaceStorage(() => memoryStorage(values));

expect(storage.getItem("workspace", {})).toEqual({
1: { visibleChatIds: [10, 20] },
});

values.set(
workspaceKey,
JSON.stringify({ version: 2, workspaces: { 1: null } }),
);
expect(storage.getItem("workspace", {})).toEqual({});

storage.setItem("workspace", { 3: { visibleChatIds: [30] } });
expect(JSON.parse(values.get(workspaceKey) ?? "null")).toEqual({
version: 1,
workspaces: { 3: { visibleChatIds: [30] } },
});
});

it("isolates workspace membership by window session and prunes stale keys", () => {
const values = new Map<string, string>();
const storageBackend = memoryStorage(values);
const storage = createChatWorkspaceStorage(() => storageBackend);
const firstWindow =
"10000000-0000-4000-8000-000000000001" as WindowSessionId;
const secondWindow =
"10000000-0000-4000-8000-000000000002" as WindowSessionId;

configureChatTabWindowSession(firstWindow, {
mayMigrateLegacySession: false,
});
storage.setItem("workspace", { 1: { visibleChatIds: [10] } });

configureChatTabWindowSession(secondWindow, {
mayMigrateLegacySession: false,
});
expect(storage.getItem("workspace", {})).toEqual({});
storage.setItem("workspace", { 2: { visibleChatIds: [20] } });

expect(values.has(chatWorkspaceStorageKey(firstWindow))).toBe(true);
expect(values.has(chatWorkspaceStorageKey(secondWindow))).toBe(true);

pruneChatWorkspaceWindowSessions(storageBackend, [secondWindow]);
expect(values.has(chatWorkspaceStorageKey(firstWindow))).toBe(false);
expect(values.has(chatWorkspaceStorageKey(secondWindow))).toBe(true);
});
});
Loading
Loading