feat: multi-project workspaces and project window groups (#448)

Workspaces open several local project folders in one IDE session (File › New / Open / Manage Workspace…, or Attach when opening a project, which creates a workspace automatically for a plain project window). Member folders are the Project view roots and take part in indexing, Find in Files, Git, language servers, run configurations and builds; agent tasks choose which member they work in. Window › Merge All Project Windows groups project windows into one window with project tabs (switch, reorder, close, move to a new window), restored on reopen.

Closes #241
Closes #198
Closes #286
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 22:45:42 +08:00
1 джерело 5bab140968
коміт 01defdefe4
88 files changed
+2689 -151

No files matched your search

+8
Переглянути файл
@@ -50,6 +50,14 @@
border-radius: 50%;
background: var(--accent);
}
.taskProject {
flex: 0 0 auto;
max-width: 35%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-subtle);
}
.detail {
flex: 1 1 0;
min-width: 0;
+7
Переглянути файл
@@ -7,6 +7,7 @@ import { SESSION_KIND, age, branchLabel, groupTasks, type TaskGroup } from "./mo
import { openHome, openTask, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import styles from "./Agents.module.css";
import { basename, useProject } from "@/platform/project";
import { useTx } from "@/platform/i18n";
export function AgentsHeaderActions() {
@@ -43,6 +44,7 @@ export function TaskStatusIcon({ status }: { status: Status }) {
}
export function AgentsView() {
const project = useProject((s) => s.project);
const tx = useTx();
const loaded = useAgents((s) => s.loaded);
const error = useAgents((s) => s.error);
@@ -129,6 +131,11 @@ export function AgentsView() {
{unread.has(n.task.id) && (
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
)}
{project?.workspace && (
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
{basename(n.task.projectRoot ?? project.root)}
</span>
)}
<span className={styles.detail}>
{providerName(n.task.provider)}
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
+47 -15
Переглянути файл
@@ -4,8 +4,10 @@ import type { AgentProbe, Branch } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { basename, useProject } from "@/platform/project";
import { Button, Icon, Select, T, type SelectOption } from "@/ui";
import { defaultTaskProject } from "./project";
import { maybeEditorService } from "@/workbench/contributions";
import { Composer } from "./Composer";
import { canSend, emptyDraft } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
@@ -22,8 +24,19 @@ export function HomeEditor() {
const history = useAgents((s) => s.history);
const composer = useAgents((s) => s.composers.get(HOME)) ?? emptyDraft();
const project = useProject((s) => s.project);
const [branches, setBranches] = useState<Branch[]>([]);
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState(() =>
defaultTaskProject(project, maybeEditorService()?.activePath() ?? null, draft.projectRoot),
);
const members = project?.workspace?.projects ?? [];
const root = members.includes(selected ?? "") ? selected! : (members[0] ?? project?.root ?? "");
const [repoState, setRepoState] = useState<{
root: string;
branches: Branch[];
current: string | null;
isGit: boolean;
} | null>(null);
const branches = repoState?.root === root ? repoState.branches : [];
const current = repoState?.root === root ? repoState.current : null;
const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
@@ -35,31 +48,37 @@ export function HomeEditor() {
}, []);
useEffect(() => {
call(commands.vcsBranches(null))
.then((list) => {
const local = list.filter((b) => !b.remote);
setBranches(local);
setCurrent(local.find((b) => b.current)?.name ?? null);
let live = true;
const repo = project?.workspace ? root : null;
void Promise.all([call(commands.vcsBranchInfo(repo)), call(commands.vcsBranches(repo))])
.then(([info, list]) => {
if (!live) return;
setRepoState({ root, branches: list.filter((b) => !b.remote), current: info.head, isGit: true });
})
.catch(() => setBranches([]));
}, []);
.catch(() => {
if (live) setRepoState({ root, branches: [], current: null, isGit: false });
});
return () => {
live = false;
};
}, [root, project?.workspace]);
const provider = providers.find((p) => p.id === draft.provider) ?? providers[0];
const probe = useProbe(provider?.installed ? provider.id : null);
const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS);
const providerOptions: SelectOption[] = agentOptions(providers, tx, providerIcon);
// Without git under the project there are no worktrees: force the local checkout.
const isGit = project?.isGit ?? false;
const isGit = project?.workspace ? repoState?.root === root && repoState.isGit : (project?.isGit ?? false);
const isolation = isGit ? draft.isolation : "local";
const base = draft.base ?? current;
const canStart = canSend(composer) && !!provider?.installed && !starting;
const root = project?.root ?? "";
const canStart =
canSend(composer) && !!provider?.installed && !starting && (!project?.workspace || repoState?.root === root);
const start = () => {
if (!canStart) return;
setStarting(true);
setError(null);
startTask(isolation)
startTask(isolation, project?.workspace ? root : null)
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setStarting(false));
};
@@ -104,7 +123,20 @@ export function HomeEditor() {
}
/>
<div className={styles.where}>
<span className={styles.project}>{project?.name}</span>
{members.length > 1 ? (
<Select
variant="ghost"
aria-label={tx("Project")}
value={root}
options={members.map((path) => ({ value: path, label: basename(path), detail: path }))}
onChange={(path) => {
setSelected(path);
updateDraft({ base: null });
}}
/>
) : (
<span className={styles.project}>{members.length ? basename(root) : project?.name}</span>
)}
{isGit && (
<Select
variant="ghost"
+11 -4
Переглянути файл
@@ -72,7 +72,11 @@ export function SessionEditor({ input }: { input: EditorInput }) {
const task = useAgents((s) => s.tasks.get(id));
const entries = useAgents((s) => s.entries.get(id));
const changes = useAgents((s) => s.changes.get(id));
const head = useLocalHead(task?.isolation === "local" ? task.status : null);
const project = useProject((s) => s.project);
const head = useLocalHead(
task?.isolation === "local" ? task.status : null,
project?.workspace ? (task?.projectRoot ?? project.root) : null,
);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null);
// The subagent conversation shown, with the subagents that lead to it (empty: the task's own).
const [path, setPath] = useState<string[]>([]);
@@ -160,14 +164,14 @@ function providerName(id: string) {
* Current branch of the project's repository for a local-checkout task (`status` is
* `null` otherwise); followed as git changes, since the agent or the user may switch it.
*/
function useLocalHead(status: AgentTask["status"] | null): string | null {
function useLocalHead(status: AgentTask["status"] | null, root: string | null): string | null {
const [head, setHead] = useState<string | null>(null);
const local = status !== null;
useEffect(() => {
if (!local) return;
let live = true;
const load = () =>
void call(commands.vcsBranchInfo(null)).then(
void call(commands.vcsBranchInfo(root)).then(
(b) => live && setHead(b.head ?? b.headCommit?.slice(0, 8) ?? null),
() => live && setHead(null),
);
@@ -177,7 +181,7 @@ function useLocalHead(status: AgentTask["status"] | null): string | null {
live = false;
off();
};
}, [local, status]);
}, [local, status, root]);
return local ? head : null;
}
@@ -196,6 +200,8 @@ function Header({
changesOpen: boolean;
onToggleChanges: () => void;
}) {
const project = useProject((s) => s.project);
const projectName = project?.workspace ? basename(task.projectRoot ?? project.root) : null;
const count = changes?.length ?? 0;
const busy = isBusy(task.status);
const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
@@ -216,6 +222,7 @@ function Header({
<span className={styles.headerText}>
{providerName(task.provider)}
{task.model ? ` · ${task.model}` : ""}
{projectName ? ` · ${projectName}` : ""}
{" · "}
{where ?? (
<>
+54 -2
Переглянути файл
@@ -5,17 +5,20 @@ import type { AgentCommand, AgentProvider } from "@/bindings";
const agentCommands = vi.fn();
const agentCreate = vi.fn();
const vcsBranches = vi.fn();
const vcsBranchInfo = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
isTauri: vi.fn(() => true),
commands: {
...real.commands,
agentCommands: (provider: string, task: string | null) => agentCommands(provider, task),
agentCreate: (request: unknown) => agentCreate(request),
vcsBranches: () => Promise.resolve({ status: "ok", data: [] }),
vcsBranchInfo: (root: string | null) => vcsBranchInfo(root),
vcsBranches: (root: string | null) => vcsBranches(root),
},
};
});
@@ -23,6 +26,10 @@ vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
}));
import { isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import type { ProjectInfo } from "@/bindings";
import { defaultTaskProject } from "./project";
import { HomeEditor } from "./HomeEditor";
import { HOME, updateComposer, useAgents } from "./store";
import { emptyDraft } from "./composerModel";
@@ -40,6 +47,14 @@ const claude: AgentProvider = {
};
beforeEach(() => {
vi.mocked(isTauri).mockReturnValue(true);
useProject.setState({ project: null });
vcsBranches.mockReset().mockResolvedValue({ status: "ok", data: [] });
vcsBranchInfo
.mockReset()
.mockImplementation((root: string | null) =>
Promise.resolve({ status: "ok", data: { head: root === "/project-b" ? "b-main" : "a-main" } }),
);
agentCommands.mockReset();
agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } });
useAgents.setState({
@@ -105,3 +120,40 @@ describe("New Task composer commands", () => {
expect(agentCreate).not.toHaveBeenCalled();
});
});
describe("workspace task project", () => {
const project = {
root: "/workspace",
name: "Workspace",
isGit: false,
workspace: { version: 1, name: "Workspace", projects: ["/project-a", "/project-b"] },
} as ProjectInfo;
it("defaults to the active member, last used member, then first member", () => {
expect(defaultTaskProject(project, "/project-b/src/main.ts", "/project-a")).toBe("/project-b");
expect(defaultTaskProject(project, null, "/project-b")).toBe("/project-b");
expect(defaultTaskProject(project, "/project-b-other/file", "/removed")).toBe("/project-a");
expect(defaultTaskProject({ ...project, workspace: null }, "/project-b/file", "/project-b")).toBe("/workspace");
});
it.each(["local", "worktree"] as const)("sends the selected project with %s isolation", async (isolation) => {
vi.mocked(isTauri).mockReturnValue(false);
useProject.setState({ project });
agentCommands.mockResolvedValue({ status: "ok", data: [] });
useAgents.setState({ draft: { ...useAgents.getState().draft, projectRoot: "/project-b", isolation } });
render(<HomeEditor />);
const selector = screen.getByRole("button", { name: "Project" });
expect(selector).toHaveTextContent("project-b");
await waitFor(() => expect(vcsBranchInfo).toHaveBeenCalledWith("/project-b"));
expect(vcsBranches).toHaveBeenCalledWith("/project-b");
fireEvent.click(selector);
expect(within(screen.getByRole("listbox")).getByText("/project-a")).toBeInTheDocument();
fireEvent.click(screen.getByRole("option", { name: /project-a/ }));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Update member" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Start" })).toBeEnabled());
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
await waitFor(() => expect(agentCreate).toHaveBeenCalled());
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ projectRoot: "/project-a", isolation });
expect(vcsBranchInfo).toHaveBeenLastCalledWith("/project-a");
expect(vcsBranches).toHaveBeenLastCalledWith("/project-a");
await vi.dynamicImportSettled();
});
});
+13
Переглянути файл
@@ -0,0 +1,13 @@
import { projectFolder } from "@/platform/project";
/** Prefer the active file's member, then the last task's member, then manifest order. */
export function defaultTaskProject(
project: Parameters<typeof projectFolder>[0],
activePath: string | null,
last: string | null | undefined,
): string | null {
const members = project?.workspace?.projects;
if (!members?.length) return project?.root ?? null;
const active = activePath ? projectFolder(project, activePath)?.root : null;
return active ?? (last && members.includes(last) ? last : (members[0] ?? null));
}
+15 -1
Переглянути файл
@@ -12,6 +12,8 @@ import type {
AgentTask,
Isolation,
} from "@/bindings";
import { useProject } from "@/platform/project";
import { defaultTaskProject } from "./project";
import { call, commands, isTauri } from "@/platform/ipc";
import { editorService, notifications } from "@/workbench/contributions";
import { bypassDefaults, canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
@@ -29,6 +31,7 @@ import {
/** New Task page settings (its text lives in the `HOME` composer). */
export interface Draft {
projectRoot?: string | null;
provider: string;
/** Settings chosen for the next task, by option id (reset when the agent changes). */
config: Record<string, string>;
@@ -343,18 +346,28 @@ export function openTask(task: string) {
/** Opens the New Task page; `prompt` pre-fills it. */
export function openHome(prompt?: string) {
const project = useProject.getState().project;
if (project?.workspace) {
const previous = useAgents.getState().draft.projectRoot;
const projectRoot = defaultTaskProject(project, editorService().activePath(), previous);
updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) });
}
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true });
}
/** `isolation` overrides the draft's (a project without git can only use the local checkout). */
export async function startTask(isolation: Isolation = useAgents.getState().draft.isolation): Promise<void> {
export async function startTask(
isolation: Isolation = useAgents.getState().draft.isolation,
projectRoot: string | null = null,
): Promise<void> {
const { draft, providers } = useAgents.getState();
const composer = composerDraft(HOME);
if (!canSend(composer)) return;
const config = { ...bypassDefaults(providers.find((p) => p.id === draft.provider)?.config ?? []), ...draft.config };
const task = await call(
commands.agentCreate({
projectRoot,
prompt: toPrompt(composer),
provider: draft.provider,
config: Object.entries(config).map(([id, value]) => ({ id, value })),
@@ -367,6 +380,7 @@ export async function startTask(isolation: Isolation = useAgents.getState().draf
tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task),
entries: new Map(s.entries).set(task.id, s.entries.get(task.id) ?? []),
}));
updateDraft({ projectRoot });
remember(composer.text.trim());
updateComposer(HOME, emptyDraft());
if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id);