diff --git a/docs/webui.md b/docs/webui.md index e8330dbf8d..2d76e1ad55 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -116,6 +116,12 @@ topic history or long-term memory: 2. Select the **Temporary chat** control in the page header. 3. Send the first message. +From an existing topic, enter `/side` to open a temporary conversation beside +the topic. The right-hand pane inherits the topic's current context without +adding the side conversation back to the original topic. Run `/side` again to +add another temporary conversation to the right-hand pane, then switch or close +individual side conversations from its tab bar. + You can keep more than one temporary chat open and switch between them under **Temporary chats** in the sidebar while the current WebUI connection remains open. Reloading or closing the page, restarting the gateway, or losing the diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index b27fd18ff6..e9ba5bf3c7 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -837,6 +837,31 @@ async def _dispatch_envelope( temporary=True, ) return + if t == "new_side_chat": + source_id = envelope.get("source_chat_id") + if not _is_valid_chat_id(source_id): + await self._send_event(connection, "error", detail="invalid source_chat_id") + return + if websocket_turn_wall_started_at(source_id) is not None: + await self._send_event(connection, "error", detail="side_chat_unavailable") + return + try: + new_id = self._temporary_chats.create_side( + connection, + source_id, + trusted_webui=connection in self._webui_connections, + ) + except TemporaryChatError as exc: + await self._send_event(connection, "error", detail=exc.detail) + return + self._attach(connection, new_id) + await self._send_event( + connection, + "attached", + chat_id=new_id, + temporary=True, + ) + return if t == "fork_chat": await handle_webui_fork_chat(self, connection, envelope) return diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index e891811160..0e6ba5e4be 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -340,6 +340,72 @@ async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None: assert read_transcript_lines(inbound.session_key) == [] +@pytest.mark.asyncio +async def test_side_chat_inherits_context_without_persisting(bus, tmp_path) -> None: + sessions = SessionManager(tmp_path) + project = tmp_path / "project" + project.mkdir() + source = sessions.get_or_create("websocket:source") + source.metadata[WORKSPACE_SCOPE_METADATA_KEY] = { + "project_path": str(project), + "access_mode": "full", + } + source.add_message("user", "main question") + source.add_message("assistant", "main answer") + sessions.save(source) + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), + ) + connection = AsyncMock() + connection.remote_address = ("127.0.0.1", 5000) + channel._webui_connections.add(connection) + + await channel._dispatch_envelope(connection, "webui-client", { + "type": "new_side_chat", + "source_chat_id": "source", + }) + + attached = json.loads(connection.send.await_args.args[0]) + assert attached["temporary"] is True + side_id = attached["chat_id"] + side_key = f"websocket:{side_id}" + side = sessions.get_cached(side_key) + assert side is not None + assert [message["content"] for message in side.messages] == [ + "main question", + "main answer", + ] + assert side.policy.persist is False + assert side.metadata[WORKSPACE_SCOPE_METADATA_KEY] == { + "project_path": str(project.resolve()), + "access_mode": "restricted", + } + assert sessions.read_session_file(side_key) is None + + connection.send.reset_mock() + await channel._dispatch_envelope(connection, "webui-client", { + "type": "message", + "chat_id": side_id, + "content": "side question", + "turn_id": "side-turn", + "webui": True, + }) + + inbound = bus.publish_inbound.await_args.args[0] + assert inbound.session_key_override == side_key + assert inbound.metadata[WORKSPACE_SCOPE_METADATA_KEY] == { + "project_path": str(project.resolve()), + "access_mode": "restricted", + } + assert sessions.read_session_file(side_key) is None + assert read_transcript_lines(side_key) == [] + assert [payload["event"] for payload in _sent_ws_payloads(connection)] == [ + "message_accepted", + ] + + @pytest.mark.asyncio @pytest.mark.parametrize("content", ["/goal private", "/trigger later", "/dream"]) async def test_temporary_chat_rejects_persistent_commands(bus, tmp_path, content) -> None: diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 5e5c92e26c..f6de6bbf53 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -68,6 +68,12 @@ def as_dict(self) -> dict[str, str | bool]: "square-pen", lifecycle="finalize_active_turn", ), + BuiltinCommandSpec( + "/side", + "Side conversation", + "Start a temporary conversation with the current chat context.", + "messages-square", + ), BuiltinCommandSpec( "/stop", "Stop current task", @@ -999,6 +1005,16 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage: ) +async def cmd_side(ctx: CommandContext) -> OutboundMessage: + """Explain the WebUI-owned side conversation command on other channels.""" + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content="/side is available from an existing WebUI chat.", + metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, + ) + + def build_help_text() -> str: """Build canonical help text shared across channels.""" lines = ["🐈 nanobot commands:"] @@ -1016,6 +1032,7 @@ def register_builtin_commands(router: CommandRouter) -> None: router.priority("/restart", cmd_restart) router.priority("/status", cmd_status) router.exact("/new", cmd_new) + router.exact("/side", cmd_side) router.exact("/status", cmd_status) router.exact("/model", cmd_model) router.prefix("/model ", cmd_model) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index fde8da91b7..e1d614aba1 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -1640,6 +1640,38 @@ def get_or_create_transient( self._remember(session) return session + def fork_transient( + self, + source_key: str, + target_key: str, + *, + disabled_tools: Collection[str] = (), + ) -> Session | None: + """Copy a persisted session into a fresh, non-persistent session.""" + source = self._cached(source_key) or self._load(source_key) + if source is None or not source.policy.persist: + return None + + metadata = deepcopy(source.metadata) + for key in _FORK_VOLATILE_METADATA_KEYS: + metadata.pop(key, None) + now = datetime.now() + target = Session( + key=target_key, + messages=[public_history_message(message) for message in source.messages], + created_at=now, + updated_at=now, + metadata=metadata, + last_consolidated=source.last_consolidated, + policy=SessionPolicy( + persist=False, + log_content=False, + disabled_tools=frozenset(disabled_tools), + ), + ) + self._remember(target) + return target + def _load(self, key: str) -> Session | None: return self._store.load(key) diff --git a/nanobot/webui/temporary_chats.py b/nanobot/webui/temporary_chats.py index 4d40d6a50b..3fa4b07220 100644 --- a/nanobot/webui/temporary_chats.py +++ b/nanobot/webui/temporary_chats.py @@ -13,7 +13,11 @@ InboundMessage, ) from nanobot.bus.queue import MessageBus -from nanobot.security.workspace_access import WorkspaceScope +from nanobot.security.workspace_access import ( + WORKSPACE_SCOPE_METADATA_KEY, + WorkspaceScope, + build_workspace_scope, +) from nanobot.session.manager import Session, SessionManager from nanobot.webui.workspaces import WebUIWorkspaceController @@ -71,6 +75,7 @@ def __init__( # events cannot create a durable transcript after a chat is discarded. self._known_transient_chat_ids: set[str] = set() self._media_paths: dict[str, set[str]] = {} + self._workspace_scopes: dict[str, WorkspaceScope] = {} def _session_key(self, chat_id: str) -> str: return f"{self._channel_name}:{chat_id}" @@ -99,6 +104,45 @@ def create(self, owner: object, *, trusted_webui: bool) -> str: self._owner_chat_ids.setdefault(owner, set()).add(chat_id) self._active_sessions[chat_id] = session self._known_transient_chat_ids.add(chat_id) + self._workspace_scopes[chat_id] = self._workspaces.restricted_default_scope() + return chat_id + + def create_side( + self, + owner: object, + source_chat_id: str, + *, + trusted_webui: bool, + ) -> str: + """Create a temporary fork of an existing persisted WebUI chat.""" + if not trusted_webui: + raise TemporaryChatError("access_denied") + if self._sessions is None: + raise TemporaryChatError("temporary_chat_unavailable") + + chat_id = str(uuid.uuid4()) + session = self._sessions.fork_transient( + self._session_key(source_chat_id), + self._session_key(chat_id), + disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS, + ) + if session is None: + raise TemporaryChatError("side_chat_unavailable") + source_scope = self._workspaces.scope_for_session_key( + self._session_key(source_chat_id) + ) + self._owners[chat_id] = owner + self._owner_chat_ids.setdefault(owner, set()).add(chat_id) + self._active_sessions[chat_id] = session + self._known_transient_chat_ids.add(chat_id) + self._workspace_scopes[chat_id] = build_workspace_scope( + source_scope.project_path, + "restricted", + source_channel=self._channel_name, + ) + session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = self._workspace_scopes[ + chat_id + ].metadata() return chat_id def message_policy( @@ -125,7 +169,7 @@ def message_policy( return TemporaryChatMessagePolicy( session_key=self._session_key(chat_id), - workspace_scope=self._workspaces.restricted_default_scope(), + workspace_scope=self._workspace_scopes[chat_id], ) def validate_attach(self, chat_id: str) -> None: @@ -190,6 +234,7 @@ async def discard(self, owner: object, chat_id: str) -> None: session_key = self._session_key(chat_id) self._forget_owner(owner, chat_id) self._active_sessions.pop(chat_id, None) + self._workspace_scopes.pop(chat_id, None) self._discard_media(chat_id) if self._sessions is not None: self._sessions.invalidate(session_key) @@ -216,3 +261,4 @@ def close(self) -> None: self._owner_chat_ids.clear() self._active_sessions.clear() self._known_transient_chat_ids.clear() + self._workspace_scopes.clear() diff --git a/tests/command/test_router_dispatchable.py b/tests/command/test_router_dispatchable.py index 697673ffe6..b56e42fd19 100644 --- a/tests/command/test_router_dispatchable.py +++ b/tests/command/test_router_dispatchable.py @@ -32,6 +32,7 @@ def router(self) -> CommandRouter: def test_exact_commands_match(self, router: CommandRouter) -> None: assert router.is_dispatchable_command("/new") + assert router.is_dispatchable_command("/side") assert router.is_dispatchable_command("/help") assert router.is_dispatchable_command("/model") assert router.is_dispatchable_command("/dream") diff --git a/webui/src/App.tsx b/webui/src/App.tsx index c7a5e942de..2456525d81 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -8,7 +8,7 @@ import { useState, type ReactNode, } from "react"; -import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react"; +import { ArrowLeft, Eye, EyeOff, MessageCirclePlus, Moon, PanelLeft, Plus, ShieldCheck, Sun, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { channelUiPresentation } from "@/channel-plugins/registry"; import { Sidebar } from "@/components/Sidebar"; @@ -122,6 +122,13 @@ type ShellRoute = { settingsSection: SettingsSectionKey; temporary?: boolean; }; +type SideChatEntry = { key: string; ordinal: number }; +type SidePaneState = { + sourceKey: string; + chats: SideChatEntry[]; + activeKey: string; + nextOrdinal: number; +}; const loadSettingsView = () => import("@/components/settings/SettingsView"); const SettingsView = lazy(async () => { const module = await loadSettingsView(); @@ -1050,6 +1057,10 @@ function Shell({ ); const [view, setView] = useState(initialRouteRef.current.view); const [temporarySessions, setTemporarySessions] = useState>({}); + const [sidePane, setSidePane] = useState(null); + const [sidePaneActive, setSidePaneActive] = useState(false); + const [sidePaneSplitRatios, setSidePaneSplitRatios] = useState([0.5]); + const creatingSideChatRef = useRef(false); const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState(initialRouteRef.current.settingsSection); @@ -1122,14 +1133,16 @@ function Shell({ const temporaryChatActive = view === "chat" && temporaryChatId !== null; const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled; const temporarySessionList = useMemo( - () => Object.values(temporarySessions).sort((a, b) => ( + () => Object.values(temporarySessions) + .filter((session) => !sidePane?.chats.some((chat) => chat.key === session.key)) + .sort((a, b) => ( Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "") )), - [temporarySessions], + [sidePane?.chats, temporarySessions], ); const temporaryChatIds = useMemo( - () => temporarySessionList.map((session) => session.chatId), - [temporarySessionList], + () => Object.values(temporarySessions).map((session) => session.chatId), + [temporarySessions], ); const navigate = useCallback( @@ -1619,6 +1632,99 @@ function Shell({ [client, navigate], ); + const closeSidePane = useCallback(() => { + if (!sidePane) return; + const sideKeys = new Set(sidePane.chats.map((chat) => chat.key)); + for (const key of sideKeys) { + const session = temporarySessionsRef.current[key]; + if (session) client.discardTemporaryChat(session.chatId); + } + setTemporarySessions((current) => { + const entries = Object.entries(current).filter(([key]) => !sideKeys.has(key)); + return entries.length === Object.keys(current).length ? current : Object.fromEntries(entries); + }); + setSidePane(null); + setSidePaneActive(false); + setSidePaneSplitRatios([0.5]); + }, [client, sidePane]); + + const closeSideChat = useCallback((sideKey: string) => { + if (!sidePane) return; + const index = sidePane.chats.findIndex((chat) => chat.key === sideKey); + if (index < 0) return; + const session = temporarySessionsRef.current[sideKey]; + if (session) client.discardTemporaryChat(session.chatId); + setTemporarySessions((current) => { + if (!current[sideKey]) return current; + const next = { ...current }; + delete next[sideKey]; + return next; + }); + const chats = sidePane.chats.filter((chat) => chat.key !== sideKey); + if (chats.length === 0) { + setSidePane(null); + setSidePaneActive(false); + setSidePaneSplitRatios([0.5]); + return; + } + const activeKey = sidePane.activeKey === sideKey + ? (chats[index] ?? chats[index - 1]).key + : sidePane.activeKey; + setSidePane({ ...sidePane, chats, activeKey }); + }, [client, sidePane]); + + const onCreateSideChat = useCallback(async (sourceChatId: string) => { + if ( + !activeKey + || temporarySessionsRef.current[activeKey] + || (sidePane && sidePane.sourceKey !== activeKey) + || creatingSideChatRef.current + ) return null; + creatingSideChatRef.current = true; + try { + const chatId = await client.newSideChat(sourceChatId); + const ordinal = sidePane?.nextOrdinal ?? 1; + const session: ChatSummary = { + ...createTemporaryChatSession(chatId), + preview: ordinal === 1 + ? t("temporaryChat.sideTitle") + : t("temporaryChat.sideTitleNumbered", { number: ordinal }), + ...(activeWorkspaceScope ? { + workspaceScope: normalizeWorkspaceScope( + scopeWithAccessMode(activeWorkspaceScope, "restricted"), + ), + } : {}), + }; + setTemporarySessions((current) => ({ ...current, [session.key]: session })); + setSidePane((current) => current + ? { + ...current, + chats: [...current.chats, { key: session.key, ordinal }], + activeKey: session.key, + nextOrdinal: ordinal + 1, + } + : { + sourceKey: activeKey, + chats: [{ key: session.key, ordinal }], + activeKey: session.key, + nextOrdinal: 2, + }); + setSidePaneActive(true); + if (!sidePane) setSidePaneSplitRatios([0.5]); + setWorkspaceError(null); + return chatId; + } catch (error) { + console.error("Failed to create side conversation", error); + return null; + } finally { + creatingSideChatRef.current = false; + } + }, [activeKey, activeWorkspaceScope, client, sidePane, t]); + + useEffect(() => { + if (sidePane && activeKey !== sidePane.sourceKey) closeSidePane(); + }, [activeKey, closeSidePane, sidePane]); + const onForkChat = useCallback(async ( sourceChatId: string, beforeUserIndex: number, @@ -2099,6 +2205,9 @@ function Shell({ if (Object.keys(temporarySessionsRef.current).length === 0) return; temporarySessionsRef.current = {}; setTemporarySessions({}); + setSidePane(null); + setSidePaneActive(false); + setSidePaneSplitRatios([0.5]); if (readShellRoute().temporary) { navigate(defaultShellRoute(), { replace: true }); } @@ -2331,6 +2440,17 @@ function Shell({ .map((key) => byKey.get(key)) .filter((session): session is ChatSummary => session !== undefined); }, [activeTabKey, activeTabState, orderedWorkbenchTabsByKey, sessions]); + const sideChatSessions = useMemo(() => sidePane?.chats + .map((chat) => { + const session = temporarySessions[chat.key]; + return session ? { ...chat, session } : null; + }) + .filter((chat): chat is SideChatEntry & { session: ChatSummary } => chat !== null) ?? [], + [sidePane?.chats, temporarySessions]); + const activeSideChat = sideChatSessions.find((chat) => chat.key === sidePane?.activeKey) + ?? sideChatSessions[0] + ?? null; + const sidePaneWorkbenchKey = sidePane ? `side-pane:${sidePane.sourceKey}` : null; const paneChromeEnabled = Boolean( activeKey && activeSession && !temporaryChatActive && activeTabState, ); @@ -2339,6 +2459,16 @@ function Shell({ && (activeTabState.explicit || activeTabState.paneKeys.length > 1), ); const renderedWorkbenchPanes = useMemo(() => { + if (activeSideChat && activeSession && sidePaneWorkbenchKey) { + return [ + { key: activeSession.key, reactKey: "tab-root", title: headerTitle }, + { + key: sidePaneWorkbenchKey, + reactKey: sidePaneWorkbenchKey, + title: activeSideChat.session.preview, + }, + ]; + } if (paneChromeEnabled) { return workbenchPaneSessions.map((session) => ({ key: session.key, @@ -2358,14 +2488,25 @@ function Shell({ activeTabState?.paneKeys, headerTitle, paneChromeEnabled, + activeSideChat, + activeSession, + sidePane?.sourceKey, + sidePaneWorkbenchKey, + t, titleForSession, workbenchPaneSessions, ]); - const renderedActivePaneKey = activeKey ?? renderedWorkbenchPanes[0].key; - const renderedWorkbenchLayout = paneChromeEnabled && activeTabState + const renderedActivePaneKey = activeSideChat && sidePaneActive && sidePaneWorkbenchKey + ? sidePaneWorkbenchKey + : activeKey ?? renderedWorkbenchPanes[0].key; + const renderedWorkbenchLayout = activeSideChat + ? "columns" + : paneChromeEnabled && activeTabState ? activeTabState.layout : "columns"; - const renderedWorkbenchSplitRatios = paneChromeEnabled && activeTabState + const renderedWorkbenchSplitRatios = activeSideChat + ? sidePaneSplitRatios + : paneChromeEnabled && activeTabState ? activeTabState.splitRatios : []; const sidebarPaneGroups = useMemo(() => { @@ -2713,35 +2854,246 @@ function Shell({ activePaneKey={renderedActivePaneKey} layout={renderedWorkbenchLayout} splitRatios={renderedWorkbenchSplitRatios} - chrome={paneChromeEnabled} - showLayoutControl={activeTabVisible} + chrome={activeSideChat ? !mobileWorkbench : paneChromeEnabled} + showLayoutControl={!activeSideChat && activeTabVisible} + allowPaneReorder={!activeSideChat} + retainCompactPanes={Boolean(activeSideChat)} addPaneDisabled={creatingPane || activePaneLimitReached} addPaneDisabledLabel={activePaneLimitReached ? t("workbench.paneLimit", { count: MAX_WORKBENCH_PANES, }) : undefined} - onActivatePane={onActivateWorkbenchPane} + onActivatePane={(paneKey) => { + if (activeSideChat && sidePaneWorkbenchKey) { + setSidePaneActive(paneKey === sidePaneWorkbenchKey); + return; + } + onActivateWorkbenchPane(paneKey); + }} onAddPane={onAddPane} onLayoutChange={(layout) => { + if (activeSideChat) return; if (!activeTabKey) return; updateWorkbenchState((current) => ( setWorkbenchLayout(current, activeTabKey, layout) )); }} onPaneOrderChange={(paneKeys) => { + if (activeSideChat) return; if (!activeTabKey) return; updateWorkbenchState((current) => ( setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys) )); }} onSplitRatiosChange={(splitRatios) => { + if (activeSideChat) { + setSidePaneSplitRatios(splitRatios); + return; + } if (!activeTabKey) return; updateWorkbenchState((current) => ( setWorkbenchSplitRatios(current, activeTabKey, splitRatios) )); }} renderPane={(pane, context) => { + if (activeSideChat && sidePaneWorkbenchKey) { + if (!activeSession) return null; + const sourceChatId = activeSession.chatId; + if (pane.key === sidePaneWorkbenchKey) { + return ( +
+
{ + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + const currentIndex = sideChatSessions.findIndex( + (chat) => chat.key === activeSideChat.key, + ); + const offset = event.key === "ArrowRight" ? 1 : -1; + const nextIndex = ( + currentIndex + offset + sideChatSessions.length + ) % sideChatSessions.length; + const next = sideChatSessions[nextIndex]; + if (!next) return; + event.preventDefault(); + setSidePane((current) => current + ? { ...current, activeKey: next.key } + : current); + document.getElementById(`side-tab-${next.key}`)?.focus(); + }} + className="flex h-10 shrink-0 items-end gap-1 overflow-x-auto border-b border-border/55 px-2 pt-1" + > + {mobileWorkbench ? ( + + ) : null} + {sideChatSessions.map((chat) => { + const title = chat.session.preview; + const selected = chat.key === activeSideChat.key; + return ( +
+ + +
+ ); + })} + +
+
+ {sideChatSessions.map((chat) => { + const selected = chat.key === activeSideChat.key; + return ( + + ); + })} +
+
+ ); + } + return ( + setSidePaneActive(true)} + className="host-no-drag inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground hover:bg-accent/45 hover:text-foreground" + > + + + ) : undefined} + headerPortalTarget={context.headerPortalTarget} + headerActive + composerActive={context.active} + composerInputAriaLabel={t("workbench.composerAria", { + defaultValue: "Message {{title}}", + title: pane.title, + })} + emptyComposerVariant="thread" + dockEmptyComposer + composerPortalTarget={undefined} + workspaceScope={activeWorkspaceScope} + workspaceDefaultScope={workspaces?.default_scope ?? null} + workspaceControls={workspaces?.controls ?? null} + workspaceScopeDisabled={runningChatIds.has(activeSession.chatId)} + workspaceError={context.active ? workspaceError : null} + onWorkspaceScopeChange={applyWorkspaceScope} + settingsSnapshot={settingsSnapshot} + onOpenModelSettings={onOpenModelSettings} + skills={skills} + /> + ); + } if (!paneChromeEnabled) { return ( void refresh()} theme={theme} onToggleTheme={toggle} diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index ef563a54af..a869b383d2 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -309,6 +309,7 @@ interface ThreadShellProps { initialMessage?: string, ) => Promise; onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise; + onCreateSideChat?: (sourceChatId: string) => Promise; onTurnEnd?: () => void; theme?: "light" | "dark"; onToggleTheme?: () => void; @@ -325,6 +326,8 @@ interface ThreadShellProps { composerActive?: boolean; composerInputAriaLabel?: string; emptyComposerVariant?: "hero" | "thread"; + emptyStateOverride?: ReactNode; + dockEmptyComposer?: boolean; workspaceScope?: WorkspaceScopePayload | null; workspaceDefaultScope?: WorkspaceScopePayload | null; workspaceControls?: WorkspacesPayload["controls"] | null; @@ -608,6 +611,7 @@ export function ThreadShell({ onToggleSidebar, onCreateChat, onForkChat, + onCreateSideChat, onTurnEnd, theme = "light", onToggleTheme = () => {}, @@ -624,6 +628,8 @@ export function ThreadShell({ composerActive = true, composerInputAriaLabel, emptyComposerVariant = "hero", + emptyStateOverride, + dockEmptyComposer = false, workspaceScope = null, workspaceDefaultScope = null, workspaceControls = null, @@ -904,10 +910,11 @@ export function ThreadShell({ [settings], ); const availableSlashCommands = useMemo( - () => temporary - ? slashCommands.filter(({ command }) => command === "/model" || command === "/stop") - : slashCommands, - [slashCommands, temporary], + () => slashCommands.filter(({ command }) => { + if (temporary) return command === "/model" || command === "/stop"; + return command !== "/side" || session !== null; + }), + [session, slashCommands, temporary], ); const modelBadge = useMemo( () => toModelBadgeInfo(modelName, settings, activeModelPreset), @@ -1321,6 +1328,14 @@ export function ThreadShell({ const handleThreadSend = useCallback( (content: string, images?: SendAttachment[], options?: SendOptions) => { + if ( + content.trim().toLowerCase() === "/side" + && options?.sideChannel + && !images?.length + ) { + if (!chatId || !onCreateSideChat || turnActive) return false; + return onCreateSideChat(chatId).then((sideChatId) => Boolean(sideChatId)); + } setFallbackModelName(null); const submitted = send(content, images, withWorkspaceScope(options)); if ( @@ -1333,7 +1348,7 @@ export function ThreadShell({ setSubmittedViewportTurnId(submitted.turnId); } }, - [chatId, send, withWorkspaceScope], + [chatId, onCreateSideChat, send, turnActive, withWorkspaceScope], ); const handleOpenFilePreview = useCallback((path: string) => { @@ -1543,9 +1558,11 @@ export function ThreadShell({ {t("thread.loadingConversation")} ) : ( -
- -
+ emptyStateOverride ?? ( +
+ +
+ ) ); const sessionInfoAction = historyKey ? ( @@ -1595,6 +1612,7 @@ export function ThreadShell({ runStartedAt={currentRunStartedAt} emptyState={emptyState} composer={composerPortalTarget === undefined ? composer : null} + dockEmptyComposer={dockEmptyComposer} activeTurnId={viewportTurnId} activeTurnStartedHere={activeTurnStartedHere} conversationKey={historyKey} diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 90a85842e4..60f7a49b37 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -41,6 +41,7 @@ interface ThreadViewportProps { runStartedAt?: number | null; composer?: ReactNode; emptyState?: ReactNode; + dockEmptyComposer?: boolean; scrollToBottomSignal?: number; activeTurnId?: string | null; activeTurnStartedHere?: boolean; @@ -176,6 +177,7 @@ export const ThreadViewport = forwardRef {emptyState} diff --git a/webui/src/components/workbench/PaneWorkbench.tsx b/webui/src/components/workbench/PaneWorkbench.tsx index 099ec6cc5a..9084820a16 100644 --- a/webui/src/components/workbench/PaneWorkbench.tsx +++ b/webui/src/components/workbench/PaneWorkbench.tsx @@ -67,6 +67,8 @@ interface PaneWorkbenchProps { layout: WorkbenchLayout; chrome?: boolean; showLayoutControl: boolean; + allowPaneReorder?: boolean; + retainCompactPanes?: boolean; addPaneDisabled?: boolean; addPaneDisabledLabel?: string; onActivatePane: (key: string) => void; @@ -202,6 +204,8 @@ export function PaneWorkbench({ layout, chrome = true, showLayoutControl, + allowPaneReorder = true, + retainCompactPanes = false, addPaneDisabled = false, addPaneDisabledLabel, onActivatePane, @@ -259,11 +263,11 @@ export function PaneWorkbench({ ]; }, [panes, previewPaneKeys]); const displayedPanes = useMemo(() => { - if (!compact) return orderedPanes; + if (!compact || retainCompactPanes) return orderedPanes; const activePane = orderedPanes.find((pane) => pane.key === activePaneKey) ?? orderedPanes[0]; return activePane ? [activePane] : []; - }, [activePaneKey, compact, orderedPanes]); + }, [activePaneKey, compact, orderedPanes, retainCompactPanes]); const paneOrder = displayedPanes.map((pane) => pane.key).join("\u0000"); useEffect(() => { @@ -718,12 +722,17 @@ export function PaneWorkbench({ else paneRefs.current.delete(pane.key); }} aria-label={pane.title} + hidden={compact && retainCompactPanes && !active} data-active={active ? "true" : "false"} data-dragging={draggingPaneKey === pane.key ? "true" : undefined} data-testid={`workbench-pane-${pane.key}`} onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)} onFocusCapture={(event) => handlePaneFocus(pane.key, event)} - className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background" + className={cn( + "workbench-pane relative min-h-0 min-w-0 overflow-hidden bg-background", + compact && retainCompactPanes && !active ? "hidden" : "flex", + compact && retainCompactPanes && "absolute inset-0", + )} style={layoutGeometry.paneStyles[index]} > {renderPane(pane, { @@ -732,7 +741,7 @@ export function PaneWorkbench({ composerPortalTarget: chrome ? composerPortalTarget : undefined, headerActions, })} - {chrome && panes.length > 1 && !compact ? ( + {chrome && allowPaneReorder && panes.length > 1 && !compact ? (