-
+
{children}
-
+
diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts
index 12774bae205c0..92ff8b344348d 100644
--- a/apps/web/src/composer-list-continuation.test.ts
+++ b/apps/web/src/composer-list-continuation.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
listContinuationForEnter,
listIndentForTab,
+ listOutdentForTab,
nextOrderedMarkerText,
} from "./composer-list-continuation";
@@ -86,6 +87,30 @@ describe("composer list indentation", () => {
const edit = listIndentForTab(value, value.length, value.length)!;
expect(applyEdit(value, edit)).toBe("1. parent\n 1. first\n 2. second");
});
+
+ it.each(["- parent\n- child", "1. parent\n2. child", " 7. child"])(
+ "undoes Tab on %j with Shift+Tab and keeps the caret on its text",
+ (value) => {
+ const cursor = value.lastIndexOf("child") + 2;
+ const nest = listIndentForTab(value, cursor, cursor)!;
+ const nested = applyEdit(value, nest);
+ const edit = listOutdentForTab(nested, nest.cursorAfter, nest.cursorAfter)!;
+ expect(applyEdit(nested, edit)).toBe(value);
+ expect(edit.cursorAfter).toBe(cursor);
+ },
+ );
+
+ it.each([
+ ["1. a\n 1. b\n2. c", "1. a\n2. b\n3. c"],
+ ["- a\n 5. b", "- a\n5. b"],
+ ])("outdents %j to %j and renumbers only within the parent's list", (value, expected) => {
+ const edit = listOutdentForTab(value, value.indexOf("b"), value.indexOf("b"))!;
+ expect(applyEdit(value, edit)).toBe(expected);
+ });
+
+ it("leaves a top-level item alone so Shift+Tab can still switch Build/Plan", () => {
+ expect(listOutdentForTab("- item", 6, 6)).toBeNull();
+ });
});
describe("nextOrderedMarkerText", () => {
diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts
index 583cf1bd04a92..ca5c78daaa2d2 100644
--- a/apps/web/src/composer-list-continuation.ts
+++ b/apps/web/src/composer-list-continuation.ts
@@ -302,3 +302,41 @@ export function listIndentForTab(
}
return { start: line.start, end: line.start, replacement: " ", cursorAfter: start + 2 };
}
+
+export function listOutdentForTab(
+ value: string,
+ start: number,
+ end: number,
+): ComposerListEdit | null {
+ if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null;
+ const line = currentLine(value, start);
+ const parsed = parseListMarker(line.text);
+ if (!parsed?.marker.indent) return null;
+ const tokens = inlineTokenRanges(value);
+ if (isInsideInlineToken(tokens, start)) return null;
+ const { marker } = parsed;
+ const parent = parentMarker(value, line.start, marker, tokens);
+ // With no parent above, undo one step of Tab's two-space fallback.
+ const indent = parent?.indent ?? marker.indent.replace(/^(?:\t| {1,2})/, "");
+ const continues =
+ parent?.kind === "ordered" &&
+ marker.kind === "ordered" &&
+ parent.delimiter === marker.delimiter;
+ const number = continues ? nestedNumber(marker, parent) : { replacedLength: 0, text: "" };
+ const replacedEnd = line.start + marker.indent.length + number.replacedLength;
+ const replacement = indent + number.text;
+ const cursorAfter =
+ start < replacedEnd
+ ? line.start + indent.length
+ : start - replacedEnd + line.start + replacement.length;
+ const renumbered = continues ? renumberFollowingItems(value, line.end, parent, tokens) : null;
+ if (renumbered) {
+ return {
+ start: line.start,
+ end: renumbered.end,
+ replacement: replacement + value.slice(replacedEnd, line.end) + renumbered.text,
+ cursorAfter,
+ };
+ }
+ return { start: line.start, end: replacedEnd, replacement, cursorAfter };
+}
diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts
index 4a76b3fcf93ae..51635a651d476 100644
--- a/apps/web/src/composer-logic.test.ts
+++ b/apps/web/src/composer-logic.test.ts
@@ -22,8 +22,10 @@ import {
extendReplacementRangeForTrailingSpace,
formatAssistantCitationForComposer,
isPathSuggestionCurrent,
+ parseSideChatCommand,
replaceTextRange,
} from "./composer-logic";
+import { slashCommandItemsForPromptPosition } from "./components/chat/composerSlashCommandSearch";
import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput";
import { formatTerminalContextReference } from "./lib/terminalContext";
@@ -548,3 +550,29 @@ describe("extendReplacementRangeForTrailingSpace", () => {
expect(extendReplacementRangeForTrailingSpace("@fi", 3, "@file ")).toBe(3);
});
});
+
+describe("parseSideChatCommand", () => {
+ it("reads an optional prompt after /side or /btw", () => {
+ expect(parseSideChatCommand("/side")).toEqual({ prompt: "" });
+ expect(parseSideChatCommand(" /SIDE why does this fail?\nline two ")).toEqual({
+ prompt: "why does this fail?\nline two",
+ });
+ expect(parseSideChatCommand("/btw check @src/a.ts")).toEqual({ prompt: "check @src/a.ts" });
+ });
+
+ it("ignores other commands and inline mentions, which the menu does not offer", () => {
+ expect(parseSideChatCommand("/sidebar")).toBeNull();
+ expect(parseSideChatCommand("/btwx")).toBeNull();
+ expect(parseSideChatCommand("ask /side later")).toBeNull();
+ const items = (["side", "btw", "plan"] as const).map((command) => ({
+ id: `slash:${command}`,
+ type: "slash-command" as const,
+ command,
+ label: `/${command}`,
+ description: "",
+ }));
+ expect(slashCommandItemsForPromptPosition(items, false).map((item) => item.id)).toEqual([
+ "slash:plan",
+ ]);
+ });
+});
diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts
index a3043ce20ccaa..9b7223797190e 100644
--- a/apps/web/src/composer-logic.ts
+++ b/apps/web/src/composer-logic.ts
@@ -18,7 +18,7 @@ import {
import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings";
export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill";
-export type ComposerSlashCommand = "model" | "plan" | "default";
+export type ComposerSlashCommand = "model" | "plan" | "default" | "side" | "btw";
export type ComposerSubmissionIntent = "foreground" | "background" | "alternate";
export interface ComposerTrigger {
@@ -318,9 +318,15 @@ export function composerStateAtPromptEnd(
};
}
+/** `/side [prompt]`, or its alias `/btw [prompt]`, as the whole message. */
+export function parseSideChatCommand(text: string): { prompt: string } | null {
+ const match = /^\/(?:side|btw)(?:\s+([\s\S]*))?$/i.exec(text.trim());
+ return match ? { prompt: match[1]?.trim() ?? "" } : null;
+}
+
export function parseStandaloneComposerSlashCommand(
text: string,
-): Exclude
| null {
+): Exclude | null {
const match = /^\/(plan|default)\s*$/i.exec(text.trim());
if (!match) {
return null;
diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts
index 535ceca280a5e..cfc17b5f08764 100644
--- a/apps/web/src/environmentGrouping.test.ts
+++ b/apps/web/src/environmentGrouping.test.ts
@@ -1,7 +1,10 @@
import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
-import { deriveLogicalProjectKeyFromSettings } from "@t3tools/client-runtime/state/project-grouping";
+import {
+ deriveLogicalProjectKeyFromSettings,
+ pickableProjectGroups,
+} from "@t3tools/client-runtime/state/project-grouping";
import {
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
@@ -275,4 +278,27 @@ describe("environment grouping", () => {
id: fallbackPrimary.id,
});
});
+
+ it("puts the preferred project after pinned projects", () => {
+ const project = (title: string, pinnedAt: string | null = null) =>
+ makeProject({
+ id: ProjectId.make(title),
+ title,
+ workspaceRoot: `/tmp/${title}`,
+ repositoryIdentity: null,
+ pinnedAt,
+ });
+ const current = project("current");
+ const groups = buildSidebarProjectSnapshots({
+ projects: [project("other"), project("pinned", "2026-01-01T00:00:00.000Z"), current],
+ settings: defaultGroupingSettings,
+ primaryEnvironmentId,
+ resolveEnvironmentLabel: () => null,
+ });
+ const entries = buildSidebarProjectPickerEntries({
+ groups: pickableProjectGroups(groups),
+ preferredProjectRef: { environmentId: current.environmentId, projectId: current.id },
+ });
+ expect(entries.map((entry) => entry.group.displayName)).toEqual(["pinned", "current", "other"]);
+ });
});
diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts
index dae666d915e9f..6f230659283a2 100644
--- a/apps/web/src/hooks/useHandleNewThread.ts
+++ b/apps/web/src/hooks/useHandleNewThread.ts
@@ -395,7 +395,7 @@ export function resolveDefaultProjectRef(
projectOrder: readonly string[],
): ScopedProjectRef | null {
const [defaultProject] = orderItemsByPreferredIds({
- items: projects,
+ items: projects.filter((project) => project.archivedAt == null),
preferredIds: projectOrder,
getId: getProjectOrderKey,
getPreferenceIds: (project) => [
diff --git a/apps/web/src/hooks/useOrderWhileOpen.test.ts b/apps/web/src/hooks/useOrderWhileOpen.test.ts
new file mode 100644
index 0000000000000..7e4308ef45551
--- /dev/null
+++ b/apps/web/src/hooks/useOrderWhileOpen.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { keepOrder, withArrivedKeys } from "./useOrderWhileOpen";
+
+describe("keepOrder", () => {
+ it("keeps the opening order and puts new items after it", () => {
+ const key = (item: string) => item;
+ // "c" was pinned and moved to the top; "d" arrived after the list opened; "b" is gone.
+ expect(keepOrder(["c", "d", "a"], ["a", "b", "c"], key)).toEqual(["a", "c", "d"]);
+ expect(keepOrder(["c", "a"], null, key)).toEqual(["c", "a"]);
+ });
+
+ it("holds rows that arrived after opening in their first place", () => {
+ // "b" and "c" load from another machine after the list opened with nothing in it.
+ const order = withArrivedKeys(null, ["b", "c"]);
+ expect(order).toEqual(["b", "c"]);
+ // Pinning "c" moves it first in the source list; the shown order stays.
+ expect(keepOrder(["c", "b"], order, (item) => item)).toEqual(["b", "c"]);
+ expect(withArrivedKeys(order, ["b", "c"])).toBe(order);
+ });
+});
diff --git a/apps/web/src/hooks/useOrderWhileOpen.ts b/apps/web/src/hooks/useOrderWhileOpen.ts
new file mode 100644
index 0000000000000..c925a8bbb1b5b
--- /dev/null
+++ b/apps/web/src/hooks/useOrderWhileOpen.ts
@@ -0,0 +1,46 @@
+import { useState } from "react";
+
+/** Sorts `items` into `order` by key. Items missing from `order` follow, in their own order. */
+export function keepOrder(
+ items: ReadonlyArray,
+ order: ReadonlyArray | null,
+ key: (item: T) => string,
+): T[] {
+ if (order === null) return [...items];
+ const rank = new Map(order.map((itemKey, index) => [itemKey, index]));
+ return items
+ .map((item, index) => ({ item, rank: rank.get(key(item)) ?? order.length + index }))
+ .toSorted((a, b) => a.rank - b.rank)
+ .map(({ item }) => item);
+}
+
+/** `order` with keys it has not seen added at the end, or `order` itself when nothing is new. */
+export function withArrivedKeys(
+ order: ReadonlyArray | null,
+ keys: ReadonlyArray,
+): ReadonlyArray | null {
+ const known = new Set(order);
+ const arrived = keys.filter((itemKey) => !known.has(itemKey));
+ return arrived.length === 0 ? order : [...(order ?? []), ...arrived];
+}
+
+/**
+ * Keeps the order a list had when it opened, so a row that a pin moves does not slide out
+ * from under the pointer or the keyboard highlight. Rows that load later join at the end and
+ * then hold their place too. The new order shows on the next open.
+ */
+export function useOrderWhileOpen(
+ items: ReadonlyArray,
+ key: (item: T) => string,
+ open: boolean,
+): T[] {
+ const [order, setOrder] = useState | null>(null);
+ if (!open) {
+ if (order !== null) setOrder(null);
+ return [...items];
+ }
+ const ordered = keepOrder(items, order, key);
+ const nextOrder = withArrivedKeys(order, ordered.map(key));
+ if (nextOrder !== order) setOrder(nextOrder);
+ return ordered;
+}
diff --git a/apps/web/src/hooks/useProjectGroupUpdate.ts b/apps/web/src/hooks/useProjectGroupUpdate.ts
new file mode 100644
index 0000000000000..23194c27d6ae6
--- /dev/null
+++ b/apps/web/src/hooks/useProjectGroupUpdate.ts
@@ -0,0 +1,160 @@
+import { AuthOrchestrationOperateScope, type ProjectIconOverride } from "@t3tools/contracts";
+import {
+ isAtomCommandInterrupted,
+ mapAtomCommandResult,
+ squashAtomCommandFailure,
+ type AtomCommandResult,
+} from "@t3tools/client-runtime/state/runtime";
+import { AsyncResult } from "effect/reactivity";
+import * as Cause from "effect/Cause";
+
+import { stackedThreadToast, toastManager } from "../components/ui/toast";
+import type { SidebarProjectGroupMember } from "../sidebarProjectGrouping";
+import { useProjects, useServerConfigs } from "../state/entities";
+import { readEnvironment } from "../state/environments";
+import { projectEnvironment } from "../state/projects";
+import { readEnvironmentScope, useEnvironmentsWithScope } from "../state/session";
+import { useOrchestrationCommand } from "../state/use-orchestration-command";
+
+export type ProjectGroupUpdate = Partial<{
+ title: string;
+ faviconPath: string | null;
+ projectIcon: ProjectIconOverride | null;
+ pinned: boolean;
+ archived: boolean;
+}>;
+
+type ProjectGroupMember = Pick<
+ SidebarProjectGroupMember,
+ "environmentId" | "id" | "environmentLabel"
+>;
+
+/**
+ * Writes one update to each checkout of a project, one machine at a time, and
+ * reports the first failure as a toast. Every machine must be connected first.
+ */
+export function useProjectGroupUpdate() {
+ const serverConfigs = useServerConfigs();
+ const updateProject = useOrchestrationCommand(projectEnvironment.update, {
+ reportFailure: false,
+ });
+
+ const updateProjectGroup = async (
+ members: ReadonlyArray,
+ input: ProjectGroupUpdate | ((member: TMember) => ProjectGroupUpdate),
+ failureTitle: string,
+ ): Promise> => {
+ const denied = checkProjectAccess(members, failureTitle);
+ if (denied) return denied;
+ const unavailable = members.find((member) => {
+ const environment = readEnvironment(member.environmentId);
+ return environment?.connection.phase !== "connected" || !environment.serverConfig;
+ });
+ if (unavailable) {
+ return failWith(
+ failureTitle,
+ `Connect ${unavailable.environmentLabel ?? "the selected environment"} and try again.`,
+ );
+ }
+ for (const member of members) {
+ // Access can be revoked between writes.
+ const revoked = checkProjectAccess([member], failureTitle);
+ if (revoked) return revoked;
+ const result = mapAtomCommandResult(
+ await updateProject({
+ environmentId: member.environmentId,
+ input: {
+ projectId: member.id,
+ ...(typeof input === "function" ? input(member) : input),
+ },
+ }),
+ () => undefined,
+ );
+ if (result._tag === "Failure") {
+ reportProjectFailure(
+ members.length > 1
+ ? `${failureTitle} on ${member.environmentLabel ?? "the current environment"}`
+ : failureTitle,
+ result,
+ );
+ return result;
+ }
+ }
+ return AsyncResult.success(undefined);
+ };
+
+ /** Older servers drop pin and archive, so their projects do not offer them. */
+ const canOrganizeProjectGroup = (members: ReadonlyArray) =>
+ members.every(
+ (member) =>
+ serverConfigs.get(member.environmentId)?.environment.capabilities.projectOrganization ===
+ true,
+ );
+
+ return { updateProjectGroup, canOrganizeProjectGroup };
+}
+
+/** Pin state for a project group, shared by every list that can pin or unpin a project. */
+export function useProjectPin() {
+ const { updateProjectGroup, canOrganizeProjectGroup } = useProjectGroupUpdate();
+ const environments = [...new Set(useProjects().map((project) => project.environmentId))].map(
+ (environmentId) => ({ environmentId }),
+ );
+ const operableEnvironmentIds = useEnvironmentsWithScope(
+ environments,
+ AuthOrchestrationOperateScope,
+ );
+ type PinnableGroup = {
+ readonly memberProjects: ReadonlyArray;
+ readonly pinned: boolean;
+ };
+ const canPinProjectGroup = (group: PinnableGroup) =>
+ canOrganizeProjectGroup(group.memberProjects) &&
+ group.memberProjects.every((member) => operableEnvironmentIds.has(member.environmentId));
+ const setProjectGroupPinned = (group: PinnableGroup, pinned: boolean) => {
+ void updateProjectGroup(
+ group.memberProjects,
+ { pinned },
+ pinned ? "Failed to pin project" : "Failed to unpin project",
+ );
+ };
+ return {
+ canPinProjectGroup,
+ setProjectGroupPinned,
+ toggleProjectGroupPin: (group: PinnableGroup) => setProjectGroupPinned(group, !group.pinned),
+ };
+}
+
+/** Reports the first member this connection cannot change, and returns that failure. */
+export function checkProjectAccess(
+ members: ReadonlyArray,
+ failureTitle: string,
+): AtomCommandResult | null {
+ const denied = members.find(
+ (member) => !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
+ );
+ return denied
+ ? failWith(
+ failureTitle,
+ `This connection cannot change projects in ${denied.environmentLabel ?? "this environment"}.`,
+ )
+ : null;
+}
+
+export function reportProjectFailure(title: string, result: AtomCommandResult) {
+ if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title,
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+}
+
+function failWith(title: string, message: string): AtomCommandResult {
+ const result = AsyncResult.failure(Cause.fail(new Error(message)));
+ reportProjectFailure(title, result);
+ return result;
+}
diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx
index d394c67c9dff2..9b1299aee1dea 100644
--- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx
+++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx
@@ -31,6 +31,7 @@ vi.mock("../state/session", () => ({
vi.mock("../state/entities", () => ({
useQuietThreadShell: () => null,
useProject: () => null,
+ readThreadShell: () => null,
}));
vi.mock("../state/terminalSessions", () => ({ useKnownTerminalSessions: () => null }));
vi.mock("../composerDraftStore", async () => {
@@ -56,6 +57,8 @@ vi.mock("../state/use-atom-command", () => ({
// The commands are replaced above; importing their runtime would connect a client.
vi.mock("../state/terminal", () => ({ terminalEnvironment: { open: {}, close: {}, write: {} } }));
vi.mock("../state/preview", () => ({ previewEnvironment: { close: {} } }));
+vi.mock("../state/threads", () => ({ threadEnvironment: { delete: {} } }));
+vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: () => null } }));
vi.mock("@t3tools/client-runtime/state/runtime", () => ({
isAtomCommandInterrupted: () => false,
}));
diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts
index a06afb97473bf..66e3ec5e95147 100644
--- a/apps/web/src/hooks/useRightPanelSurfaceClose.ts
+++ b/apps/web/src/hooks/useRightPanelSurfaceClose.ts
@@ -1,6 +1,11 @@
// Upstream's useRightPanelSurfaceClose. Coder: there are no browser surfaces, so no preview
// session closes with a surface and no agent-controlled browser asks first.
-import { AuthTerminalOperateScope, type ScopedThreadRef } from "@t3tools/contracts";
+import {
+ AuthOrchestrationOperateScope,
+ AuthTerminalOperateScope,
+ type ScopedThreadRef,
+} from "@t3tools/contracts";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
import { useThreadTerminalActions } from "../components/terminal/useThreadTerminalActions";
import { confirmTerminalClose } from "../lib/terminalCloseConfirm";
@@ -10,19 +15,25 @@ import {
useRightPanelStore,
type RightPanelSurface,
} from "../rightPanelStore";
+import { appAtomRegistry } from "../rpc/atomRegistry";
+import { readThreadShell } from "../state/entities";
import { readEnvironmentScope } from "../state/session";
+import { environmentShell } from "../state/shell";
+import { threadEnvironment } from "../state/threads";
+import { useAtomCommand } from "../state/use-atom-command";
import { useTerminalUiStateStore } from "../terminalUiStateStore";
import { useStableCallback } from "./useStableCallback";
/**
* Closes right panel surfaces for a thread. A terminal tab's sessions go with
- * it, and closing a terminal tab asks first.
+ * it, closing a terminal tab asks first, and a side chat that never ran is deleted.
*/
export function useRightPanelSurfaceClose(input: {
readonly threadRef: ScopedThreadRef | null;
readonly terminalLabelsById: ReadonlyMap;
}) {
const { threadRef, terminalLabelsById } = input;
+ const deleteThread = useAtomCommand(threadEnvironment.delete, "side chat delete");
const terminalActions = useThreadTerminalActions(threadRef);
const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);
const surfaces = useRightPanelStore(
@@ -39,6 +50,21 @@ export function useRightPanelSurfaceClose(input: {
terminalActions.closeTerminalSession(terminalId, ref);
}
}
+ if (
+ surface.kind === "side-chat" &&
+ readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope) &&
+ // A live shell without the side chat means it is already deleted.
+ !(
+ appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live" &&
+ readThreadShell(scopeThreadRef(ref.environmentId, surface.threadId)) === null
+ )
+ ) {
+ // The server keeps a side chat with a run or message. Reopen skips one it deleted.
+ void deleteThread({
+ environmentId: ref.environmentId,
+ input: { threadId: surface.threadId, onlyIfUnused: true },
+ });
+ }
};
// The active surface closes last so the panel does not flash through it.
const finishClose = (toClose: readonly RightPanelSurface[]) => {
diff --git a/apps/web/src/lib/sideChatPane.ts b/apps/web/src/lib/sideChatPane.ts
new file mode 100644
index 0000000000000..3bbf09f24bc51
--- /dev/null
+++ b/apps/web/src/lib/sideChatPane.ts
@@ -0,0 +1,31 @@
+import type { OrchestrationV2ThreadShell, ScopedThreadRef } from "@t3tools/contracts";
+
+import type { ToolCallDiffTarget } from "../diffPanelStore";
+import { useRightPanelStore } from "../rightPanelStore";
+
+/** Whether an event started in the side chat pane (`data-side-chat-pane`), which the main chat ignores. */
+export function isInSideChatPane(target: EventTarget | null): boolean {
+ return target instanceof Element && target.closest("[data-side-chat-pane]") !== null;
+}
+
+/**
+ * Opens an embedded side chat's tool diffs in its parent's diff panel and runs its shell commands
+ * in the parent's terminal drawer, since the embedded view hides its own. Other panel actions reach
+ * the parent through `hostRightPanel`; diff selection and the drawer are not panel state.
+ */
+export interface SideChatParentPanel {
+ readonly openToolDiff: (target: ToolCallDiffTarget) => void;
+ readonly runShellCommand: (command: string) => void;
+}
+
+/** Opens `thread` in its tab when it is a side chat of `parentRef`; false for any other thread. */
+export function openSideChatTab(
+ parentRef: ScopedThreadRef,
+ thread: Pick | null,
+): boolean {
+ if (thread?.lineage.sideChat !== true || thread.lineage.parentThreadId !== parentRef.threadId) {
+ return false;
+ }
+ useRightPanelStore.getState().openSideChat(parentRef, thread.id);
+ return true;
+}
diff --git a/apps/web/src/reopenClosedView.test.ts b/apps/web/src/reopenClosedView.test.ts
index 3e9cf1abd9324..0acd30cca59b4 100644
--- a/apps/web/src/reopenClosedView.test.ts
+++ b/apps/web/src/reopenClosedView.test.ts
@@ -101,6 +101,7 @@ describe("planNextReopen", () => {
environmentKnown: true,
catalogReady: true,
ownerExists: true,
+ sideChatExists: true,
shellLive: true,
panel: { isOpen: false, activeSurfaceId: null, surfaces: [] },
};
@@ -130,6 +131,18 @@ describe("planNextReopen", () => {
[],
"b",
],
+ [
+ "skips but keeps a side chat deleted from a live shell",
+ [{ ...known, sideChatExists: false }, known],
+ [],
+ "b",
+ ],
+ [
+ "waits when a cached shell lacks the side chat",
+ [{ ...known, sideChatExists: false, shellLive: false }, known],
+ [],
+ null,
+ ],
[
"waits when a cached shell lacks the thread",
[{ ...known, ownerExists: false, shellLive: false }, known],
diff --git a/apps/web/src/reopenClosedView.ts b/apps/web/src/reopenClosedView.ts
index 7b2cd978b5739..63eb3b4eb2184 100644
--- a/apps/web/src/reopenClosedView.ts
+++ b/apps/web/src/reopenClosedView.ts
@@ -12,6 +12,8 @@ export interface ReopenOwnerState {
environmentKnown: boolean;
catalogReady: boolean;
ownerExists: boolean;
+ /** False only for a side chat tab whose thread is gone from the shell. */
+ sideChatExists: boolean;
shellLive: boolean;
panel: ThreadRightPanelState;
}
@@ -28,7 +30,7 @@ export function planNextReopen(
drop.push(entry);
continue;
}
- if (!owner.ownerExists) {
+ if (!owner.ownerExists || !owner.sideChatExists) {
if (owner.shellLive) continue;
break;
}
@@ -113,6 +115,9 @@ export async function reopenClosedView(
case "pull-request":
panels.openPullRequest(ref, surface);
break;
+ case "side-chat":
+ panels.openSideChat(ref, surface.threadId);
+ break;
default:
panels.open(ref, surface.kind);
}
diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts
index 03bcf9025c88d..ca8fb98bfd5f0 100644
--- a/apps/web/src/rightPanelStore.test.ts
+++ b/apps/web/src/rightPanelStore.test.ts
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";
import { useClosedViewStore } from "./closedViewStore";
import { reopenClosedView } from "./reopenClosedView";
import {
+ hostRightPanel,
migratePersistedRightPanelState,
pullRequestSurface,
pullRequestSurfaceId,
@@ -39,6 +40,23 @@ async function reloadRightPanelStore() {
}
describe("rightPanelStore", () => {
+ it("opens a hosted side chat's panel actions in its parent's panel until unhosted", () => {
+ const store = useRightPanelStore.getState();
+ const unhost = hostRightPanel(refB, refA);
+ store.openFile(refB, "src/app.ts");
+ expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe(
+ "file:src/app.ts",
+ );
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB).surfaces,
+ ).toEqual([]);
+ unhost();
+ store.openFile(refB, "src/app.ts");
+ expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refB)?.id).toBe(
+ "file:src/app.ts",
+ );
+ });
+
it("records single and bulk tab closes, newest first", () => {
const store = useRightPanelStore.getState();
const pr = pullRequestSurface({
diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts
index ea043c0298bdb..6d9c692d64564 100644
--- a/apps/web/src/rightPanelStore.ts
+++ b/apps/web/src/rightPanelStore.ts
@@ -21,6 +21,7 @@ const RIGHT_PANEL_KINDS = [
"terminal",
"pull-request",
"pull-requests",
+ "side-chat",
] as const;
export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
@@ -74,7 +75,8 @@ export type RightPanelSurface =
url?: string;
detailTab?: PullRequestDetailTab;
}
- | { id: "pull-requests"; kind: "pull-requests" };
+ | { id: "pull-requests"; kind: "pull-requests" }
+ | { id: `side-chat:${string}`; kind: "side-chat"; threadId: ThreadId };
const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
const RIGHT_PANEL_STORAGE_VERSION = 14;
@@ -121,8 +123,9 @@ interface RightPanelStoreState {
) => boolean;
open: (
ref: ScopedThreadRef,
- kind: Exclude,
+ kind: Exclude,
) => void;
+ openSideChat: (ref: ScopedThreadRef, threadId: ThreadId) => void;
openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void;
renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void;
openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
@@ -180,7 +183,7 @@ interface RightPanelStoreState {
toggleVisibility: (ref: ScopedThreadRef) => void;
toggle: (
ref: ScopedThreadRef,
- kind: Exclude