diff --git a/src/features/team-mode/team-layout-tmux/layout.test.ts b/src/features/team-mode/team-layout-tmux/layout.test.ts index 109060f03..3ef809823 100644 --- a/src/features/team-mode/team-layout-tmux/layout.test.ts +++ b/src/features/team-mode/team-layout-tmux/layout.test.ts @@ -114,7 +114,7 @@ describe("team-layout-tmux", () => { return null } - return { sessionId: displaySessionId } + return { sessionId: displaySessionId, paneId: process.env.TMUX_PANE, windowTarget: "test-session:0" } }) }) @@ -149,7 +149,7 @@ describe("team-layout-tmux", () => { expect(runTmuxCommandMock).toHaveBeenCalledTimes(0) }) - test("creates detached focus and grid windows and sends attach via send-keys", async () => { + test("creates teammate panes in the caller window and sends attach via send-keys", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [ @@ -162,12 +162,8 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() - const newWindowCalls = commands.filter((args) => args[0] === "new-window") - expect(newWindowCalls.length).toBe(2) - expect(newWindowCalls.map((args) => args[args.indexOf("-n") + 1])).toEqual([ - "team-run-attach-focus", - "team-run-attach-grid", - ]) + expect(commands.some((args) => args[0] === "new-window")).toBe(false) + expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(2) const sendKeysCalls = commands.filter((args) => args[0] === "send-keys") const literals = sendKeysCalls.map((args) => args.join(" ")) @@ -175,7 +171,7 @@ describe("team-layout-tmux", () => { expect(literals.some((s) => s.includes("--session 's-m2'"))).toBe(true) }) - test("uses focus main-vertical and grid tiled windows", async () => { + test("uses caller window main-vertical layout with caller pane as primary", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [ @@ -191,14 +187,14 @@ describe("team-layout-tmux", () => { const commands = getCommands() const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1]) expect(selectLayoutArgs).toContain("main-vertical") - expect(selectLayoutArgs).toContain("tiled") - expect(commands).toContainEqual(["set-window-option", "-t", "@1", "main-pane-width", "60%"]) + expect(selectLayoutArgs).not.toContain("tiled") + expect(commands).toContainEqual(["resize-pane", "-t", process.env.TMUX_PANE ?? "", "-x", "30%"]) expect(result).not.toBeNull() expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"]) - expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"]) + expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([]) }) - test("#given 4 or more teammates #when createTeamLayout runs #then it still keeps separate focus and grid windows", async () => { + test("#given 4 or more teammates #when createTeamLayout runs #then it keeps every teammate in the caller window", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = Array.from({ length: 5 }, (_, index) => ({ @@ -212,13 +208,11 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() - const newWindowNames = commands - .filter((args) => args[0] === "new-window") - .map((args) => args[args.indexOf("-n") + 1]) - expect(newWindowNames).toEqual(["team-run-tiled-focus", "team-run-tiled-grid"]) + expect(commands.some((args) => args[0] === "new-window")).toBe(false) + expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(5) const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1]) expect(selectLayoutArgs).toContain("main-vertical") - expect(selectLayoutArgs).toContain("tiled") + expect(selectLayoutArgs).not.toContain("tiled") }) test("#given caller inside tmux #when createTeamLayout runs #then it never steals focus or mutates window border options", async () => { @@ -333,7 +327,7 @@ describe("team-layout-tmux", () => { }) describe("createTeamLayout - focus/grid window topology", () => { - test("#given caller inside tmux #when createTeamLayout runs #then creates focus and grid windows without a new session", async () => { + test("#given caller inside tmux #when createTeamLayout runs #then uses the caller window without a new session", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [ @@ -347,8 +341,8 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() expect(commands.some((args) => args[0] === "new-session")).toBe(false) - expect(commands.filter((args) => args[0] === "new-window").length).toBe(2) - expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(false) + expect(commands.filter((args) => args[0] === "new-window").length).toBe(0) + expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(true) }) test("#given caller session resolved #when createTeamLayout runs #then ownedSession is false", async () => { @@ -364,7 +358,7 @@ describe("team-layout-tmux", () => { expect(result?.ownedSession).toBe(false) }) - test("#given first teammate #when layout runs #then it creates focus and grid windows without splitting the leader pane", async () => { + test("#given first teammate #when layout runs #then it splits the caller pane horizontally for teammate area", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }] @@ -375,8 +369,10 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() const splitCalls = commands.filter((args) => args[0] === "split-window") - expect(splitCalls).toEqual([]) - expect(commands.filter((args) => args[0] === "new-window").length).toBe(2) + expect(splitCalls).toEqual([ + ["split-window", "-t", process.env.TMUX_PANE ?? "", "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", "/tmp/m1"], + ]) + expect(commands.filter((args) => args[0] === "new-window").length).toBe(0) }) test("#given 3 members #when createTeamLayout runs #then focusPanesByMember contains 3 distinct pane ids", async () => { @@ -397,7 +393,7 @@ describe("team-layout-tmux", () => { expect(new Set(Object.values(result?.focusPanesByMember ?? {})).size).toBe(3) }) - test("#given layout created #when createTeamLayout runs #then it keeps separate focus and grid pane maps", async () => { + test("#given layout created #when createTeamLayout runs #then it records focus panes only", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [ @@ -412,9 +408,10 @@ describe("team-layout-tmux", () => { const commands = getCommands() expect(result).not.toBeNull() expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"]) - expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2"]) - expect(result?.focusWindowId).not.toBe(result?.gridWindowId) - expect(commands.filter((args) => args[0] === "new-window").length).toBe(2) + expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([]) + expect(result?.focusWindowId).toBe("test-session:0") + expect(result?.gridWindowId).toBeUndefined() + expect(commands.filter((args) => args[0] === "new-window").length).toBe(0) expect(commands.some((args) => args[0] === "send-keys" && args.includes("Enter"))).toBe(true) }) }) diff --git a/src/features/team-mode/team-layout-tmux/layout.ts b/src/features/team-mode/team-layout-tmux/layout.ts index 827f439d4..78b340330 100644 --- a/src/features/team-mode/team-layout-tmux/layout.ts +++ b/src/features/team-mode/team-layout-tmux/layout.ts @@ -9,7 +9,7 @@ type TeamLayoutMember = { name: string; sessionId: string; worktreePath?: string export type TeamLayoutResult = { focusWindowId: string - gridWindowId: string + gridWindowId?: string focusPanesByMember: Record gridPanesByMember: Record targetSessionId: string @@ -34,60 +34,63 @@ function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}` } -async function listPanesInWindow(tmuxPath: string, windowId: string): Promise> { - const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowId, "-F", "#{pane_id}"]) +async function listPanesInWindow(tmuxPath: string, windowTarget: string): Promise> { + const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"]) if (!result.success || !result.output) return [] return result.output.trim().split("\n").filter(Boolean) } -async function createTeamWindow( +function selectExistingTeammatePane(teammatePanes: Array, callerPaneId: string): string { + return teammatePanes[Math.floor(teammatePanes.length / 2)] ?? teammatePanes[teammatePanes.length - 1] ?? callerPaneId +} + +function buildSplitArgs(callerPaneId: string, teammatePanes: Array, member: TeamLayoutMember): Array { + if (teammatePanes.length === 0) { + return ["split-window", "-t", callerPaneId, "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member)] + } + + return [ + "split-window", + "-t", + selectExistingTeammatePane(teammatePanes, callerPaneId), + teammatePanes.length % 2 === 1 ? "-v" : "-h", + "-P", + "-F", + "#{pane_id}", + "-c", + getPaneWorkingDirectory(member), + ] +} + +async function createTeamLayoutInCallerWindow( tmuxPath: string, - targetSessionId: string, - windowName: string, - layout: "main-vertical" | "tiled", + callerPaneId: string, + windowTarget: string, members: Array, serverUrl: string, -): Promise<{ windowId: string; panesByMember: Record } | null> { - const [firstMember, ...restMembers] = members - if (!firstMember) return null - - const created = await runTmuxCommand(tmuxPath, [ - "new-window", "-d", "-P", "-F", "#{window_id}", "-t", targetSessionId, "-n", windowName, - "-c", getPaneWorkingDirectory(firstMember), - ]) - if (!created.success || !created.output) return null - - const windowId = created.output.trim() - const initialPanes = await listPanesInWindow(tmuxPath, windowId) - const firstPaneId = initialPanes[0] - if (!firstPaneId) return null - - const panesByMember: Record = { [firstMember.name]: firstPaneId } - for (const member of restMembers) { - const split = await runTmuxCommand(tmuxPath, [ - "split-window", "-d", "-P", "-F", "#{pane_id}", "-t", firstPaneId, - "-c", getPaneWorkingDirectory(member), - ]) - if (!split.success || !split.output) return null - panesByMember[member.name] = split.output.trim() - } - - const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout]) - if (!layoutResult.success) return null - - if (layout === "main-vertical") { - await runTmuxCommand(tmuxPath, ["set-window-option", "-t", windowId, "main-pane-width", "60%"]) - await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout]) - } +): Promise<{ focusWindowId: string; focusPanesByMember: Record } | null> { + const panesByMember: Record = {} + const existingPanes = await listPanesInWindow(tmuxPath, windowTarget) + let teammatePanes = existingPanes.filter((paneId) => paneId !== callerPaneId) for (const member of members) { - const paneId = panesByMember[member.name] - if (!paneId) return null + const split = await runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member)) + if (!split.success || !split.output) return null + + const paneId = split.output.trim() + teammatePanes = [...teammatePanes, paneId] + panesByMember[member.name] = paneId await runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name]) await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"]) } - return { windowId, panesByMember } + const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"]) + if (!layoutResult.success) return null + + const resizeResult = await runTmuxCommand(tmuxPath, ["resize-pane", "-t", callerPaneId, "-x", "30%"]) + if (!resizeResult.success) return null + + return { focusWindowId: windowTarget, focusPanesByMember: panesByMember } } export async function createTeamLayout(teamRunId: string, members: Array, tmuxMgr: TmuxSessionManager): Promise { @@ -111,27 +114,21 @@ export async function createTeamLayout(teamRunId: string, members: Array gridPanesByMember: Record targetSessionId: string @@ -79,7 +79,7 @@ function isTeamLayoutResultLike(value: unknown): value is TeamLayoutResultLike { } return typeof value.focusWindowId === "string" - && typeof value.gridWindowId === "string" + && (value.gridWindowId === undefined || typeof value.gridWindowId === "string") && isRecord(value.focusPanesByMember) && isRecord(value.gridPanesByMember) && typeof value.targetSessionId === "string" @@ -210,6 +210,7 @@ async function invokeRemoveTeamLayout( targetSessionId, focusWindowId: layoutResult.focusWindowId, gridWindowId: layoutResult.gridWindowId, + paneIds: Object.values(layoutResult.focusPanesByMember), }, tmuxManager, ])) @@ -272,13 +273,11 @@ describe("team-mode live tmux smoke", () => { await rm(state.tempRoot, { recursive: true, force: true }) }) - test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then two new windows appear in the caller session AND removeTeamLayout deletes exactly those two windows leaving the caller session intact", async () => { + test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then teammate panes appear in the caller window and cleanup leaves the session intact", async () => { // given const state = requireLiveTestState() const layoutModule = await loadLayoutModule() const teamRunId = randomUUID() - const shortTeamRunId = teamRunId.slice(0, 8) - const expectedWindowNames = [`focus-${shortTeamRunId}`, `grid-${shortTeamRunId}`] const initialWindows = await listWindows(state.callerSessionId) const members: TeamLayoutMemberLike[] = [ { @@ -295,25 +294,33 @@ describe("team-mode live tmux smoke", () => { // when const layoutResult = await invokeCreateTeamLayout(layoutModule, teamRunId, members, state.tmuxManager) - const windowsAppeared = await waitForCondition(async () => { + const panesAppeared = await waitForCondition(async () => { + const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"]) + return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => panes.stdout.split("\n").includes(paneId)) + }) + const windowsUnchangedBeforeCleanup = await waitForCondition(async () => { const windows = await listWindows(state.callerSessionId) - return expectedWindowNames.every((windowName) => windows.some((window) => window.name === windowName)) + return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",") }) await invokeRemoveTeamLayout(layoutModule, teamRunId, state.tmuxManager, layoutResult, state.callerSessionId) - const windowsRemoved = await waitForCondition(async () => { + const panesRemoved = await waitForCondition(async () => { + const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"]) + return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => !panes.stdout.split("\n").includes(paneId)) + }) + const windowsUnchangedAfterCleanup = await waitForCondition(async () => { const windows = await listWindows(state.callerSessionId) - const noExpectedWindowsRemain = expectedWindowNames.every((windowName) => windows.every((window) => window.name !== windowName)) - const sameWindowIds = windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",") - return noExpectedWindowsRemain && sameWindowIds + return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",") }) const callerSessionStillAlive = await runTmuxCommand(["has-session", "-t", state.callerSessionId]) // then expect(layoutResult.focusWindowId.length).toBeGreaterThan(0) - expect(layoutResult.gridWindowId.length).toBeGreaterThan(0) - expect(windowsAppeared).toBe(true) - expect(windowsRemoved).toBe(true) + expect(layoutResult.gridWindowId).toBeUndefined() + expect(panesAppeared).toBe(true) + expect(windowsUnchangedBeforeCleanup).toBe(true) + expect(panesRemoved).toBe(true) + expect(windowsUnchangedAfterCleanup).toBe(true) expect(callerSessionStillAlive.success).toBe(true) expect(process.env.TMUX_PANE).toBe(state.callerPaneId) })