diff --git a/src/main/db.schema.ts b/src/main/db.schema.ts index 84e4b8327..72c3edfd3 100644 --- a/src/main/db.schema.ts +++ b/src/main/db.schema.ts @@ -26,6 +26,8 @@ export const threads = sqliteTable("threads", { projectId: text("project_id") .notNull() .references(() => projects.id, { onDelete: "cascade" }), + /** Workspace a Home thread was created in; NULL = visible in every workspace. */ + workspaceId: text("workspace_id"), title: text("title").notNull(), agentKind: text("agent_kind").notNull(), // provider kind /** Optional id of a user-registered ACP instance backing this thread. */ diff --git a/src/main/db/migrations.test.ts b/src/main/db/migrations.test.ts index a69969797..cfbd2f826 100644 --- a/src/main/db/migrations.test.ts +++ b/src/main/db/migrations.test.ts @@ -41,8 +41,9 @@ describe("database migration registry", () => { [34, "projects.icon"], [35, "threads.archived_at"], [36, "runtime item stream chunks"], + [37, "threads.workspace_id"], ]); - expect(LATEST_SCHEMA_VERSION).toBe(36); + expect(LATEST_SCHEMA_VERSION).toBe(37); expect(() => validateMigrationRegistry()).not.toThrow(); }); diff --git a/src/main/db/migrations.ts b/src/main/db/migrations.ts index 608599157..87e776c45 100644 --- a/src/main/db/migrations.ts +++ b/src/main/db/migrations.ts @@ -455,6 +455,15 @@ export const DATABASE_MIGRATIONS = [ normalizeRuntimeStreams(sqlite); }, }, + { + version: 37, + name: "threads.workspace_id", + // Workspace a Home thread was created in. Existing rows deliberately stay + // NULL: an untagged Home thread remains visible in every workspace (the + // same unfiled rule projects use), so pre-upgrade threads keep today's + // behavior instead of vanishing from sidebars. + migrate: (sqlite) => addColumnIfMissing(sqlite, "threads", "workspace_id", "TEXT"), + }, ] as const satisfies readonly DatabaseMigration[]; export const LATEST_SCHEMA_VERSION = DATABASE_MIGRATIONS[DATABASE_MIGRATIONS.length - 1]!.version; diff --git a/src/main/db/projectsThreads.test.ts b/src/main/db/projectsThreads.test.ts index 029ec2486..b269dd945 100644 --- a/src/main/db/projectsThreads.test.ts +++ b/src/main/db/projectsThreads.test.ts @@ -145,6 +145,39 @@ describe("projectsThreads (real sqlite round-trip)", () => { expect(dbGetState("schema_version")).toBe(String(LATEST_SCHEMA_VERSION)); }); + it("round-trips the thread workspace through the threads table", () => { + dbUpsertThread(testThread({ workspaceId: "ws-work" }), 0); + expect(dbGetThread("thread-1")?.workspaceId).toBe("ws-work"); + + // Conflict-update path: "Move to Workspace" must survive a full re-sync. + dbUpsertThread(testThread({ workspaceId: "ws-side" }), 0); + expect(dbGetThread("thread-1")?.workspaceId).toBe("ws-side"); + + // Un-filing ("All workspaces") clears the column rather than leaving the + // previous value behind. + dbUpsertThread(testThread(), 0); + expect(dbGetThread("thread-1")?.workspaceId).toBeUndefined(); + }); + + it("keeps pre-upgrade threads untagged after the v37 workspace migration", () => { + dbUpsertThread(testThread(), 0); + // Simulate a pre-v37 database: the column absent, the version rewound. + getSqlite().exec("ALTER TABLE threads DROP COLUMN workspace_id"); + dbSetState("schema_version", "36"); + + closeDatabase(); + initDatabase(join(dir, "state.sqlite")); + + expect(dbGetState("schema_version")).toBe(String(LATEST_SCHEMA_VERSION)); + const columns = getSqlite().prepare("PRAGMA table_info(threads)").all() as { + name: string; + }[]; + expect(columns.some((column) => column.name === "workspace_id")).toBe(true); + // Untagged = visible in every workspace, so upgraded threads keep today's + // behavior instead of vanishing from sidebars. + expect(dbGetThread("thread-1")?.workspaceId).toBeUndefined(); + }); + it("round-trips project MCP servers through the projects table", () => { dbUpsertProject( { diff --git a/src/main/db/projectsThreads.ts b/src/main/db/projectsThreads.ts index d0adfb5dc..8ed0e37ee 100644 --- a/src/main/db/projectsThreads.ts +++ b/src/main/db/projectsThreads.ts @@ -99,6 +99,7 @@ export function dbUpsertThread(thread: Thread, sortOrder: number): void { .values({ id: thread.id, projectId: thread.projectId, + workspaceId: thread.workspaceId ?? null, title: thread.title, agentKind: thread.agentKind, agentInstanceId: thread.agentInstanceId ?? null, @@ -131,6 +132,8 @@ export function dbUpsertThread(thread: Thread, sortOrder: number): void { .onConflictDoUpdate({ target: schema.threads.id, set: { + // Kept in the update set so "Move to Workspace" survives full syncs. + workspaceId: thread.workspaceId ?? null, title: thread.title, agentInstanceId: thread.agentInstanceId ?? null, config: JSON.stringify(thread.config), diff --git a/src/main/db/rowMappers.ts b/src/main/db/rowMappers.ts index 231852f7f..c036fe72b 100644 --- a/src/main/db/rowMappers.ts +++ b/src/main/db/rowMappers.ts @@ -72,6 +72,7 @@ export function rowToThread(row: typeof schema.threads.$inferSelect): Thread { return { id: row.id, projectId: row.projectId, + ...(row.workspaceId ? { workspaceId: row.workspaceId } : {}), title: row.title, agentKind: row.agentKind as Thread["agentKind"], ...(row.agentInstanceId ? { agentInstanceId: row.agentInstanceId } : {}), diff --git a/src/main/db/sync.test.ts b/src/main/db/sync.test.ts index 4bf636528..57776450d 100644 --- a/src/main/db/sync.test.ts +++ b/src/main/db/sync.test.ts @@ -107,4 +107,27 @@ describe.skipIf(!sqliteAvailable)("dbSyncAll thread ownership", () => { expect(dbGetThread("thread-remote")).toBeNull(); expect(dbGetThreadRuntimeItems("thread-remote")).toEqual([]); }); + + it("persists thread workspace tags through a full renderer sync", () => { + const tagged: Thread = { + ...remoteStartedThread(), + id: "thread-tagged", + workspaceId: "ws-work", + }; + const untagged: Thread = { ...remoteStartedThread(), id: "thread-untagged" }; + + // Insert path: a fresh row carries its tag through the first sync. + dbSyncAll([project], [tagged, untagged], JSON.stringify({ kind: "home" })); + expect(dbGetThread("thread-tagged")?.workspaceId).toBe("ws-work"); + expect(dbGetThread("thread-untagged")?.workspaceId).toBeUndefined(); + + // Conflict-update path: moving the thread files it under the new workspace… + dbSyncAll([project], [{ ...tagged, workspaceId: "ws-side" }, untagged], "{}"); + expect(dbGetThread("thread-tagged")?.workspaceId).toBe("ws-side"); + + // …and un-filing clears the column instead of leaving the old value. + const { workspaceId: _dropped, ...unfiled } = tagged; + dbSyncAll([project], [unfiled, untagged], "{}"); + expect(dbGetThread("thread-tagged")?.workspaceId).toBeUndefined(); + }); }); diff --git a/src/main/db/sync.ts b/src/main/db/sync.ts index 169659b84..0dc5f7509 100644 --- a/src/main/db/sync.ts +++ b/src/main/db/sync.ts @@ -151,19 +151,20 @@ function runProjectSync(stmt: SqliteStatement, project: Project, sortOrder: numb function prepareThreadSyncStatement(sqlite: InstanceType): SqliteStatement { return sqlite.prepare(` INSERT INTO threads ( - id, project_id, title, agent_kind, agent_instance_id, config, status, + id, project_id, workspace_id, title, agent_kind, agent_instance_id, config, status, attention, can_resume_with_config, session_ref, terminal_prompt, worktree_path, worktree_branch, pr_number, group_id, group_name, parent_thread_id, archived, archived_at, done, done_at, starred, presentation_mode, sort_order, created_at, updated_at, active_turn_started_at, last_turn_started_at, last_turn_ended_at ) VALUES ( - @id, @projectId, @title, @agentKind, @agentInstanceId, @config, @status, + @id, @projectId, @workspaceId, @title, @agentKind, @agentInstanceId, @config, @status, @attention, @canResumeWithConfig, @sessionRef, NULL, @worktreePath, @worktreeBranch, @prNumber, @groupId, @groupName, @parentThreadId, @archived, @archivedAt, @done, @doneAt, @starred, @presentationMode, @sortOrder, @createdAt, @updatedAt, @activeTurnStartedAt, @lastTurnStartedAt, @lastTurnEndedAt ) ON CONFLICT(id) DO UPDATE SET + workspace_id = excluded.workspace_id, title = excluded.title, agent_instance_id = excluded.agent_instance_id, config = excluded.config, @@ -196,6 +197,9 @@ function runThreadSync(stmt: SqliteStatement, thread: Thread, sortOrder: number) stmt.run({ id: thread.id, projectId: thread.projectId, + // Kept in the sync so "Move to Workspace" survives the renderer's periodic + // full-store persist, exactly like the single-row dbUpsertThread path. + workspaceId: thread.workspaceId ?? null, title: thread.title, agentKind: thread.agentKind, agentInstanceId: thread.agentInstanceId ?? null, diff --git a/src/renderer/actions/threadActions.test.ts b/src/renderer/actions/threadActions.test.ts index ecb3ad226..984357357 100644 --- a/src/renderer/actions/threadActions.test.ts +++ b/src/renderer/actions/threadActions.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { waitFor } from "@testing-library/react"; import type { Project, RemoteThreadCommand, Thread, Workspace } from "@/shared/contracts"; +import { HOME_PROJECT_ID } from "@/shared/homeScope"; import { useAppStore } from "@/renderer/state/appStore"; import { useDevTerminalStore } from "@/renderer/state/devTerminalStore"; import { usePanelStore } from "@/renderer/state/panelStore"; @@ -999,6 +1000,29 @@ describe("threadActions", () => { switchToAdjacentThread(only, "next"); expect(useAppStore.getState().view).toEqual({ kind: "home" }); }); + + it("skips Home threads filed under other workspaces but keeps untagged ones", async () => { + useSharedSettings.setState({ + workspaces: [ + { id: "w1", name: "Work", createdAt: "2026-01-01T00:00:00.000Z", icon: "briefcase" }, + { id: "w2", name: "Side", createdAt: "2026-01-01T00:00:00.000Z", icon: "rocket" }, + ] as Workspace[], + }); + useWorkspaceStore.setState({ activeWorkspaceId: "w1" }); + const threads = [ + makeThread({ id: "a", projectId: HOME_PROJECT_ID, workspaceId: "w1" }), + makeThread({ id: "hidden", projectId: HOME_PROJECT_ID, workspaceId: "w2" }), + makeThread({ id: "b", projectId: HOME_PROJECT_ID }), + ]; + useAppStore.setState((state) => ({ ...state, threads })); + + // "hidden" is invisible in this workspace's sidebar, so Next must land on + // the untagged (visible-everywhere) thread instead. + switchToAdjacentThread(threads[0]!, "next"); + await waitFor(() => + expect(useAppStore.getState().view).toEqual({ kind: "thread", panes: ["b"] }), + ); + }); }); }); diff --git a/src/renderer/actions/threadActions.ts b/src/renderer/actions/threadActions.ts index e3e0875b8..caf702230 100644 --- a/src/renderer/actions/threadActions.ts +++ b/src/renderer/actions/threadActions.ts @@ -2,6 +2,7 @@ import { startTransition } from "react"; import { toast } from "@heroui/react"; import { isProjectInWorkspace, + isThreadInWorkspace, type Project, type RemoteThreadCommand, type Thread, @@ -24,7 +25,11 @@ import { import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useSidebarUiStore } from "@/renderer/state/sidebarUiStore"; import { shouldConfirmThreadDelete } from "@/renderer/state/threadDeletePreference"; -import { getActiveWorkspaceId, getLastWorkspaceProjectId } from "@/renderer/state/workspaceStore"; +import { + getActiveWorkspaceId, + getKnownWorkspaceIds, + getLastWorkspaceProjectId, +} from "@/renderer/state/workspaceStore"; import { useWorktreeDeleteStore } from "@/renderer/state/worktreeDeleteStore"; import { buildSidebarProjectRows } from "@/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows"; import { resolveWorktreeBranch } from "@/renderer/utils/gitHelpers"; @@ -281,8 +286,15 @@ export function openThread( */ export function switchToAdjacentThread(current: Thread, direction: "next" | "previous"): void { const store = useAppStore.getState(); + const knownWorkspaceIds = getKnownWorkspaceIds(); + const activeWorkspaceId = getActiveWorkspaceId(); const projectThreads = store.threads.filter( - (thread) => thread.projectId === current.projectId && !thread.archived, + (thread) => + thread.projectId === current.projectId && + !thread.archived && + // Home threads filed under other workspaces are hidden from the sidebar, + // so the shortcuts must not wrap into them either. + isThreadInWorkspace(thread, activeWorkspaceId, knownWorkspaceIds), ); if (projectThreads.length < 2) return; diff --git a/src/renderer/actions/threadLaunchActions.test.ts b/src/renderer/actions/threadLaunchActions.test.ts index 46168e31a..9b58e00fa 100644 --- a/src/renderer/actions/threadLaunchActions.test.ts +++ b/src/renderer/actions/threadLaunchActions.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Project, Thread } from "@/shared/contracts"; +import { HOME_PROJECT_ID } from "@/shared/homeScope"; import type { RemoteThreadLaunchResult } from "@/renderer/state/remoteServers/types"; function deferred() { @@ -69,6 +70,7 @@ const mocks = vi.hoisted(() => { remoteState, remoteClient, bridge, + activeWorkspaceId: "ws-work" as string | null, createWorktree: vi.fn< ( @@ -127,6 +129,10 @@ vi.mock("@/renderer/state/sharedSettingsStore", () => ({ }, })); +vi.mock("@/renderer/state/workspaceStore", () => ({ + getActiveWorkspaceId: () => mocks.activeWorkspaceId, +})); + vi.mock("@/renderer/bridge", () => ({ readBridge: () => mocks.bridge, })); @@ -176,6 +182,7 @@ const remoteProject: Project = { describe("startThreadFromDraft host transport", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.activeWorkspaceId = "ws-work"; mocks.appState.view = { kind: "home" }; mocks.appState.projects = []; mocks.appState.threads = []; @@ -404,6 +411,58 @@ describe("startThreadFromDraft host transport", () => { ); }); + it("tags a Home thread with the active workspace at creation", async () => { + const homeProject: Project = { + id: HOME_PROJECT_ID, + name: "Home", + location: { kind: "windows", path: "C:\\Users\\me" }, + disabled: true, + createdAt: "2026-01-01T00:00:00.000Z", + }; + + await startThreadFromDraft(homeProject, { + agentKind: "claude", + config: { model: "sonnet" }, + prompt: "restart the service", + presentationMode: "gui", + }); + + expect(mocks.appState.createThread).toHaveBeenCalledWith( + expect.objectContaining({ projectId: HOME_PROJECT_ID, workspaceId: "ws-work" }), + ); + }); + + it("leaves a Home thread untagged when no workspace is active", async () => { + mocks.activeWorkspaceId = null; + const homeProject: Project = { + id: HOME_PROJECT_ID, + name: "Home", + location: { kind: "windows", path: "C:\\Users\\me" }, + disabled: true, + createdAt: "2026-01-01T00:00:00.000Z", + }; + + await startThreadFromDraft(homeProject, { + agentKind: "claude", + config: { model: "sonnet" }, + prompt: "restart the service", + presentationMode: "gui", + }); + + expect(mocks.appState.createThread.mock.calls[0]?.[0]).not.toHaveProperty("workspaceId"); + }); + + it("never tags a real project's thread with a workspace", async () => { + await startThreadFromDraft(localProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build it", + presentationMode: "gui", + }); + + expect(mocks.appState.createThread.mock.calls[0]?.[0]).not.toHaveProperty("workspaceId"); + }); + it("marks a local non-worktree thread failed when the bridge launch fails", async () => { mocks.bridge.startThread.mockRejectedValue(new Error("spawn failed")); diff --git a/src/renderer/actions/threadLaunchActions.ts b/src/renderer/actions/threadLaunchActions.ts index 9fdaee5e1..9328afdfd 100644 --- a/src/renderer/actions/threadLaunchActions.ts +++ b/src/renderer/actions/threadLaunchActions.ts @@ -32,6 +32,7 @@ import { isRemoteProjectUnreachable } from "@/renderer/state/remoteServers/reach import { useRemoteServersStore } from "@/renderer/state/remoteServersStore"; import type { RemoteThreadLaunchResult } from "@/renderer/state/remoteServers/types"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { getActiveWorkspaceId } from "@/renderer/state/workspaceStore"; import { generateTitleAsync } from "@/renderer/utils/titleGen"; import { buildProjectDraftConfig } from "@/renderer/views/MainView/parts/AppContent/draftConfig"; import { @@ -468,9 +469,13 @@ function createThreadRow(launch: ThreadLaunchRequest): Thread { ? applyHomeScopePermissions(launch.project.location, launch.config, agentStatus.capabilities) : launch.config; + // Home threads stay local to the workspace they were started in; threads in + // real projects scope through their project's workspaceId instead. + const homeWorkspaceId = isHomeProject(launch.project) ? getActiveWorkspaceId() : null; const thread = store.createThread({ ...(launch.threadId ? { threadId: launch.threadId } : {}), projectId: launch.project.id, + ...(homeWorkspaceId ? { workspaceId: homeWorkspaceId } : {}), agentKind: launch.agentKind, config, prompt: titlePrompt, diff --git a/src/renderer/components/workspace/workspaceMenuItems.tsx b/src/renderer/components/workspace/workspaceMenuItems.tsx new file mode 100644 index 000000000..907477828 --- /dev/null +++ b/src/renderer/components/workspace/workspaceMenuItems.tsx @@ -0,0 +1,40 @@ +import { Layers } from "lucide-react"; +import { useLingui } from "@lingui/react/macro"; +import type { ContextMenuEntry } from "@/renderer/components/common/ContextMenu"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { WORKSPACE_UNFILED_KEY, workspaceMenuKey } from "./workspaceMenuKeys"; +import { WorkspaceIcon } from "./WorkspaceIcon"; + +/** + * The "Move to Workspace" submenu shared by the project header menu and the + * Home thread menu: one entry per workspace (the current filing disabled) plus + * the unfiled "All workspaces" choice. Undefined while fewer than two + * workspaces exist — there is nothing to move between. + */ +export function useWorkspaceMenuItems( + currentWorkspaceId: string | undefined, +): ContextMenuEntry | undefined { + const { t } = useLingui(); + const workspaces = useSharedSettings((state) => state.workspaces); + if (workspaces.length < 2) return undefined; + return { + type: "submenu" as const, + id: "move-to-workspace", + label: t`Move to Workspace`, + icon: , + items: [ + ...workspaces.map((workspace) => ({ + id: workspaceMenuKey(workspace.id), + label: workspace.name, + icon: , + isDisabled: workspace.id === currentWorkspaceId, + })), + { + id: WORKSPACE_UNFILED_KEY, + label: t`All workspaces`, + icon: , + isDisabled: !currentWorkspaceId, + }, + ], + }; +} diff --git a/src/renderer/components/workspace/workspaceMenuKeys.ts b/src/renderer/components/workspace/workspaceMenuKeys.ts index a00e5664b..7f9639b79 100644 --- a/src/renderer/components/workspace/workspaceMenuKeys.ts +++ b/src/renderer/components/workspace/workspaceMenuKeys.ts @@ -24,3 +24,13 @@ export function parseWorkspaceMenuKey(key: string): WorkspaceMenuSelection | nul if (!key.startsWith(WORKSPACE_KEY_PREFIX)) return null; return { kind: "workspace", workspaceId: key.slice(WORKSPACE_KEY_PREFIX.length) }; } + +/** Route a fired menu key to `setWorkspace`; non-workspace keys are ignored. */ +export function applyWorkspaceMenuChoice( + key: string, + setWorkspace: (workspaceId: string | undefined) => void, +): void { + const choice = parseWorkspaceMenuKey(key); + if (choice?.kind === "unfiled") setWorkspace(undefined); + else if (choice?.kind === "workspace") setWorkspace(choice.workspaceId); +} diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index bb80a92ef..63ba6e481 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -1267,6 +1267,7 @@ msgstr "Alle Threads unter „Fertig“ werden archiviert." msgid "All threads in Done will be permanently deleted." msgstr "Alle Threads unter „Fertig“ werden dauerhaft gelöscht." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "Panel verschieben" msgid "Move to clean worktree" msgstr "In sauberen Arbeitsbaum verschieben" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "In Arbeitsbereich verschieben" diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index 734806baf..5286c9943 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -1267,6 +1267,7 @@ msgstr "All threads in Done will be archived." msgid "All threads in Done will be permanently deleted." msgstr "All threads in Done will be permanently deleted." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "Move panel" msgid "Move to clean worktree" msgstr "Move to clean worktree" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "Move to Workspace" diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index 363f5ec07..c3cf1f31d 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -1267,6 +1267,7 @@ msgstr "Todos los hilos de Listo se archivarán." msgid "All threads in Done will be permanently deleted." msgstr "Todos los hilos de Listo se eliminarán permanentemente." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "Mover panel" msgid "Move to clean worktree" msgstr "Mover a worktree limpio" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "Mover a espacio de trabajo" diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index bc196a630..265e9b9a0 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -1267,6 +1267,7 @@ msgstr "Tous les fils de la section Terminé seront archivés." msgid "All threads in Done will be permanently deleted." msgstr "Tous les fils de la section Terminé seront définitivement supprimés." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7295,6 +7296,7 @@ msgstr "Déplacer le panneau" msgid "Move to clean worktree" msgstr "Déplacer vers un worktree propre" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "Déplacer vers un espace de travail" diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index 648c04ca2..f5ce06af9 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -1266,6 +1266,7 @@ msgstr "「完了」のすべてのスレッドがアーカイブされます。 msgid "All threads in Done will be permanently deleted." msgstr "「完了」のすべてのスレッドが完全に削除されます。" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7294,6 +7295,7 @@ msgstr "パネルを移動" msgid "Move to clean worktree" msgstr "クリーンなワークツリーに移動" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "ワークスペースへ移動" diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 3e3e32ce9..f5791996e 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -1267,6 +1267,7 @@ msgstr "완료 섹션의 모든 스레드가 보관됩니다." msgid "All threads in Done will be permanently deleted." msgstr "완료 섹션의 모든 스레드가 영구적으로 삭제됩니다." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "패널 이동" msgid "Move to clean worktree" msgstr "클린 작업 트리로 이동" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "작업 영역으로 이동" diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index 993d1b19c..5bc32ba51 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -1267,6 +1267,7 @@ msgstr "Wszystkie wątki w sekcji Gotowe zostaną zarchiwizowane." msgid "All threads in Done will be permanently deleted." msgstr "Wszystkie wątki w sekcji Gotowe zostaną trwale usunięte." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "Przenieś panel" msgid "Move to clean worktree" msgstr "Przenieś do czystego drzewa roboczego" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "Przenieś do obszaru roboczego" diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 851246d0c..f012c5f8c 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -1267,6 +1267,7 @@ msgstr "Todas as threads em Concluído serão arquivadas." msgid "All threads in Done will be permanently deleted." msgstr "Todas as threads em Concluído serão excluídas permanentemente." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "Mover painel" msgid "Move to clean worktree" msgstr "Mover para árvore de trabalho limpa" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "Mover para espaço de trabalho" diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index f595fdf1d..81d1219f2 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -1267,6 +1267,7 @@ msgstr "Все потоки в разделе «Готово» будут арх msgid "All threads in Done will be permanently deleted." msgstr "Все потоки в разделе «Готово» будут удалены без возможности восстановления." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "Переместить панель" msgid "Move to clean worktree" msgstr "Переместить в чистый worktree" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "Переместить в рабочую область" diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 43a4b3517..55523b63b 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -1267,6 +1267,7 @@ msgstr "Bitti bölümündeki tüm iş parçacıkları arşivlenecek." msgid "All threads in Done will be permanently deleted." msgstr "Bitti bölümündeki tüm iş parçacıkları kalıcı olarak silinecek." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "Paneli taşı" msgid "Move to clean worktree" msgstr "Temiz çalışma ağacına taşı" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "Çalışma alanına taşı" diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index c06cdf5ca..8b04911d6 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -1267,6 +1267,7 @@ msgstr "Усі потоки в розділі «Завершено» буде з msgid "All threads in Done will be permanently deleted." msgstr "Усі потоки в розділі «Завершено» буде остаточно видалено." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "Перемістити панель" msgid "Move to clean worktree" msgstr "Перемістити в чистий worktree" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "Перемістити до робочої області" diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index 362b2b044..13db0e977 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -1267,6 +1267,7 @@ msgstr "Tất cả luồng trong mục Xong sẽ được lưu trữ." msgid "All threads in Done will be permanently deleted." msgstr "Tất cả luồng trong mục Xong sẽ bị xóa vĩnh viễn." +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7296,6 +7297,7 @@ msgstr "Di chuyển bảng điều khiển" msgid "Move to clean worktree" msgstr "Chuyển sang cây làm việc sạch" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "Chuyển tới không gian làm việc" diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index da8bc8103..b6891fe36 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -1267,6 +1267,7 @@ msgstr "“完成”中的所有线程都将归档。" msgid "All threads in Done will be permanently deleted." msgstr "“完成”中的所有线程都将永久删除。" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx #: src/renderer/views/SettingsOverlay/parts/WorkspacesSettings.tsx msgid "All workspaces" @@ -7295,6 +7296,7 @@ msgstr "移动面板" msgid "Move to clean worktree" msgstr "移至干净的工作树" +#: src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx #: src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx msgid "Move to Workspace" msgstr "移动到工作区" diff --git a/src/renderer/state/slices/threadSlice.ts b/src/renderer/state/slices/threadSlice.ts index 9e61af670..7c0d2c7f4 100644 --- a/src/renderer/state/slices/threadSlice.ts +++ b/src/renderer/state/slices/threadSlice.ts @@ -65,6 +65,8 @@ export interface ThreadSlice { createThread: (input: { threadId?: string; projectId: string; + /** Workspace tag for Home threads; see {@link Thread}'s `workspaceId`. */ + workspaceId?: string; remoteServerId?: string; remoteId?: string; agentKind: Thread["agentKind"]; @@ -87,6 +89,8 @@ export interface ThreadSlice { }) => Thread; deleteThread: (threadId: string) => void; renameThread: (threadId: string, title: string) => void; + /** Re-file a Home thread into a workspace; `undefined` = visible in every workspace. */ + setThreadWorkspace: (threadId: string, workspaceId: string | undefined) => void; setThreadWorktree: ( threadId: string, worktreePath: string, @@ -173,6 +177,7 @@ export const createThreadSlice: SliceCreator = (set) => ({ createThread: ({ threadId, projectId, + workspaceId, remoteServerId, remoteId, agentKind, @@ -194,6 +199,7 @@ export const createThreadSlice: SliceCreator = (set) => ({ const thread: Thread = { id: threadId ?? crypto.randomUUID(), projectId, + ...(workspaceId ? { workspaceId } : {}), ...(remoteServerId ? { remoteServerId } : {}), ...(remoteId ? { remoteId } : {}), title: title ?? makeThreadTitle(prompt), @@ -324,6 +330,22 @@ export const createThreadSlice: SliceCreator = (set) => ({ thread.id === threadId ? { ...thread, title } : thread, ), })), + setThreadWorkspace: (threadId, workspaceId) => + set((state) => { + const thread = state.threads.find((t) => t.id === threadId); + if (!thread || thread.workspaceId === workspaceId) return {}; + return { + threads: state.threads.map((t) => { + if (t.id !== threadId) return t; + const { workspaceId: _dropped, ...rest } = t; + return { + ...rest, + ...(workspaceId ? { workspaceId } : {}), + updatedAt: new Date().toISOString(), + }; + }), + }; + }), setThreadWorktree: (threadId, worktreePath, worktreeBranch, options) => set((state) => { const { [threadId]: _droppedProvisioning, ...provisioningWorktreeThreadIds } = diff --git a/src/renderer/state/workspaceSelectors.test.ts b/src/renderer/state/workspaceSelectors.test.ts new file mode 100644 index 000000000..5f2b15f8d --- /dev/null +++ b/src/renderer/state/workspaceSelectors.test.ts @@ -0,0 +1,57 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { Thread, Workspace } from "@/shared/contracts"; +import { HOME_PROJECT_ID } from "@/shared/homeScope"; +import { useSharedSettings } from "./sharedSettingsStore"; +import { useWorkspaceStore } from "./workspaceStore"; +import { useWorkspaceThreadFilter } from "./workspaceSelectors"; + +const workspaces: Workspace[] = [ + { id: "w1", name: "Work", createdAt: "2026-01-01T00:00:00.000Z", icon: "briefcase" }, + { id: "w2", name: "Side Hustle", createdAt: "2026-01-01T00:00:00.000Z", icon: "rocket" }, +]; + +function makeThread(input: Partial = {}): Thread { + return { + id: "thread-1", + projectId: "project-1", + title: "Thread", + agentKind: "claude", + config: { model: "sonnet" }, + status: "idle", + attention: "none", + canResumeWithConfig: false, + archived: false, + done: false, + starred: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...input, + }; +} + +function threadFilter(): (thread: Thread) => boolean { + return renderHook(() => useWorkspaceThreadFilter()).result.current; +} + +describe("useWorkspaceThreadFilter", () => { + beforeEach(() => { + localStorage.clear(); + useSharedSettings.setState({ workspaces }); + useWorkspaceStore.setState({ activeWorkspaceId: "w1" }); + }); + + it("passes real-project threads regardless of their tag", () => { + const isVisible = threadFilter(); + expect(isVisible(makeThread())).toBe(true); + expect(isVisible(makeThread({ workspaceId: "w2" }))).toBe(true); + }); + + it("scopes Home threads to the active workspace, keeping untagged and dangling ones", () => { + const isVisible = threadFilter(); + expect(isVisible(makeThread({ projectId: HOME_PROJECT_ID, workspaceId: "w1" }))).toBe(true); + expect(isVisible(makeThread({ projectId: HOME_PROJECT_ID, workspaceId: "w2" }))).toBe(false); + expect(isVisible(makeThread({ projectId: HOME_PROJECT_ID }))).toBe(true); + expect(isVisible(makeThread({ projectId: HOME_PROJECT_ID, workspaceId: "w-gone" }))).toBe(true); + }); +}); diff --git a/src/renderer/state/workspaceSelectors.ts b/src/renderer/state/workspaceSelectors.ts index 0213ab613..b77be4f2d 100644 --- a/src/renderer/state/workspaceSelectors.ts +++ b/src/renderer/state/workspaceSelectors.ts @@ -1,5 +1,11 @@ import { useShallow } from "zustand/react/shallow"; -import { isProjectInWorkspace, type Project, type Workspace } from "@/shared/contracts"; +import { + isProjectInWorkspace, + isThreadInWorkspace, + type Project, + type Thread, + type Workspace, +} from "@/shared/contracts"; import { isHomeProjectId } from "@/shared/homeScope"; import { useAppStore } from "./appStore"; import { useSharedSettings } from "./sharedSettingsStore"; @@ -32,6 +38,17 @@ export function useWorkspaceProjectFilter(): (project: Project) => boolean { return (project) => isProjectInWorkspace(project, activeWorkspaceId, knownWorkspaceIds); } +/** + * Reactive predicate for "is this thread in the active workspace". Only Home + * threads can fail it — threads in real projects scope through their project + * (see `isThreadInWorkspace` in `@/shared/contracts/workspace`). + */ +export function useWorkspaceThreadFilter(): (thread: Thread) => boolean { + const knownWorkspaceIds = workspaceIdSet(useSharedSettings((state) => state.workspaces)); + const activeWorkspaceId = useActiveWorkspaceId(); + return (thread) => isThreadInWorkspace(thread, activeWorkspaceId, knownWorkspaceIds); +} + /** Ids of projects the active workspace shows, in store order. Excludes Home. */ export function useWorkspaceProjectIds(): string[] { const isVisible = useWorkspaceProjectFilter(); diff --git a/src/renderer/state/workspaceStore.ts b/src/renderer/state/workspaceStore.ts index 040fa32d4..c4a6fb4c2 100644 --- a/src/renderer/state/workspaceStore.ts +++ b/src/renderer/state/workspaceStore.ts @@ -99,6 +99,10 @@ export function getActiveWorkspaceId(): string | null { ); } +export function getKnownWorkspaceIds(): ReadonlySet { + return new Set((useSharedSettings.getState().workspaces ?? []).map((workspace) => workspace.id)); +} + /** * Seed the default workspaces on first run and file every pre-existing project * into the first one, so an install that predates workspaces opens showing diff --git a/src/renderer/views/HomeView.test.tsx b/src/renderer/views/HomeView.test.tsx index 58d7cf33b..b35954b26 100644 --- a/src/renderer/views/HomeView.test.tsx +++ b/src/renderer/views/HomeView.test.tsx @@ -6,12 +6,15 @@ import { useAppStore } from "@/renderer/state/appStore"; import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { useRemoteServersStore } from "@/renderer/state/remoteServersStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { useWorkspaceStore } from "@/renderer/state/workspaceStore"; +import { HOME_PROJECT_ID, HOME_PROJECT_NAME } from "@/shared/homeScope"; import { HomeView } from "./HomeView"; describe("HomeView", () => { beforeEach(() => { localStorage.clear(); - useSharedSettings.setState({ homeScopeEnabled: true }); + useSharedSettings.setState({ homeScopeEnabled: true, workspaces: [] } as never); + useWorkspaceStore.setState({ activeWorkspaceId: null }); useAppStore.setState((state) => ({ ...state, projects: [makeProject()], @@ -95,6 +98,46 @@ describe("HomeView", () => { expect(screen.getByText("Second project thread")).toBeInTheDocument(); }); + it("hides Home recents filed under another workspace but keeps untagged ones", () => { + useSharedSettings.setState({ + workspaces: [ + { id: "w1", name: "Work", createdAt: "2026-01-01T00:00:00.000Z", icon: "briefcase" }, + { id: "w2", name: "Side Hustle", createdAt: "2026-01-01T00:00:00.000Z", icon: "rocket" }, + ], + } as never); + useWorkspaceStore.setState({ activeWorkspaceId: "w1" }); + useAppStore.setState({ + projects: [ + { + ...makeProject({ id: HOME_PROJECT_ID, name: HOME_PROJECT_NAME }), + disabled: true, + }, + makeProject(), + ], + threads: [ + makeThread({ + id: "h-mine", + title: "My Home thread", + projectId: HOME_PROJECT_ID, + workspaceId: "w1", + }), + makeThread({ + id: "h-other", + title: "Other workspace Home thread", + projectId: HOME_PROJECT_ID, + workspaceId: "w2", + }), + makeThread({ id: "h-legacy", title: "Legacy Home thread", projectId: HOME_PROJECT_ID }), + ], + }); + + render(); + + expect(screen.getByText("My Home thread")).toBeInTheDocument(); + expect(screen.getByText("Legacy Home thread")).toBeInTheDocument(); + expect(screen.queryByText("Other workspace Home thread")).not.toBeInTheDocument(); + }); + it("opens a draft from the workspace row's new-thread button", () => { render(); diff --git a/src/renderer/views/HomeView.tsx b/src/renderer/views/HomeView.tsx index 763767f28..6202d6760 100644 --- a/src/renderer/views/HomeView.tsx +++ b/src/renderer/views/HomeView.tsx @@ -6,6 +6,7 @@ import type { Project } from "@/shared/contracts"; import { isHomeProject, isHomeProjectId } from "@/shared/homeScope"; import { useAppStore } from "@/renderer/state/appStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { useWorkspaceThreadFilter } from "@/renderer/state/workspaceSelectors"; import { openThread } from "@/renderer/actions/threadActions"; import { ThreadProviderIcon } from "@/renderer/components/providers/ThreadProviderIcon"; import { RelativeTime } from "@/renderer/components/common/RelativeTime"; @@ -36,6 +37,7 @@ export function HomeView() { ? filterProjectId : null; + const isThreadInActiveWorkspace = useWorkspaceThreadFilter(); const recentThreads = useAppStore( useShallow((state) => { const sorted = state.threads @@ -44,6 +46,7 @@ export function HomeView() { !thread.done && !thread.archived && (homeScopeEnabled || !isHomeProjectId(thread.projectId)) && + isThreadInActiveWorkspace(thread) && (activeFilter === null || thread.projectId === activeFilter), ) .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)); diff --git a/src/renderer/views/MainView/parts/Sidebar/Sidebar.tsx b/src/renderer/views/MainView/parts/Sidebar/Sidebar.tsx index 07824e7c9..9cd3ea84e 100644 --- a/src/renderer/views/MainView/parts/Sidebar/Sidebar.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/Sidebar.tsx @@ -44,6 +44,7 @@ import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useProjectIdsHiddenByWorkspace, useWorkspaceProjectIds, + useWorkspaceThreadFilter, } from "@/renderer/state/workspaceSelectors"; import { SidebarFlatThreadList } from "./parts/SidebarFlatThreadList"; import { SidebarFooterNav } from "./parts/SidebarFooterNav"; @@ -134,6 +135,7 @@ function CollapsedThreadRailButton(props: { thread: Thread; projectName?: string function CollapsedThreadRail() { const homeScopeEnabled = useSharedSettings((s) => s.homeScopeEnabled); const showProjectName = usePanelStore((s) => s.threadListLayout === "flat"); + const isThreadInActiveWorkspace = useWorkspaceThreadFilter(); const projects = useAppStore((s) => s.projects); const projectsById = new Map(projects.map((project) => [project.id, project])); const activeThreads = useAppStore( @@ -143,7 +145,8 @@ function CollapsedThreadRail() { thread.status !== "inactive" && !thread.done && !thread.archived && - (homeScopeEnabled || !isHomeProjectId(thread.projectId)), + (homeScopeEnabled || !isHomeProjectId(thread.projectId)) && + isThreadInActiveWorkspace(thread), ), ), ); diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarFlatThreadList.test.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarFlatThreadList.test.tsx index 8fe4e6928..aa5e58053 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarFlatThreadList.test.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarFlatThreadList.test.tsx @@ -63,7 +63,12 @@ vi.mock("./SidebarProjectFilter", () => ({ ), })); -function makeThread(id: string, projectId: string, updatedAt: string): Thread { +function makeThread( + id: string, + projectId: string, + updatedAt: string, + overrides: Partial = {}, +): Thread { return { id, projectId, @@ -75,6 +80,7 @@ function makeThread(id: string, projectId: string, updatedAt: string): Thread { createdAt: updatedAt, updatedAt, agentKind: "claude", + ...overrides, } as unknown as Thread; } @@ -259,6 +265,48 @@ describe("SidebarFlatThreadList", () => { expect(localRow).not.toHaveTextContent("WSL"); }); + it("scopes Home threads to the workspace they were filed under", () => { + useSharedSettings.setState({ + workspaces: [ + { id: "w1", name: "Side Hustle" }, + { id: "w2", name: "Work" }, + ], + } as never); + useAppStore.setState({ + projects: [homeProject, localProject], + threads: [ + makeThread("h-mine", HOME_PROJECT_ID, "2026-08-04T10:00:00.000Z", { workspaceId: "w1" }), + makeThread("h-other", HOME_PROJECT_ID, "2026-08-03T10:00:00.000Z", { workspaceId: "w2" }), + // Legacy/headless Home threads carry no tag and stay visible everywhere. + makeThread("h-legacy", HOME_PROJECT_ID, "2026-08-02T10:00:00.000Z"), + makeThread("p1", "local-1", "2026-08-01T10:00:00.000Z"), + ], + }); + + render(); + + expect(screen.getByText(/thread:h-mine in Home/)).toBeInTheDocument(); + expect(screen.getByText(/thread:h-legacy in Home/)).toBeInTheDocument(); + expect(screen.queryByText(/thread:h-other/)).not.toBeInTheDocument(); + expect(screen.getByText(/thread:p1 in Poracode/)).toBeInTheDocument(); + }); + + it("keeps a Home thread with a dangling workspace tag visible", () => { + useAppStore.setState({ + projects: [homeProject, localProject], + threads: [ + makeThread("h-dangling", HOME_PROJECT_ID, "2026-08-02T10:00:00.000Z", { + workspaceId: "w-deleted", + }), + makeThread("p1", "local-1", "2026-08-01T10:00:00.000Z"), + ], + }); + + render(); + + expect(screen.getByText(/thread:h-dangling in Home/)).toBeInTheDocument(); + }); + it("hides Home threads when home scope is disabled", () => { useSharedSettings.setState({ homeScopeEnabled: false } as never); useAppStore.setState({ diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarFlatThreadList.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarFlatThreadList.tsx index f01952dbd..26959fa55 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarFlatThreadList.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarFlatThreadList.tsx @@ -21,7 +21,10 @@ import { useAppStore } from "@/renderer/state/appStore"; import { useExperimentCandidateOrder } from "@/renderer/state/experimentStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useSidebarUiStore, useThreadListLimit } from "@/renderer/state/sidebarUiStore"; -import { useWorkspaceProjectIds } from "@/renderer/state/workspaceSelectors"; +import { + useWorkspaceProjectIds, + useWorkspaceThreadFilter, +} from "@/renderer/state/workspaceSelectors"; import { sidebarBodyScrollClass } from "@/renderer/components/layout/sidebarChrome"; import { NewThreadButton } from "./NewThreadButton"; import { SidebarProjectFilter } from "./SidebarProjectFilter"; @@ -116,8 +119,12 @@ export function SidebarFlatThreadList(props: { sortMode: ThreadSortMode }) { : new Set(filteredVisibleIds); const allThreads = useAppStore((s) => s.threads); + // Home stays in every workspace, but its threads are workspace-scoped + // individually (isThreadInWorkspace) — a no-op for real projects' threads. + const isThreadInActiveWorkspace = useWorkspaceThreadFilter(); const visibleThreads = allThreads.filter( - (thread) => !thread.archived && projectsById.has(thread.projectId), + (thread) => + !thread.archived && projectsById.has(thread.projectId) && isThreadInActiveWorkspace(thread), ); const threadCounts = new Map(); for (const thread of visibleThreads) { diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectThreadList.test.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectThreadList.test.tsx new file mode 100644 index 000000000..617ebf5df --- /dev/null +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectThreadList.test.tsx @@ -0,0 +1,124 @@ +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; +import type { Project, Thread } from "@/shared/contracts"; +import { HOME_PROJECT_ID, HOME_PROJECT_NAME } from "@/shared/homeScope"; +import { useAppStore } from "@/renderer/state/appStore"; +import { useRemoteServersStore } from "@/renderer/state/remoteServersStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { useWorkspaceStore } from "@/renderer/state/workspaceStore"; +import { SidebarProjectThreadList } from "./SidebarProjectThreadList"; + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => ({}), +})); + +vi.mock("@/renderer/dnd", () => ({ + useDragSource: () => null, +})); + +vi.mock("@/renderer/actions/threadActions", () => ({ + openNewThread: vi.fn<() => void>(), + openNewThreadSideBySide: vi.fn<() => void>(), +})); + +vi.mock("./NewThreadButton", () => ({ + NewThreadButton: (props: { projectId: string }) => ( + + ), +})); + +vi.mock("./SidebarThreadRow", () => ({ + SeeMoreThreadsButton: () => , + SidebarThreadRow: (props: { row: { key: string }; project: { name: string } }) => ( +
+ {props.row.key} in {props.project.name} +
+ ), +})); + +function makeThread( + id: string, + projectId: string, + updatedAt: string, + overrides: Partial = {}, +): Thread { + return { + id, + projectId, + title: `Thread ${id}`, + status: "inactive", + done: false, + starred: false, + archived: false, + createdAt: updatedAt, + updatedAt, + agentKind: "claude", + ...overrides, + } as unknown as Thread; +} + +const homeProject: Project = { + id: HOME_PROJECT_ID, + name: HOME_PROJECT_NAME, + location: { kind: "windows", path: "C:\\Users\\me" }, + createdAt: "2026-07-01T00:00:00.000Z", + disabled: true, +} as Project; + +const localProject: Project = { + id: "local-1", + name: "Poracode", + location: { kind: "windows", path: "C:\\repo" }, + createdAt: "2026-07-01T00:00:00.000Z", + workspaceId: "w1", +} as Project; + +describe("SidebarProjectThreadList", () => { + beforeEach(() => { + useRemoteServersStore.setState({ servers: [], runtime: {} }); + useSharedSettings.setState({ + homeScopeEnabled: true, + workspaces: [ + { id: "w1", name: "Work" }, + { id: "w2", name: "Side Hustle" }, + ], + } as never); + useWorkspaceStore.setState({ activeWorkspaceId: "w1" }); + useAppStore.setState({ projects: [homeProject, localProject], threads: [] }); + }); + + it("hides Home threads filed under other workspaces but keeps untagged and dangling ones", () => { + useAppStore.setState({ + threads: [ + makeThread("h-mine", HOME_PROJECT_ID, "2026-08-04T10:00:00.000Z", { workspaceId: "w1" }), + makeThread("h-other", HOME_PROJECT_ID, "2026-08-03T10:00:00.000Z", { workspaceId: "w2" }), + makeThread("h-legacy", HOME_PROJECT_ID, "2026-08-02T10:00:00.000Z"), + makeThread("h-dangling", HOME_PROJECT_ID, "2026-08-01T10:00:00.000Z", { + workspaceId: "w-deleted", + }), + ], + }); + + render(); + + expect(screen.getByText(/thread:h-mine/)).toBeInTheDocument(); + expect(screen.getByText(/thread:h-legacy/)).toBeInTheDocument(); + expect(screen.getByText(/thread:h-dangling/)).toBeInTheDocument(); + expect(screen.queryByText(/thread:h-other/)).not.toBeInTheDocument(); + }); + + it("never filters a real project's threads by workspace", () => { + useAppStore.setState({ + threads: [ + makeThread("p1", "local-1", "2026-08-04T10:00:00.000Z"), + makeThread("p2", "local-1", "2026-08-03T10:00:00.000Z"), + ], + }); + + render(); + + expect(screen.getByText(/thread:p1 in Poracode/)).toBeInTheDocument(); + expect(screen.getByText(/thread:p2 in Poracode/)).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectThreadList.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectThreadList.tsx index ab6f021d9..4f080b020 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectThreadList.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectThreadList.tsx @@ -9,6 +9,7 @@ import { import { useDragSource } from "@/renderer/dnd"; import { openNewThread, openNewThreadSideBySide } from "@/renderer/actions/threadActions"; import { useSidebarUiStore, useThreadListLimit } from "@/renderer/state/sidebarUiStore"; +import { useWorkspaceThreadFilter } from "@/renderer/state/workspaceSelectors"; import { useExperimentCandidateOrder } from "@/renderer/state/experimentStore"; import { NewThreadButton } from "./NewThreadButton"; import { buildSidebarProjectRows } from "./sidebarProjectRows"; @@ -17,7 +18,9 @@ import { SeeMoreThreadsButton, SidebarThreadRow } from "./SidebarThreadRow"; export function SidebarProjectThreadList(props: { project: Project; sortMode: ThreadSortMode }) { const { project, sortMode } = props; - const projectThreads = useProjectThreads(project.id); + const isThreadVisible = useWorkspaceThreadFilter(); + // No-op for real projects; hides Home threads filed under other workspaces. + const projectThreads = useProjectThreads(project.id).filter(isThreadVisible); const experimentCandidateOrder = useExperimentCandidateOrder(project.id); const collapsedWorktrees = useSidebarUiStore((s) => s.collapsedWorktrees); const editingThreadId = useSidebarUiStore((s) => s.editingThreadId); diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.test.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.test.tsx index 2be696e8c..4b054a960 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.test.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.test.tsx @@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; import type { Project, Thread } from "@/shared/contracts"; import { HOME_PROJECT_ID } from "@/shared/homeScope"; +import { useAppStore } from "@/renderer/state/appStore"; import { resetDevTerminalStore, useDevTerminalStore } from "@/renderer/state/devTerminalStore"; import { usePanelStore } from "@/renderer/state/panelStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { ThreadContextMenu } from "./ThreadContextMenu"; vi.mock("@/renderer/bridge", () => ({ @@ -60,6 +62,7 @@ describe("ThreadContextMenu project actions", () => { beforeEach(() => { resetDevTerminalStore(); usePanelStore.setState({ githubActionsContext: null }); + useSharedSettings.setState({ workspaces: [] } as never); }); it("offers project Git and Run submenus on flat main-branch rows", async () => { @@ -113,6 +116,54 @@ describe("ThreadContextMenu project actions", () => { expect(screen.queryByRole("menuitem", { name: "Run" })).not.toBeInTheDocument(); }); + it("files a Home thread under a picked workspace and un-files it again", async () => { + useSharedSettings.setState({ + workspaces: [ + { id: "w1", name: "Work", createdAt: "2026-01-01T00:00:00.000Z", icon: "briefcase" }, + { id: "w2", name: "Side Hustle", createdAt: "2026-01-01T00:00:00.000Z", icon: "rocket" }, + ], + } as never); + const homeThread = thread({ projectId: HOME_PROJECT_ID, workspaceId: "w1" }); + useAppStore.setState({ threads: [homeThread] }); + + await renderMenu(homeThread, homeProject); + + fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Move to Workspace" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Side Hustle" })); + expect(useAppStore.getState().threads[0]?.workspaceId).toBe("w2"); + + fireEvent.contextMenu(screen.getByRole("button", { name: "row" })); + await screen.findByRole("menu"); + fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Move to Workspace" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "All workspaces" })); + expect(useAppStore.getState().threads[0]?.workspaceId).toBeUndefined(); + }); + + it("offers Move to Workspace only for Home threads with several workspaces", async () => { + useSharedSettings.setState({ + workspaces: [ + { id: "w1", name: "Work", createdAt: "2026-01-01T00:00:00.000Z", icon: "briefcase" }, + { id: "w2", name: "Side Hustle", createdAt: "2026-01-01T00:00:00.000Z", icon: "rocket" }, + ], + } as never); + + await renderMenu(thread(), project); + + expect(screen.queryByRole("menuitem", { name: "Move to Workspace" })).not.toBeInTheDocument(); + }); + + it("hides Move to Workspace when only one workspace exists", async () => { + useSharedSettings.setState({ + workspaces: [ + { id: "w1", name: "Work", createdAt: "2026-01-01T00:00:00.000Z", icon: "briefcase" }, + ], + } as never); + + await renderMenu(thread({ projectId: HOME_PROJECT_ID }), homeProject); + + expect(screen.queryByRole("menuitem", { name: "Move to Workspace" })).not.toBeInTheDocument(); + }); + it("shows a running indicator for the action-owned terminal", async () => { const terminal = useDevTerminalStore.getState(); const tab = terminal.addTab(project.id, "Build", undefined, "build"); diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx index 54a05b4ff..f48da5b77 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/ThreadContextMenu.tsx @@ -23,6 +23,8 @@ import type { Project, Thread } from "@/shared/contracts"; import { isHomeProject } from "@/shared/homeScope"; import { useAppStore } from "@/renderer/state/appStore"; import { useExperimentStore } from "@/renderer/state/experimentStore"; +import { applyWorkspaceMenuChoice } from "@/renderer/components/workspace/workspaceMenuKeys"; +import { useWorkspaceMenuItems } from "@/renderer/components/workspace/workspaceMenuItems"; import { useGitStore } from "@/renderer/state/gitStore"; import { ContextMenu, type ContextMenuItem } from "@/renderer/components/common/ContextMenu"; import { readBridge } from "@/renderer/bridge"; @@ -97,6 +99,15 @@ export function ThreadContextMenu(props: { // Home has no project menu even in the grouped layout (its sidebar section // is a plain header), so flat Home threads don't get project actions either. const showProjectActions = props.showProjectActions === true && !isHomeProject(project); + const workspaceMenuItem = useWorkspaceMenuItems(thread.workspaceId); + // Home threads are workspace-scoped individually (real-project threads follow + // their project), so only they get a per-thread move; remote mirrors don't — + // the host owns their metadata. + const showMoveToWorkspace = + isHomeProject(project) && + workspaceMenuItem !== undefined && + !thread.remoteServerId && + !isExperimentCandidate; const runningActionIds = useRunningProjectActionIds(project.id, thread.worktreePath); const runActionItems: ContextMenuItem[] = []; for (const action of project.scripts?.actions ?? []) { @@ -240,6 +251,7 @@ export function ThreadContextMenu(props: { label: thread.starred ? t`Unpin` : t`Pin to top`, icon: , }, + ...(showMoveToWorkspace && workspaceMenuItem ? [workspaceMenuItem] : []), ...(!isExperimentCandidate ? [ { @@ -388,6 +400,9 @@ export function ThreadContextMenu(props: { if (key.startsWith("stop-action:")) { stopProjectAction(project.id, key.slice("stop-action:".length), thread.worktreePath); } + applyWorkspaceMenuChoice(key, (workspaceId) => + useAppStore.getState().setThreadWorkspace(thread.id, workspaceId), + ); }} > {props.children} diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx index 86340d21f..a9916f482 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/useProjectMenu.tsx @@ -2,7 +2,6 @@ import { EyeOff, FileDiff, GitFork, - Layers, Loader2, Play, Power, @@ -25,14 +24,9 @@ import { showTerminalPanel, stopProjectAction, } from "@/renderer/actions/terminalActions"; -import { - WORKSPACE_UNFILED_KEY, - parseWorkspaceMenuKey, - workspaceMenuKey, -} from "@/renderer/components/workspace/workspaceMenuKeys"; -import { WorkspaceIcon } from "@/renderer/components/workspace/WorkspaceIcon"; +import { applyWorkspaceMenuChoice } from "@/renderer/components/workspace/workspaceMenuKeys"; +import { useWorkspaceMenuItems } from "@/renderer/components/workspace/workspaceMenuItems"; import { useAppStore } from "@/renderer/state/appStore"; -import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useRemoteServersStore } from "@/renderer/state/remoteServersStore"; import { resolveActionIcon } from "@/renderer/utils/actionIcons"; import { useRunningProjectActionIds } from "@/renderer/hooks/uiSelectors"; @@ -50,7 +44,7 @@ export function useProjectMenu( ): { items: ContextMenuEntry[]; onAction: (key: string) => void } { const { t } = useLingui(); const { isUnreachable } = options; - const workspaces = useSharedSettings((s) => s.workspaces); + const workspaceMenuItem = useWorkspaceMenuItems(project.workspaceId); const setRemoteProjectSynced = useRemoteServersStore((state) => state.setRemoteProjectSynced); const isDisabled = !!project.disabled; const isRemote = project.remoteServerId !== undefined && project.remoteId !== undefined; @@ -133,30 +127,7 @@ export function useProjectMenu( ] : []), ]), - ...(workspaces.length > 1 - ? [ - { - type: "submenu" as const, - id: "move-to-workspace", - label: t`Move to Workspace`, - icon: , - items: [ - ...workspaces.map((workspace) => ({ - id: workspaceMenuKey(workspace.id), - label: workspace.name, - icon: , - isDisabled: workspace.id === project.workspaceId, - })), - { - id: WORKSPACE_UNFILED_KEY, - label: t`All workspaces`, - icon: , - isDisabled: !project.workspaceId, - }, - ], - }, - ] - : []), + ...(workspaceMenuItem ? [workspaceMenuItem] : []), { id: "toggle-disabled", label: isDisabled ? t`Enable Project` : t`Disable Project`, @@ -202,12 +173,9 @@ export function useProjectMenu( if (key.startsWith("stop-action:")) { stopProjectAction(project.id, key.slice("stop-action:".length)); } - const workspaceChoice = parseWorkspaceMenuKey(key); - if (workspaceChoice?.kind === "unfiled") { - useAppStore.getState().setProjectWorkspace(project.id, undefined); - } else if (workspaceChoice?.kind === "workspace") { - useAppStore.getState().setProjectWorkspace(project.id, workspaceChoice.workspaceId); - } + applyWorkspaceMenuChoice(key, (workspaceId) => + useAppStore.getState().setProjectWorkspace(project.id, workspaceId), + ); }; return { items, onAction }; diff --git a/src/shared/contracts/thread.ts b/src/shared/contracts/thread.ts index 2632b489a..e6bbccc9f 100644 --- a/src/shared/contracts/thread.ts +++ b/src/shared/contracts/thread.ts @@ -27,6 +27,15 @@ export const threadSchema = z.object({ remoteServerId: z.string().min(1).optional(), remoteId: z.string().min(1).optional(), projectId: z.string().min(1), + /** + * Workspace the thread was created in. Only meaningful while `projectId` is + * the Home project — threads in real projects scope through + * `project.workspaceId` instead, so a stale tag here is inert. Absent + * (legacy, headless, scheduled, remote-created) or dangling ⇒ the thread + * stays visible in every workspace, mirroring `isProjectInWorkspace`'s + * unfiled rule. + */ + workspaceId: z.string().min(1).optional(), title: z.string().min(1), agentKind: agentKindSchema, /** Optional reference to a user-registered ACP instance (Phase 7). */ diff --git a/src/shared/contracts/workspace.test.ts b/src/shared/contracts/workspace.test.ts index 06e52da41..3d870cbc6 100644 --- a/src/shared/contracts/workspace.test.ts +++ b/src/shared/contracts/workspace.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "vitest"; +import { HOME_PROJECT_ID } from "../homeScope"; import { isProjectInWorkspace, + isThreadInWorkspace, nextWorkspaceIconId, workspaceListSchema, workspaceSchema, @@ -75,3 +77,45 @@ describe("isProjectInWorkspace", () => { expect(isProjectInWorkspace({ workspaceId: "ws-work" }, null, KNOWN)).toBe(false); }); }); + +describe("isThreadInWorkspace", () => { + test("threads in real projects always pass — the project rule governs them", () => { + expect( + isThreadInWorkspace({ projectId: "proj-1", workspaceId: "ws-side" }, "ws-work", KNOWN), + ).toBe(true); + expect(isThreadInWorkspace({ projectId: "proj-1" }, "ws-work", KNOWN)).toBe(true); + }); + + test("a Home thread shows only in the workspace it is filed under", () => { + expect( + isThreadInWorkspace({ projectId: HOME_PROJECT_ID, workspaceId: "ws-work" }, "ws-work", KNOWN), + ).toBe(true); + expect( + isThreadInWorkspace({ projectId: HOME_PROJECT_ID, workspaceId: "ws-side" }, "ws-work", KNOWN), + ).toBe(false); + }); + + test("an untagged Home thread stays visible in every workspace", () => { + expect(isThreadInWorkspace({ projectId: HOME_PROJECT_ID }, "ws-work", KNOWN)).toBe(true); + expect( + isThreadInWorkspace({ projectId: HOME_PROJECT_ID, workspaceId: undefined }, "ws-side", KNOWN), + ).toBe(true); + }); + + test("a dangling workspace tag stays visible rather than hiding the thread", () => { + expect( + isThreadInWorkspace( + { projectId: HOME_PROJECT_ID, workspaceId: "ws-deleted" }, + "ws-work", + KNOWN, + ), + ).toBe(true); + }); + + test("no active workspace hides filed Home threads but keeps unfiled ones", () => { + expect(isThreadInWorkspace({ projectId: HOME_PROJECT_ID }, null, KNOWN)).toBe(true); + expect( + isThreadInWorkspace({ projectId: HOME_PROJECT_ID, workspaceId: "ws-work" }, null, KNOWN), + ).toBe(false); + }); +}); diff --git a/src/shared/contracts/workspace.ts b/src/shared/contracts/workspace.ts index 2a2026618..df9c6e170 100644 --- a/src/shared/contracts/workspace.ts +++ b/src/shared/contracts/workspace.ts @@ -88,15 +88,46 @@ export function nextWorkspaceIconId(workspaces: readonly Workspace[]): Workspace * longer exists stays visible rather than vanishing — losing track of a project * is far worse than showing it in the wrong group, and the Workspaces settings * section lets the user file it deliberately. Home is synthetic and never filed, - * so it belongs to every workspace by the same rule. + * so it belongs to every workspace by the same rule — but its *threads* are + * workspace-scoped individually via the companion rule + * {@link isThreadInWorkspace}, which the same surfaces must also apply. */ +/** + * Shared core of the unfiled rule: an absent tag, or one naming a workspace + * that no longer exists, stays visible rather than vanishing — losing track of + * the item is far worse than showing it in the wrong group. + */ +function isUnfiledOrActiveWorkspace( + assigned: string | undefined, + activeWorkspaceId: string | null, + knownWorkspaceIds: ReadonlySet, +): boolean { + if (!assigned || !knownWorkspaceIds.has(assigned)) return true; + return assigned === activeWorkspaceId; +} + export function isProjectInWorkspace( project: { id?: string; workspaceId?: string | undefined }, activeWorkspaceId: string | null, knownWorkspaceIds: ReadonlySet, ): boolean { if (isHomeProjectId(project.id)) return true; - const assigned = project.workspaceId; - if (!assigned || !knownWorkspaceIds.has(assigned)) return true; - return assigned === activeWorkspaceId; + return isUnfiledOrActiveWorkspace(project.workspaceId, activeWorkspaceId, knownWorkspaceIds); +} + +/** + * Companion to {@link isProjectInWorkspace} for individual threads. Threads in + * real projects always pass — their project decides workspace membership. Home + * threads carry their own `workspaceId` (the workspace active when they were + * created) so projectless chats stay local to that workspace instead of + * cluttering every sidebar. An absent or dangling tag keeps the thread visible + * everywhere, mirroring the unfiled-project rule above. + */ +export function isThreadInWorkspace( + thread: { projectId: string; workspaceId?: string | undefined }, + activeWorkspaceId: string | null, + knownWorkspaceIds: ReadonlySet, +): boolean { + if (!isHomeProjectId(thread.projectId)) return true; + return isUnfiledOrActiveWorkspace(thread.workspaceId, activeWorkspaceId, knownWorkspaceIds); }