From b4a8a13cb62553ff4b222429e45813a8aca4ef43 Mon Sep 17 00:00:00 2001 From: Pi on Code Agent <313040514+pi-on-code-agent[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:24:29 +0000 Subject: [PATCH] feat: add chat panel location setting --- package.json | 15 +++++ src/chat-panel-location.ts | 41 +++++++++++++ src/extension.ts | 18 +++++- src/test/chat-panel-location.test.ts | 88 ++++++++++++++++++++++++++++ src/webview-panel.ts | 5 +- 5 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 src/chat-panel-location.ts create mode 100644 src/test/chat-panel-location.test.ts diff --git a/package.json b/package.json index 403c188..2082c29 100644 --- a/package.json +++ b/package.json @@ -327,6 +327,21 @@ "type": "boolean", "default": false, "description": "Automatically reveal a new Pi chat tab when the extension activates. Previously open Pi session tabs are always restored." + }, + "pi-on-code.chatPanelLocation": { + "type": "string", + "default": "splitPanel", + "enum": [ + "panel", + "splitPanel", + "secondarySideBar" + ], + "enumDescriptions": [ + "Open new chats as tabs in the active editor group without splitting the editor area.", + "Open new chats in an editor group split to the right of the active group (default).", + "Keep chats in the right-most chat editor group: split once, then stack further chats there (right-hand chat rail)." + ], + "description": "Where new Pi chat panels are placed: editor tab (Panel), right split (SplitPanel), or the right chat rail (SecondarySideBar)." } } }, diff --git a/src/chat-panel-location.ts b/src/chat-panel-location.ts new file mode 100644 index 0000000..6b6d861 --- /dev/null +++ b/src/chat-panel-location.ts @@ -0,0 +1,41 @@ +/** + * Where a newly created Pi chat panel is placed inside the VS Code window. + * + * - `"panel"` — the chat opens as a tab in the active editor group; the editor + * area is never split for it. + * - `"splitPanel"` — the chat opens in a new editor group split to the right + * of the active group (historical default behavior). + * - `"secondarySideBar"` — chats live in the right-most chat editor group: + * the first chat splits to the right once and later chats are stacked into + * the same far-right group, approximating a right-hand chat rail without + * multiplying editor columns. + */ +export type ChatPanelLocation = "panel" | "splitPanel" | "secondarySideBar"; + +export const DEFAULT_CHAT_PANEL_LOCATION: ChatPanelLocation = "splitPanel"; + +export function parseChatPanelLocation(raw: unknown): ChatPanelLocation { + return raw === "panel" || raw === "splitPanel" || raw === "secondarySideBar" + ? raw + : DEFAULT_CHAT_PANEL_LOCATION; +} + +export type ChatColumnTarget = + | { kind: "active" } + | { kind: "beside" } + | { kind: "column"; column: number }; + +/** Choose the editor column a new chat panel should open in. */ +export function resolveChatColumnTarget( + location: ChatPanelLocation, + openChatColumns: ReadonlyArray, +): ChatColumnTarget { + if (location === "panel") { return { kind: "active" }; } + if (location === "secondarySideBar") { + if (openChatColumns.length > 0) { + return { kind: "column", column: Math.max(...openChatColumns) }; + } + return { kind: "beside" }; + } + return { kind: "beside" }; +} diff --git a/src/extension.ts b/src/extension.ts index 1f8c177..c7185d6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -21,6 +21,10 @@ import { extractSessionId } from "./session-reference.js"; import { shouldRevealSessionPanel } from "./session-startup.js"; import { findReusableDraft, shouldPromoteDraft } from "./session-draft.js"; import { normalizeSessionRename } from "./session-rename.js"; +import { + parseChatPanelLocation, + resolveChatColumnTarget, +} from "./chat-panel-location.js"; import { emptySessionListPreferences, isSessionArchived, @@ -1604,7 +1608,19 @@ function addSession(context: vscode.ExtensionContext, cwd = getWorkspaceCwd()): void saveOpenSessionPaths(); }; setActiveSession(sw); - void sw.webviewPanel.show(); + const location = parseChatPanelLocation( + vscode.workspace.getConfiguration("pi-on-code").get("chatPanelLocation"), + ); + const openChatColumns = sessions + .map((session) => session.webviewPanel.viewColumn) + .filter((column): column is number => typeof column === "number"); + const target = resolveChatColumnTarget(location, openChatColumns); + const viewColumn = target.kind === "active" + ? vscode.ViewColumn.Active + : target.kind === "beside" + ? vscode.ViewColumn.Beside + : target.column; + void sw.webviewPanel.show(viewColumn); void initSessionInBackground(context, sw, { fresh: true }); } diff --git a/src/test/chat-panel-location.test.ts b/src/test/chat-panel-location.test.ts new file mode 100644 index 0000000..bd43829 --- /dev/null +++ b/src/test/chat-panel-location.test.ts @@ -0,0 +1,88 @@ +import * as assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { + DEFAULT_CHAT_PANEL_LOCATION, + parseChatPanelLocation, + resolveChatColumnTarget, +} from "../chat-panel-location.js"; + +suite("Chat panel location", () => { + test("defaults to splitPanel for missing or invalid values", () => { + assert.strictEqual(DEFAULT_CHAT_PANEL_LOCATION, "splitPanel"); + assert.strictEqual(parseChatPanelLocation(undefined), "splitPanel"); + assert.strictEqual(parseChatPanelLocation("sidebar-left"), "splitPanel"); + assert.strictEqual(parseChatPanelLocation(42), "splitPanel"); + }); + + test("accepts the three documented locations", () => { + assert.strictEqual(parseChatPanelLocation("panel"), "panel"); + assert.strictEqual(parseChatPanelLocation("splitPanel"), "splitPanel"); + assert.strictEqual(parseChatPanelLocation("secondarySideBar"), "secondarySideBar"); + }); + + test("panel mode opens in the active editor group without splitting", () => { + assert.deepStrictEqual( + resolveChatColumnTarget("panel", [1, 2]), + { kind: "active" }, + ); + }); + + test("splitPanel mode always splits to the right of the active group", () => { + assert.deepStrictEqual( + resolveChatColumnTarget("splitPanel", [1, 2, 3]), + { kind: "beside" }, + ); + }); + + test("secondarySideBar stacks new chats into the right-most chat column", () => { + assert.deepStrictEqual( + resolveChatColumnTarget("secondarySideBar", [1, 3]), + { kind: "column", column: 3 }, + ); + }); + + test("secondarySideBar splits once when no chat column exists yet", () => { + assert.deepStrictEqual( + resolveChatColumnTarget("secondarySideBar", []), + { kind: "beside" }, + ); + }); + + test("publishes a matching configuration property", () => { + const manifest = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { + contributes: { + configuration: { + properties: Record; + }; + }; + }; + const property = manifest.contributes.configuration.properties["pi-on-code.chatPanelLocation"]; + assert.ok(property, "pi-on-code.chatPanelLocation property is missing"); + assert.strictEqual(property.default, "splitPanel"); + assert.deepStrictEqual(property.enum, ["panel", "splitPanel", "secondarySideBar"]); + assert.strictEqual(property.enumDescriptions?.length, 3); + }); + + test("new-session creation consults the setting and panel column", () => { + const extension = readFileSync( + new URL("../../src/extension.ts", import.meta.url), + "utf8", + ); + assert.match(extension, /parseChatPanelLocation\(/); + assert.match(extension, /getConfiguration\("pi-on-code"\)\.get\("chatPanelLocation"\)/); + assert.match(extension, /resolveChatColumnTarget\(location, openChatColumns\)/); + assert.match(extension, /sw\.webviewPanel\.show\(viewColumn\)/); + const panel = readFileSync( + new URL("../../src/webview-panel.ts", import.meta.url), + "utf8", + ); + assert.match(panel, /async show\(column\?: vscode\.ViewColumn\)/); + assert.match(panel, /column \?\? vscode\.ViewColumn\.Two/); + }); +}); diff --git a/src/webview-panel.ts b/src/webview-panel.ts index 61d6b20..aba7f0a 100644 --- a/src/webview-panel.ts +++ b/src/webview-panel.ts @@ -89,8 +89,9 @@ export class PiWebviewPanel { set onActivate(cb: (() => void) | null) { this._onActivateCb = cb; } private _onActivateCb: (() => void) | null = null; get isActive(): boolean { return this.panel?.active === true; } + get viewColumn(): vscode.ViewColumn | undefined { return this.panel?.viewColumn; } - async show(): Promise { + async show(column?: vscode.ViewColumn): Promise { if (this.panel) { this.panel.reveal(); // Retained webviews can keep the compositor surface from the size they @@ -107,7 +108,7 @@ export class PiWebviewPanel { this.panel = vscode.window.createWebviewPanel( "pi-chat-" + randomId, "Pi on Code", - vscode.ViewColumn.Two, + column ?? vscode.ViewColumn.Two, { enableScripts: true, retainContextWhenHidden: true,