Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/main/db.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
3 changes: 2 additions & 1 deletion src/main/db/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
9 changes: 9 additions & 0 deletions src/main/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions src/main/db/projectsThreads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
3 changes: 3 additions & 0 deletions src/main/db/projectsThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/main/db/rowMappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
23 changes: 23 additions & 0 deletions src/main/db/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
8 changes: 6 additions & 2 deletions src/main/db/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,19 +151,20 @@ function runProjectSync(stmt: SqliteStatement, project: Project, sortOrder: numb
function prepareThreadSyncStatement(sqlite: InstanceType<typeof Database>): 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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/renderer/actions/threadActions.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"] }),
);
});
});
});

Expand Down
16 changes: 14 additions & 2 deletions src/renderer/actions/threadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { startTransition } from "react";
import { toast } from "@heroui/react";
import {
isProjectInWorkspace,
isThreadInWorkspace,
type Project,
type RemoteThreadCommand,
type Thread,
Expand All @@ -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";
Expand Down Expand Up @@ -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;

Expand Down
59 changes: 59 additions & 0 deletions src/renderer/actions/threadLaunchActions.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>() {
Expand Down Expand Up @@ -69,6 +70,7 @@ const mocks = vi.hoisted(() => {
remoteState,
remoteClient,
bridge,
activeWorkspaceId: "ws-work" as string | null,
createWorktree:
vi.fn<
(
Expand Down Expand Up @@ -127,6 +129,10 @@ vi.mock("@/renderer/state/sharedSettingsStore", () => ({
},
}));

vi.mock("@/renderer/state/workspaceStore", () => ({
getActiveWorkspaceId: () => mocks.activeWorkspaceId,
}));

vi.mock("@/renderer/bridge", () => ({
readBridge: () => mocks.bridge,
}));
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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"));

Expand Down
5 changes: 5 additions & 0 deletions src/renderer/actions/threadLaunchActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions src/renderer/components/workspace/workspaceMenuItems.tsx
Original file line number Diff line number Diff line change
@@ -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: <Layers className="size-3.5" />,
items: [
...workspaces.map((workspace) => ({
id: workspaceMenuKey(workspace.id),
label: workspace.name,
icon: <WorkspaceIcon icon={workspace.icon} className="size-3.5" />,
isDisabled: workspace.id === currentWorkspaceId,
})),
{
id: WORKSPACE_UNFILED_KEY,
label: t`All workspaces`,
icon: <Layers className="size-3.5" />,
isDisabled: !currentWorkspaceId,
},
],
};
}
Loading