diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index b0751ca554..df6c3398cb 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -10116,6 +10116,7 @@ pub async fn acp_connect( db: State<'_, AppDatabase>, app_handle: tauri::AppHandle, window: tauri::WebviewWindow, + conversation_windows: State<'_, crate::commands::windows::ConversationWindowState>, ) -> Result { // Resolve through the effective data dir so a custom `CODEG_DATA_DIR` // reaches the credential helper script the agent's git subprocess @@ -10136,13 +10137,17 @@ pub async fn acp_connect( verify_agent_installed(agent_type).await?; let emitter = EventEmitter::Tauri(app_handle); + // Not `window.label()` directly: a conversation window is a view the user + // can close mid-turn, so the connection it starts is owned by the workspace + // that opened it. Every other window resolves to itself. + let owner_window_label = conversation_windows.resolve_owner_window(window.label()); manager .spawn_agent( agent_type, working_dir, session_id, runtime_env, - window.label().to_string(), + owner_window_label, emitter, preferred_mode_id, preferred_config_values.unwrap_or_default(), diff --git a/src-tauri/src/commands/terminal.rs b/src-tauri/src/commands/terminal.rs index c8a16178de..ded850a7e1 100644 --- a/src-tauri/src/commands/terminal.rs +++ b/src-tauri/src/commands/terminal.rs @@ -64,6 +64,7 @@ pub(crate) fn prepare_credential_env( #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] +#[allow(clippy::too_many_arguments)] pub async fn terminal_spawn( working_dir: String, shell: Option, @@ -72,6 +73,7 @@ pub async fn terminal_spawn( manager: State<'_, TerminalManager>, app_handle: tauri::AppHandle, window: tauri::WebviewWindow, + conversation_windows: State<'_, crate::commands::windows::ConversationWindowState>, ) -> Result { let terminal_id = terminal_id .filter(|id| !id.is_empty() && id.len() <= 256) @@ -94,7 +96,10 @@ pub async fn terminal_spawn( SpawnOptions { terminal_id, working_dir, - owner_window_label: window.label().to_string(), + // A conversation window's shells belong to the workspace that + // opened it, so closing the view doesn't strand them (only `main` + // is swept by owner on close). See `resolve_owner_window`. + owner_window_label: conversation_windows.resolve_owner_window(window.label()), shell, initial_command, extra_env, diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 904a8e34c3..41015938e4 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -1222,6 +1222,184 @@ pub async fn open_project_boot_window( Ok(()) } +// ─── Conversation windows ─────────────────────────────────────────────── +// +// One window per conversation, so a session can sit on a second monitor next +// to the workspace instead of only in a split group. The window loads the +// same `/workspace` route (query parameters, not a dynamic route — the +// frontend is a static export) and the tab store treats +// `?conversationWindow=1` as a detached view: it seeds the one tab it was +// opened for and never reads, writes or mirrors the shared `opened_tabs` +// (see `src/lib/conversation-window.ts`). + +/// Owner tracking for the per-conversation windows. Two things ride on it. +/// +/// Closing a conversation window hands focus back to the workspace it was +/// opened from, for the same reason the settings / commit / merge windows do: +/// the workspace close button *hides* `main` to the tray, so an auxiliary +/// window can end up being the only thing on screen. +/// +/// And `acp_connect` / `terminal_spawn` resolve their owner label through it, +/// so work started from a conversation window belongs to that workspace rather +/// than to the window looking at it — see [`Self::resolve_owner_window`]. +pub struct ConversationWindowState { + owner_by_conversation_label: Mutex>, +} + +impl ConversationWindowState { + pub fn new() -> Self { + Self { + owner_by_conversation_label: Mutex::new(HashMap::new()), + } + } + + fn set_owner(&self, conversation_label: String, owner_label: String) { + if let Ok(mut owners) = self.owner_by_conversation_label.lock() { + owners.insert(conversation_label, owner_label); + } + } + + fn take_owner(&self, conversation_label: &str) -> Option { + self.owner_by_conversation_label + .lock() + .ok() + .and_then(|mut owners| owners.remove(conversation_label)) + } + + /// Which window should own an agent connection or a terminal a webview + /// starts: a conversation window's opener, and every other window itself. + /// + /// A conversation window is a VIEW, and the user can close it while the + /// agent is mid-turn. Nothing sweeps by owner when a non-`main` window + /// closes (`lib.rs` runs `disconnect_by_owner_window` only for `main`, and + /// only on a real quit), so a connection owned by the view would outlive + /// its window with nothing left to reach it. Owned by the workspace it was + /// opened from, it stays reachable from the tab that is still open there, + /// and it is swept on quit like any other session — which is also what + /// makes closing the window safe while the conversation keeps running. + pub fn resolve_owner_window(&self, window_label: &str) -> String { + self.owner_by_conversation_label + .lock() + .ok() + .and_then(|owners| owners.get(window_label).cloned()) + .unwrap_or_else(|| window_label.to_string()) + } +} + +impl Default for ConversationWindowState { + fn default() -> Self { + Self::new() + } +} + +/// Window label for a conversation, keyed by its (backend-unique) conversation +/// id. Re-invoking "Open in New Window" on the same conversation therefore +/// focuses the window that already exists instead of stacking duplicates. +pub(crate) fn conversation_window_label( + conversation_id: i32, + remote_connection_id: Option, +) -> String { + match remote_connection_id { + Some(remote_id) => format!("remote-conversation-{remote_id}-{conversation_id}"), + None => format!("conversation-{conversation_id}"), + } +} + +/// True for the labels [`conversation_window_label`] produces, and nothing +/// else — `lib.rs` uses it to pick the windows whose close hands the owner +/// back. +pub(crate) fn is_conversation_window_label(label: &str) -> bool { + label.starts_with("conversation-") || label.starts_with("remote-conversation-") +} + +/// The `/workspace` route a conversation window loads. +fn conversation_window_route(folder_id: i32, conversation_id: i32, agent: &str) -> String { + format!( + "workspace?conversationWindow=1&folderId={folder_id}\ + &conversationId={conversation_id}&agent={agent}" + ) +} + +/// Window title. The conversation title comes from the caller's tab, so an +/// untitled conversation simply falls back to the app name. +fn conversation_window_title(title: Option<&str>) -> String { + match title.map(str::trim).filter(|t| !t.is_empty()) { + Some(t) => format!("Codeg - {t}"), + None => "Codeg".to_string(), + } +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +#[allow(clippy::too_many_arguments)] +pub async fn open_conversation_window( + app: AppHandle, + window: tauri::WebviewWindow, + state: tauri::State<'_, ConversationWindowState>, + folder_id: i32, + conversation_id: i32, + agent: crate::models::agent::AgentType, + title: Option, + remote_connection_id: Option, +) -> Result<(), AppCommandError> { + let owner_label = window.label().to_string(); + let label = conversation_window_label(conversation_id, remote_connection_id); + + if let Some(existing) = app.get_webview_window(&label) { + post_window_setup(&existing); + // Re-point the owner: the window is reachable from every workspace, so + // the restore has to follow the one the user last came from. + state.set_owner(label, owner_label); + let _ = existing.unminimize(); + let _ = existing.show(); + existing.set_focus().map_err(|e| { + AppCommandError::window("Failed to focus conversation window", e.to_string()) + })?; + return Ok(()); + } + + let (url_str, remote_window_id) = route_with_new_remote_window( + conversation_window_route(folder_id, conversation_id, &agent.as_wire()), + remote_connection_id, + ); + let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url_str.into())) + .title(conversation_window_title(title.as_deref())) + .inner_size(1100.0, 820.0) + .min_inner_size(400.0, 600.0) + .center(); + // Deliberately NOT `.parent(&window)`: on macOS that attaches the window + // via `addChildWindow`, which would make it move and minimize with the + // workspace — the opposite of what a second-monitor view is for. Focus + // returns to the owner on close via `restore_window_after_conversation`. + let builder = apply_platform_window_style(builder); + // Loads `/workspace`, so it wears the taller h-10 title bar's traffic-light + // position rather than the shorter auxiliary-window default. + #[cfg(target_os = "macos")] + let builder = builder.traffic_light_position(workspace_window_traffic_light_position()); + let conversation_window = builder.build().map_err(|e| { + AppCommandError::window("Failed to open conversation window", e.to_string()) + })?; + register_remote_window_cleanup(&app, &conversation_window, remote_window_id.as_deref()); + post_window_setup(&conversation_window); + state.set_owner(label, owner_label); + conversation_window.set_focus().map_err(|e| { + AppCommandError::window("Failed to focus conversation window", e.to_string()) + })?; + + Ok(()) +} + +/// Hand focus back to the workspace a conversation window was opened from. +pub fn restore_window_after_conversation( + app: &AppHandle, + state: &ConversationWindowState, + conversation_window_label: &str, +) { + if let Some(owner_label) = state.take_owner(conversation_window_label) { + show_and_focus_window(app, &owner_label); + } +} + // ─── Desktop pet window ───────────────────────────────────────────────── const PET_WINDOW_LABEL: &str = "pet"; @@ -2155,7 +2333,7 @@ pub async fn set_tray_locale( #[cfg(test)] mod owner_window_tests { - use super::SettingsWindowState; + use super::{ConversationWindowState, SettingsWindowState}; // `lib.rs` runs the restore on both `CloseRequested` and `Destroyed`, so the // owner has to be handed back exactly once. That mattered less while the @@ -2180,6 +2358,163 @@ mod owner_window_tests { assert_eq!(state.take_owner("settings").as_deref(), Some("remote-workspace-3")); } + + // Same hand-back-once contract for the conversation windows. + #[test] + fn conversation_owner_is_handed_back_once() { + let state = ConversationWindowState::new(); + state.set_owner("conversation-7".to_string(), "main".to_string()); + + assert_eq!(state.take_owner("conversation-7").as_deref(), Some("main")); + assert_eq!(state.take_owner("conversation-7"), None); + } + + // One map for every conversation window, so closing one must leave the + // others' owners alone. + #[test] + fn conversation_owners_are_kept_per_window_label() { + let state = ConversationWindowState::new(); + state.set_owner("conversation-7".to_string(), "main".to_string()); + state.set_owner( + "remote-conversation-3-7".to_string(), + "remote-workspace-3".to_string(), + ); + + assert_eq!(state.take_owner("conversation-7").as_deref(), Some("main")); + assert_eq!( + state.take_owner("remote-conversation-3-7").as_deref(), + Some("remote-workspace-3") + ); + } + + // Re-opening from another workspace re-points the owner: the window is + // reused across workspaces, so the restore has to follow the window the + // user last came from. + #[test] + fn reopening_a_conversation_window_repoints_the_owner() { + let state = ConversationWindowState::new(); + state.set_owner("conversation-7".to_string(), "main".to_string()); + state.set_owner("conversation-7".to_string(), "remote-workspace-3".to_string()); + + assert_eq!( + state.take_owner("conversation-7").as_deref(), + Some("remote-workspace-3") + ); + } + + // Ownership resolution for `acp_connect` / `terminal_spawn`: a conversation + // window hands its work to the workspace it was opened from, and every + // other window owns its own. Registration is what distinguishes them, so an + // unregistered label — including a conversation window whose owner was + // already handed back — resolves to itself rather than to nothing. + #[test] + fn work_started_in_a_conversation_window_is_owned_by_its_workspace() { + let state = ConversationWindowState::new(); + state.set_owner("conversation-7".to_string(), "main".to_string()); + state.set_owner( + "remote-conversation-3-9".to_string(), + "remote-workspace-3".to_string(), + ); + + assert_eq!(state.resolve_owner_window("conversation-7"), "main"); + assert_eq!( + state.resolve_owner_window("remote-conversation-3-9"), + "remote-workspace-3" + ); + for label in ["main", "pet", "settings", "commit-7", "remote-workspace-3"] { + assert_eq!(state.resolve_owner_window(label), label, "{label} owns itself"); + } + } + + // The resolve is a PEEK: `acp_connect` runs many times over a window's life + // and must keep getting the same answer, and the close-time hand-back is + // the only thing allowed to consume the entry. + #[test] + fn resolving_an_owner_does_not_consume_it() { + let state = ConversationWindowState::new(); + state.set_owner("conversation-7".to_string(), "main".to_string()); + + assert_eq!(state.resolve_owner_window("conversation-7"), "main"); + assert_eq!(state.resolve_owner_window("conversation-7"), "main"); + assert_eq!(state.take_owner("conversation-7").as_deref(), Some("main")); + } +} + +#[cfg(test)] +mod conversation_window_tests { + use super::{ + conversation_window_label, conversation_window_route, conversation_window_title, + is_conversation_window_label, + }; + + // The label is keyed by conversation id alone, so a second "Open in New + // Window" on the same conversation finds the window that already exists. + // Remote windows get their own namespace: the ids are per-backend. + #[test] + fn conversation_labels_are_unique_per_conversation_and_backend() { + let local = conversation_window_label(7, None); + assert_eq!(local, "conversation-7"); + // Stable across calls — that is what makes the second invocation find + // the window rather than build another one. + assert_eq!(local, conversation_window_label(7, None)); + assert_ne!( + conversation_window_label(7, None), + conversation_window_label(8, None) + ); + assert_eq!( + conversation_window_label(7, Some(3)), + "remote-conversation-3-7" + ); + assert_ne!( + conversation_window_label(7, Some(3)), + conversation_window_label(7, Some(4)) + ); + } + + // `lib.rs` asks this of EVERY closing window, so it has to recognise the + // conversation windows and nothing else — `remote-commit-3-7` in particular + // must not be mistaken for one. + #[test] + fn only_conversation_windows_are_recognised() { + assert!(is_conversation_window_label(&conversation_window_label(7, None))); + assert!(is_conversation_window_label(&conversation_window_label( + 7, + Some(3) + ))); + for label in [ + "main", + "pet", + "settings", + "commit-7", + "remote-commit-3-7", + "merge-7", + "remote-workspace-3", + "project-boot", + ] { + assert!( + !is_conversation_window_label(label), + "{label} is not a conversation window" + ); + } + } + + // Static export: the target is a query on `/workspace`, never a dynamic + // route. `conversationWindow=1` is what puts the tab store in its detached + // mode, so it must ride along with the identity. + #[test] + fn the_route_carries_the_detached_marker_and_the_identity() { + assert_eq!( + conversation_window_route(4, 7, "claude_code"), + "workspace?conversationWindow=1&folderId=4&conversationId=7&agent=claude_code" + ); + } + + #[test] + fn an_untitled_conversation_falls_back_to_the_app_name() { + assert_eq!(conversation_window_title(Some("Fix the parser")), "Codeg - Fix the parser"); + assert_eq!(conversation_window_title(Some(" ")), "Codeg"); + assert_eq!(conversation_window_title(None), "Codeg"); + } } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1a0688b4d..d615b6b122 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -395,6 +395,7 @@ mod tauri_app { .manage(windows::SettingsWindowState::new()) .manage(windows::CommitWindowState::new()) .manage(windows::MergeWindowState::new()) + .manage(windows::ConversationWindowState::new()) .manage(web::WebServerState::new()) // Remote-workspace IPC proxy. Routes HTTP / WS for windows // opened against a remote codeg-server through Rust so we @@ -1122,6 +1123,22 @@ mod tauri_app { } } + if windows::is_conversation_window_label(&label) + && matches!( + event, + tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed + ) + { + // Focus only. The conversation keeps running: its agent is + // owned by the workspace this window was opened from (see + // `ConversationWindowState::resolve_owner_window`), and the + // tab it is still open in lives there too. + let app = window.app_handle(); + if let Some(state) = app.try_state::() { + windows::restore_window_after_conversation(app, &state, &label); + } + } + if label == windows::PET_PANEL_LABEL && matches!(event, tauri::WindowEvent::Focused(false)) { @@ -1314,6 +1331,7 @@ mod tauri_app { windows::open_push_window, windows::open_project_boot_window, windows::open_import_sessions_window, + windows::open_conversation_window, remote_workspace_commands::list_remote_workspace_connections, remote_workspace_commands::create_remote_workspace_connection, remote_workspace_commands::update_remote_workspace_connection, diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index 3dc7fc8332..278b6536c3 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -8,6 +8,7 @@ import { type FocusEvent, } from "react" import { + AppWindow, AtSign, Pencil, Trash2, @@ -25,6 +26,7 @@ import { import { useTranslations } from "next-intl" import { useImeGuard } from "@/hooks/use-ime-guard" import { useTabStore } from "@/contexts/tab-context" +import { openConversationWindow } from "@/lib/api" import { emitAttachSessionToSession } from "@/lib/session-attachment-events" import type { DbConversationSummary, ConversationStatus } from "@/lib/types" import { STATUS_ORDER } from "@/lib/types" @@ -274,6 +276,27 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ emitAttachSessionToSession({ tabId, conversation }) }, [conversation]) + const handleOpenInNewWindow = useCallback(() => { + void openConversationWindow( + { + folderId: conversation.folder_id, + conversationId: conversation.id, + agentType: conversation.agent_type, + }, + conversation.title + ).catch((err) => { + console.error( + "[SidebarConversationCard] open conversation window failed:", + err + ) + }) + }, [ + conversation.folder_id, + conversation.id, + conversation.agent_type, + conversation.title, + ]) + const handleRenameOpen = useCallback(() => { setRenameValue(conversation.title || "") setRenameOpen(true) @@ -659,6 +682,10 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ {tDetails("menuLabel")} + + + {t("openInNewWindow")} + {/* Mirrors the file tree's "add to session": inserts an `@`-style mention of THIS conversation into the active session's composer. Disabled only when no conversation tab is open — there is no diff --git a/src/components/conversations/sidebar-conversation-open-in-window.test.tsx b/src/components/conversations/sidebar-conversation-open-in-window.test.tsx new file mode 100644 index 0000000000..40ccc7412c --- /dev/null +++ b/src/components/conversations/sidebar-conversation-open-in-window.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { SidebarConversationCard } from "./sidebar-conversation-card" +import { openConversationWindow } from "@/lib/api" +import type { DbConversationSummary } from "@/lib/types" +import enMessages from "@/i18n/messages/en.json" + +vi.mock("@/lib/api", () => ({ + openConversationWindow: vi.fn(async () => {}), +})) + +const conversation: DbConversationSummary = { + id: 7, + folder_id: 4, + title: "Fix the parser", + title_locked: false, + agent_type: "claude_code", + status: "pending", + kind: "regular", + model: null, + git_branch: null, + external_id: null, + message_count: 0, + child_count: 0, + created_at: new Date(0).toISOString(), + updated_at: new Date(0).toISOString(), + pinned_at: null, +} + +const noop = () => {} + +afterEach(cleanup) + +describe("SidebarConversationCard open-in-new-window", () => { + it("opens the conversation it belongs to, titled for the taskbar", () => { + render( + + {}} + onDelete={async () => {}} + onStatusChange={async () => {}} + /> + + ) + + fireEvent.contextMenu(screen.getByText("Fix the parser")) + fireEvent.click( + screen.getByText(enMessages.Folder.conversationCard.openInNewWindow) + ) + + expect(openConversationWindow).toHaveBeenCalledWith( + { folderId: 4, conversationId: 7, agentType: "claude_code" }, + "Fix the parser" + ) + }) +}) diff --git a/src/components/tabs/tab-bar.tsx b/src/components/tabs/tab-bar.tsx index fbdae3482b..1eb94d4a04 100644 --- a/src/components/tabs/tab-bar.tsx +++ b/src/components/tabs/tab-bar.tsx @@ -6,6 +6,7 @@ import type { PanInfo } from "motion/react" import { SquarePen } from "lucide-react" import { useTranslations } from "next-intl" import { cn } from "@/lib/utils" +import { openConversationWindow } from "@/lib/api" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useActiveFolder } from "@/contexts/active-folder-context" import { useTabActions, useTabStore } from "@/contexts/tab-context" @@ -117,6 +118,23 @@ export function TabBar({ groupId }: TabBarProps) { [dissolveGroup, stripGroupId] ) + // The tab stays here: `opened_tabs` is the workspace's own list, and the new + // window is a detached view that never writes to it. Closing the window + // therefore leaves the conversation exactly where it was. + const handleOpenInNewWindow = useCallback((tab: TabItemData) => { + if (tab.conversationId == null) return + void openConversationWindow( + { + folderId: tab.folderId, + conversationId: tab.conversationId, + agentType: tab.agentType, + }, + tab.title + ).catch((err) => { + console.error("[TabBar] open conversation window failed:", err) + }) + }, []) + // ── Cross-group drag & drop (split-group strips only) ──────────────────── // The dragged tab itself is axis-locked to its own strip (Reorder drag="x" + // overflow clipping), so crossing groups is pointer-based: hit-test the @@ -344,6 +362,7 @@ export function TabBar({ groupId }: TabBarProps) { folderBranch={branches.get(tab.folderId) ?? null} isSplit={isSplit} canSplitMove={canSplitMove && !isDraft} + onOpenInNewWindow={isDraft ? undefined : handleOpenInNewWindow} canMoveToGroup={!isDraft} moveTargets={moveTargets} onTabDrag={crossDragEnabled && !isDraft ? handleTabDrag : undefined} diff --git a/src/components/tabs/tab-item-open-in-window.test.tsx b/src/components/tabs/tab-item-open-in-window.test.tsx new file mode 100644 index 0000000000..93ad7b8410 --- /dev/null +++ b/src/components/tabs/tab-item-open-in-window.test.tsx @@ -0,0 +1,86 @@ +import { Reorder } from "motion/react" +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { TabItem } from "./tab-item" +import type { TabItem as TabItemData } from "@/contexts/tab-context" +import enMessages from "@/i18n/messages/en.json" + +const tab: TabItemData = { + id: "conv-4-claude_code-7", + kind: "conversation", + folderId: 4, + conversationId: 7, + agentType: "claude_code", + title: "Fix the parser", + isPinned: true, +} + +const noop = () => {} + +function renderTab(onOpenInNewWindow?: (tab: TabItemData) => void) { + return render( + + + + + + ) +} + +afterEach(cleanup) + +describe("TabItem open-in-new-window", () => { + it("hands the whole tab to the caller, which is what identifies the window", () => { + const onOpenInNewWindow = vi.fn() + renderTab(onOpenInNewWindow) + + fireEvent.contextMenu(screen.getByText("Fix the parser")) + fireEvent.click(screen.getByText(enMessages.Folder.tabs.openInNewWindow)) + + expect(onOpenInNewWindow).toHaveBeenCalledTimes(1) + expect(onOpenInNewWindow).toHaveBeenCalledWith(tab) + }) + + // Drafts pass no handler (see tab-bar): an unsent draft has no conversation + // for a window to show, and an inert menu item reads as broken. + it("is absent when the strip withholds the handler", () => { + renderTab(undefined) + + fireEvent.contextMenu(screen.getByText("Fix the parser")) + + expect( + screen.queryByText(enMessages.Folder.tabs.openInNewWindow) + ).toBeNull() + // The rest of the menu is untouched. + expect(screen.getByText(enMessages.Folder.tabs.splitRight)).toBeTruthy() + expect(screen.getByText(enMessages.Folder.tabs.closeAll)).toBeTruthy() + }) +}) diff --git a/src/components/tabs/tab-item.tsx b/src/components/tabs/tab-item.tsx index ea238aa253..f6764a064b 100644 --- a/src/components/tabs/tab-item.tsx +++ b/src/components/tabs/tab-item.tsx @@ -53,6 +53,9 @@ interface TabItemProps { /** This tab's group has ≥ 2 tabs, so "Split and Move" leaves a non-empty * group behind (moving the only tab would just shift the group). */ canSplitMove: boolean + /** Open this conversation in its own window. Undefined for DRAFTS: an unsent + * draft has no conversation for a window to show. */ + onOpenInNewWindow?: (tab: TabItemData) => void /** This tab may change groups at all. False for DRAFTS: an unsent draft is the * group's own scratch slot (its composer text, folder and agent live with the * group), and every group can spawn one from its own strip. Only the move @@ -99,6 +102,7 @@ export const TabItem = memo(function TabItem({ folderBranch, isSplit, canSplitMove, + onOpenInNewWindow, canMoveToGroup, moveTargets, onTabDrag, @@ -227,6 +231,10 @@ export const TabItem = memo(function TabItem({ () => onSplit(tab.id, "down", true), [onSplit, tab.id] ) + const handleOpenInNewWindow = useCallback( + () => onOpenInNewWindow?.(tab), + [onOpenInNewWindow, tab] + ) const handleMoveToOpposite = useCallback(() => { const target = moveTargets[0] if (target) onMoveToGroup(tab.id, target.groupId) @@ -381,6 +389,13 @@ export const TabItem = memo(function TabItem({ {t("closeOthers")} + {/* Placement group, widest surface first: the new window is the only + item here that leaves this window at all. */} + {onOpenInNewWindow && ( + + {t("openInNewWindow")} + + )} {/* IDEA-style split-group vocabulary. Plain "Split" seeds the new group with a fresh draft (a conversation can't be open in two groups at once), "Split and Move" relocates this tab. */} diff --git a/src/components/tabs/tab-strip-wiring.test.ts b/src/components/tabs/tab-strip-wiring.test.ts index e0ce76c1f7..f525e36f2e 100644 --- a/src/components/tabs/tab-strip-wiring.test.ts +++ b/src/components/tabs/tab-strip-wiring.test.ts @@ -28,6 +28,11 @@ describe("tab strip draft gating", () => { // register a drop target (no ghost, no highlight, no move). expect(tabBar).toMatch(/onTabDrag=\{\s*crossDragEnabled && !isDraft/) expect(tabBar).toMatch(/onTabDragEnd=\{\s*crossDragEnabled && !isDraft/) + // A draft has no conversation for a window to show, so the strip withholds + // the handler entirely rather than offering an inert menu item. + expect(tabBar).toContain( + "onOpenInNewWindow={isDraft ? undefined : handleOpenInNewWindow}" + ) }) it("gates only the move items, so a draft keeps the group-management menu", () => { @@ -38,6 +43,24 @@ describe("tab strip draft gating", () => { }) }) +describe("open in new window wiring", () => { + it("passes the tab's own identity and title to the window opener", () => { + // The window is keyed by conversation id, and the title only names the + // taskbar entry — everything else the view reads back for itself. + expect(tabBar).toMatch( + /openConversationWindow\(\s*\{\s*folderId: tab\.folderId,\s*conversationId: tab\.conversationId,\s*agentType: tab\.agentType,\s*\},\s*tab\.title\s*\)/ + ) + }) + + it("leaves the tab where it is", () => { + // `opened_tabs` is the workspace's own list and the new window is a + // detached view that never writes to it, so there is no close here — a + // removal would have to be pushed to every client and would take the + // conversation out of the workspace for good. + expect(tabBar).not.toMatch(/handleOpenInNewWindow[\s\S]{0,400}closeTab\(/) + }) +}) + describe("tab drag selection guard wiring", () => { it("suppresses text selection for EVERY tab drag, composed with the long-press handlers", () => { // Held on drag start / released on drag end + unmount, so within-group diff --git a/src/components/workspace/deep-link-bootstrap.tsx b/src/components/workspace/deep-link-bootstrap.tsx index 56a3c7a410..a5cd95f886 100644 --- a/src/components/workspace/deep-link-bootstrap.tsx +++ b/src/components/workspace/deep-link-bootstrap.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react" import { toast } from "sonner" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabStore, useTabActions } from "@/contexts/tab-context" +import { conversationWindowTarget } from "@/lib/conversation-window" import type { AgentType } from "@/lib/types" /** @@ -23,6 +24,11 @@ export function DeepLinkBootstrap() { if (typeof window === "undefined") return + // A conversation window carries the same folder/conversation/agent query, + // but the tab store already seeded that tab from it — and the URL has to + // survive, because it is what marks this webview as detached. Leave it be. + if (conversationWindowTarget()) return + const params = new URLSearchParams(window.location.search) const rawFolderId = params.get("folderId") const rawConversationId = params.get("conversationId") diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 66c1e78eca..1d9ccc6ea5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1960,6 +1960,7 @@ "untitledConversation": "محادثة بدون عنوان", "newConversation": "محادثة جديدة", "attachToCurrentSession": "إضافة إلى الجلسة", + "openInNewWindow": "فتح في نافذة جديدة", "rename": "إعادة تسمية", "status": "الحالة", "delete": "حذف", @@ -2119,6 +2120,7 @@ "closeConversationTab": "إغلاق تبويب المحادثة", "close": "إغلاق", "closeOthers": "إغلاق البقية", + "openInNewWindow": "فتح في نافذة جديدة", "splitRight": "تقسيم لليمين", "splitDown": "تقسيم للأسفل", "splitAndMoveRight": "تقسيم ونقل لليمين", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 791fd785d6..24e853ddfe 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1960,6 +1960,7 @@ "untitledConversation": "Unbenannte Konversation", "newConversation": "Neue Konversation", "attachToCurrentSession": "Zur Sitzung hinzufügen", + "openInNewWindow": "In neuem Fenster öffnen", "rename": "Umbenennen", "status": "Zustand", "delete": "Löschen", @@ -2119,6 +2120,7 @@ "closeConversationTab": "Konversationstab schließen", "close": "Schließen", "closeOthers": "Andere schließen", + "openInNewWindow": "In neuem Fenster öffnen", "splitRight": "Nach rechts teilen", "splitDown": "Nach unten teilen", "splitAndMoveRight": "Teilen und nach rechts verschieben", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ecab7562ac..0dff22d823 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1960,6 +1960,7 @@ "untitledConversation": "Untitled conversation", "newConversation": "New Conversation", "attachToCurrentSession": "Add to session", + "openInNewWindow": "Open in new window", "rename": "Rename", "status": "Status", "delete": "Delete", @@ -2119,6 +2120,7 @@ "closeConversationTab": "Close conversation tab", "close": "Close", "closeOthers": "Close Others", + "openInNewWindow": "Open in New Window", "splitRight": "Split Right", "splitDown": "Split Down", "splitAndMoveRight": "Split and Move Right", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index db03d9122f..1eec902dd8 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1960,6 +1960,7 @@ "untitledConversation": "Conversación sin título", "newConversation": "Nueva conversación", "attachToCurrentSession": "Agregar a la sesión", + "openInNewWindow": "Abrir en nueva ventana", "rename": "Renombrar", "status": "Estado", "delete": "Eliminar", @@ -2119,6 +2120,7 @@ "closeConversationTab": "Cerrar pestaña de conversación", "close": "Cerrar", "closeOthers": "Cerrar otros", + "openInNewWindow": "Abrir en nueva ventana", "splitRight": "Dividir a la derecha", "splitDown": "Dividir abajo", "splitAndMoveRight": "Dividir y mover a la derecha", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1a4d49f6e2..30b3865849 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1960,6 +1960,7 @@ "untitledConversation": "Conversation sans titre", "newConversation": "Nouvelle conversation", "attachToCurrentSession": "Ajouter à la session", + "openInNewWindow": "Ouvrir dans une nouvelle fenêtre", "rename": "Renommer", "status": "Statut", "delete": "Supprimer", @@ -2119,6 +2120,7 @@ "closeConversationTab": "Fermer l’onglet de conversation", "close": "Fermer", "closeOthers": "Fermer les autres", + "openInNewWindow": "Ouvrir dans une nouvelle fenêtre", "splitRight": "Diviser à droite", "splitDown": "Diviser en bas", "splitAndMoveRight": "Diviser et déplacer à droite", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fbe7afddbf..74e0625338 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1960,6 +1960,7 @@ "untitledConversation": "無題の会話", "newConversation": "新しい会話", "attachToCurrentSession": "セッションに追加", + "openInNewWindow": "新しいウィンドウで開く", "rename": "名前を変更", "status": "ステータス", "delete": "削除", @@ -2119,6 +2120,7 @@ "closeConversationTab": "会話タブを閉じる", "close": "閉じる", "closeOthers": "他を閉じる", + "openInNewWindow": "新しいウィンドウで開く", "splitRight": "右に分割", "splitDown": "下に分割", "splitAndMoveRight": "分割して右へ移動", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index f23e38116f..a923def797 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1960,6 +1960,7 @@ "untitledConversation": "제목 없는 대화", "newConversation": "새 대화", "attachToCurrentSession": "세션에 추가", + "openInNewWindow": "새 창에서 열기", "rename": "이름 변경", "status": "상태", "delete": "삭제", @@ -2119,6 +2120,7 @@ "closeConversationTab": "대화 탭 닫기", "close": "닫기", "closeOthers": "다른 항목 닫기", + "openInNewWindow": "새 창에서 열기", "splitRight": "오른쪽으로 분할", "splitDown": "아래로 분할", "splitAndMoveRight": "분할하고 오른쪽으로 이동", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index d334c38757..bd5261fcd6 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1960,6 +1960,7 @@ "untitledConversation": "Conversa sem título", "newConversation": "Nova conversa", "attachToCurrentSession": "Adicionar à sessão", + "openInNewWindow": "Abrir em nova janela", "rename": "Renomear", "status": "Status", "delete": "Excluir", @@ -2119,6 +2120,7 @@ "closeConversationTab": "Fechar aba de conversa", "close": "Fechar", "closeOthers": "Fechar outros", + "openInNewWindow": "Abrir em nova janela", "splitRight": "Dividir à direita", "splitDown": "Dividir abaixo", "splitAndMoveRight": "Dividir e mover para a direita", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index e42916daf2..3b0e91f5aa 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1960,6 +1960,7 @@ "untitledConversation": "未命名会话", "newConversation": "新建会话", "attachToCurrentSession": "添加到会话", + "openInNewWindow": "在新窗口打开", "rename": "重命名", "status": "状态", "delete": "删除", @@ -2119,6 +2120,7 @@ "closeConversationTab": "关闭会话标签", "close": "关闭", "closeOthers": "关闭其它", + "openInNewWindow": "在新窗口打开", "splitRight": "向右拆分", "splitDown": "向下拆分", "splitAndMoveRight": "拆分并右移", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d58afc27ba..2d1dcf3784 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1960,6 +1960,7 @@ "untitledConversation": "未命名會話", "newConversation": "新增會話", "attachToCurrentSession": "添加到會話", + "openInNewWindow": "在新視窗打開", "rename": "重新命名", "status": "狀態", "delete": "刪除", @@ -2119,6 +2120,7 @@ "closeConversationTab": "關閉會話分頁", "close": "關閉", "closeOthers": "關閉其它", + "openInNewWindow": "在新視窗打開", "splitRight": "向右拆分", "splitDown": "向下拆分", "splitAndMoveRight": "拆分並右移", diff --git a/src/lib/api.ts b/src/lib/api.ts index f00b815b0a..100f7c4fb2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -9,6 +9,11 @@ import { import { getCodegToken } from "./transport/web-auth" import { notifyWebUnauthorized } from "./transport/web-connection-store" import { getCurrentEffectiveAppLocale } from "./i18n" +import { + conversationWindowName, + conversationWindowRoute, + type ConversationWindowTarget, +} from "./conversation-window" import { DEFAULT_FORGE_COMMENT_PAGE_SIZE, DEFAULT_FORGE_FILES_PAGE_SIZE, @@ -3120,6 +3125,38 @@ export async function openImportSessionsWindow( ) } +/** + * Open one conversation in its own window, or focus the window it is already + * in — the label/name is keyed by conversation id, so this never stacks + * duplicates. `title` is only used for the window title (the taskbar entry); + * the view reads everything else back from the conversation itself. + * + * Web mode has no backend round trip to make: the target is `/workspace` with + * the conversation in the query string, which the caller's own click can open + * directly. See `src/lib/conversation-window.ts` for what that URL means. + */ +export async function openConversationWindow( + target: ConversationWindowTarget, + title?: string | null +): Promise { + if (isDesktop()) { + return getShellTransport().call("open_conversation_window", { + folderId: target.folderId, + conversationId: target.conversationId, + agent: target.agentType, + title: title ?? null, + remoteConnectionId: getActiveRemoteConnectionId(), + }) + } + const path = conversationWindowRoute(target) + return openAppWindow( + conversationWindowName(target.conversationId), + async () => ({ + path, + }) + ) +} + export async function openProjectBootWindow(source?: string): Promise { if (isDesktop()) { return getShellTransport().call("open_project_boot_window", { diff --git a/src/lib/conversation-window.test.ts b/src/lib/conversation-window.test.ts new file mode 100644 index 0000000000..3610057530 --- /dev/null +++ b/src/lib/conversation-window.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it } from "vitest" + +import { + conversationWindowName, + conversationWindowRoute, + conversationWindowTarget, + parseConversationWindowTarget, +} from "./conversation-window" + +const TARGET = { + folderId: 4, + conversationId: 7, + agentType: "claude_code", +} as const + +afterEach(() => { + window.history.replaceState({}, "", "/workspace") +}) + +describe("parseConversationWindowTarget", () => { + it("reads the target a conversation window was opened for", () => { + expect( + parseConversationWindowTarget( + "?conversationWindow=1&folderId=4&conversationId=7&agent=claude_code" + ) + ).toEqual(TARGET) + }) + + // The plain deep link (`/workspace?folderId=…`, handled by DeepLinkBootstrap) + // carries the same identity WITHOUT the marker, and must stay an ordinary + // workspace: detaching it would cut that window off from `opened_tabs`. + it("ignores a deep link that is not marked as a conversation window", () => { + expect( + parseConversationWindowTarget( + "?folderId=4&conversationId=7&agent=claude_code" + ) + ).toBeNull() + expect( + parseConversationWindowTarget( + "?conversationWindow=0&folderId=4&conversationId=7&agent=claude_code" + ) + ).toBeNull() + }) + + // A partial URL must fall through to the ordinary workspace rather than open + // a detached window with nothing to show. + it("rejects an incomplete target", () => { + for (const search of [ + "?conversationWindow=1", + "?conversationWindow=1&folderId=4&agent=claude_code", + "?conversationWindow=1&conversationId=7&agent=claude_code", + "?conversationWindow=1&folderId=4&conversationId=7", + "?conversationWindow=1&folderId=x&conversationId=7&agent=claude_code", + "?conversationWindow=1&folderId=4&conversationId=x&agent=claude_code", + "?conversationWindow=1&folderId=4&conversationId=&agent=claude_code", + "?conversationWindow=1&folderId=4&conversationId=0&agent=claude_code", + "?conversationWindow=1&folderId=4&conversationId=7&agent=", + ]) { + expect(parseConversationWindowTarget(search), search).toBeNull() + } + }) + + it("survives extra parameters (the remote-workspace context rides along)", () => { + expect( + parseConversationWindowTarget( + "?conversationWindow=1&folderId=4&conversationId=7&agent=claude_code" + + "&remoteConnectionId=3&remoteWindowId=abc" + ) + ).toEqual(TARGET) + }) +}) + +describe("conversationWindowTarget", () => { + it("reads the live location, so the marker survives for the window's life", () => { + expect(conversationWindowTarget()).toBeNull() + window.history.replaceState({}, "", conversationWindowRoute(TARGET)) + expect(conversationWindowTarget()).toEqual(TARGET) + }) +}) + +describe("conversationWindowRoute", () => { + // Static export: a query on `/workspace`, never a dynamic route. Mirrors + // `conversation_window_route` in src-tauri/src/commands/windows.rs. + it("round-trips through the parser", () => { + const route = conversationWindowRoute(TARGET) + expect(route.startsWith("/workspace?")).toBe(true) + expect(route).toContain("conversationWindow=1") + expect( + parseConversationWindowTarget(route.slice(route.indexOf("?"))) + ).toEqual(TARGET) + }) +}) + +describe("conversationWindowName", () => { + // Keyed by conversation id alone, so re-invoking "Open in New Window" + // focuses the window that already exists instead of stacking duplicates. + it("is stable per conversation and distinct across conversations", () => { + expect(conversationWindowName(7)).toBe("conversation-7") + expect(conversationWindowName(7)).toBe(conversationWindowName(7)) + expect(conversationWindowName(7)).not.toBe(conversationWindowName(8)) + }) +}) diff --git a/src/lib/conversation-window.ts b/src/lib/conversation-window.ts new file mode 100644 index 0000000000..1bff6d43b2 --- /dev/null +++ b/src/lib/conversation-window.ts @@ -0,0 +1,90 @@ +import type { AgentType } from "@/lib/types" + +/** + * A conversation opened in its own window (`open_conversation_window` on the + * desktop, a named browser window in web mode). + * + * The window loads the ordinary `/workspace` route with the conversation in the + * query string — the frontend is a static export, so there is no dynamic route + * to open instead. `conversationWindow=1` is the part that matters: it marks + * the webview as a DETACHED view of one conversation rather than a second + * workspace, and the tab store keys its whole behaviour off it. A detached + * window seeds the single tab it was opened for and never reads, writes or + * mirrors the shared `opened_tabs` — without that, both windows would show the + * same tab set and, because focus is mirrored across clients, would be dragged + * onto the same conversation whenever either one switched tabs. + */ +export const CONVERSATION_WINDOW_PARAM = "conversationWindow" + +export interface ConversationWindowTarget { + folderId: number + conversationId: number + agentType: AgentType +} + +/** A row id from the query string, or `null` for anything that is not one. + * Not `Number(...)` alone: that reads a missing parameter as `0`, which is + * finite and would seed a tab pointing at nothing. */ +function rowId(raw: string | null): number | null { + if (!raw) return null + const value = Number(raw) + return Number.isSafeInteger(value) && value > 0 ? value : null +} + +/** + * Read a conversation-window target out of a query string. Returns `null` + * unless the marker AND a complete target are all present: a partial URL must + * fall through to the ordinary workspace rather than open a window showing + * nothing. + */ +export function parseConversationWindowTarget( + search: string +): ConversationWindowTarget | null { + const params = new URLSearchParams(search) + if (params.get(CONVERSATION_WINDOW_PARAM) !== "1") return null + + const folderId = rowId(params.get("folderId")) + const conversationId = rowId(params.get("conversationId")) + const agent = params.get("agent") + if (folderId == null || conversationId == null || !agent) return null + + return { folderId, conversationId, agentType: agent as AgentType } +} + +/** + * The target this webview was opened for, or `null` in the workspace and every + * auxiliary window. + * + * Deliberately re-read on each call rather than memoized: the value is a + * property of the window's URL, and `DeepLinkBootstrap` (which DOES rewrite the + * URL for the plain `?folderId=…` deep link) skips a conversation window + * precisely so this stays true for the window's whole life. + */ +export function conversationWindowTarget(): ConversationWindowTarget | null { + if (typeof window === "undefined") return null + return parseConversationWindowTarget(window.location.search) +} + +/** + * The route a conversation window loads. Mirrors `conversation_window_route` + * in `src-tauri/src/commands/windows.rs`, which builds the same URL for the + * desktop window; this copy serves the web fallback. + */ +export function conversationWindowRoute( + target: ConversationWindowTarget +): string { + const params = new URLSearchParams({ + [CONVERSATION_WINDOW_PARAM]: "1", + folderId: String(target.folderId), + conversationId: String(target.conversationId), + agent: target.agentType, + }) + return `/workspace?${params.toString()}` +} + +/** Window name / Tauri label for a conversation. Keyed by the conversation id + * alone, so re-invoking "Open in New Window" focuses the existing window + * instead of stacking duplicates. Mirrors `conversation_window_label`. */ +export function conversationWindowName(conversationId: number): string { + return `conversation-${conversationId}` +} diff --git a/src/stores/conversation-window-detach.test.ts b/src/stores/conversation-window-detach.test.ts new file mode 100644 index 0000000000..3205fdf74f --- /dev/null +++ b/src/stores/conversation-window-detach.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { listOpenedTabs, saveOpenedTabs } from "@/lib/api" +import { saveLastActiveContext } from "@/lib/last-active-context-storage" +import { conversationWindowRoute } from "@/lib/conversation-window" +import { leafIds, ROOT_GROUP_ID } from "@/lib/tab-group-layout" +import { + resetAppWorkspaceStore, + useAppWorkspaceStore, +} from "./app-workspace-store" +import { resetTabStore, useTabStore } from "./tab-store" +import type { FolderDetail, TabsChanged } from "@/lib/types" + +vi.mock("@/lib/api", () => ({ + listOpenedTabs: vi.fn(async () => ({ version: 1, items: [] })), + saveOpenedTabs: vi.fn(async () => ({ accepted: true, version: 2, tabs: [] })), + getFolderConversation: vi.fn(), +})) + +vi.mock("@/lib/platform", () => ({ + subscribe: vi.fn(), + onTransportReconnect: vi.fn(), +})) + +vi.mock("@/lib/last-active-context-storage", () => ({ + loadLastActiveContext: vi.fn(() => null), + saveLastActiveContext: vi.fn(), + clearLastActiveContext: vi.fn(), +})) + +const folder = { id: 4, name: "repo", path: "/repo" } as unknown as FolderDetail + +/** Device-local split state, written by whichever window last had one. */ +const TAB_GROUPS_KEY = "workspace:tab-groups:v1" +const SPLIT_BLOB = JSON.stringify({ + layout: { + type: "split", + id: "s-root", + orientation: "horizontal", + ratios: [0.5, 0.5], + children: [ + { type: "group", id: ROOT_GROUP_ID }, + { type: "group", id: "g-second" }, + ], + }, + assignments: {}, + selection: {}, + tileByGroup: {}, + drafts: [], + activeDraft: null, +}) + +const TARGET = { + folderId: 4, + conversationId: 7, + agentType: "claude_code", +} as const + +/** What the WORKSPACE window has open: a different conversation, focused. */ +const workspaceSnapshot: TabsChanged = { + version: 9, + origin: "workspace", + tabs: [ + { + folder_id: 4, + conversation_id: 99, + agent_type: "claude_code", + is_pinned: false, + is_active: true, + }, + ] as TabsChanged["tabs"], +} + +function enterWindow(search: string) { + window.history.replaceState({}, "", search) + // The group blob and the initial layout are read by `initialTabState`, so the + // URL has to be in place before the store is rebuilt. + resetTabStore() + resetAppWorkspaceStore() + useAppWorkspaceStore.setState({ + folders: [folder], + allFolders: [folder], + foldersHydrated: true, + }) + useTabStore.getState().setLabels({ + loadingConversation: "Loading...", + newConversation: "New conversation", + untitledConversation: "Untitled", + }) +} + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() +}) + +afterEach(() => { + window.history.replaceState({}, "", "/workspace") + resetTabStore() + resetAppWorkspaceStore() +}) + +describe("detached conversation window", () => { + beforeEach(() => { + enterWindow(conversationWindowRoute(TARGET)) + }) + + it("seeds exactly the conversation it was opened for, without reading opened_tabs", () => { + useTabStore.getState().hydrate() + + const state = useTabStore.getState() + expect(state.tabsHydrated).toBe(true) + expect(state.rawTabs).toHaveLength(1) + expect(state.rawTabs[0]).toMatchObject({ + folderId: 4, + conversationId: 7, + agentType: "claude_code", + isPinned: true, + }) + expect(state.activeTabId).toBe(state.rawTabs[0].id) + // The single group has to select it, or the shell renders an empty window. + expect(state.groupSelection[ROOT_GROUP_ID]).toBe(state.rawTabs[0].id) + expect(listOpenedTabs).not.toHaveBeenCalled() + }) + + it("never pushes its tab set at the workspace", async () => { + useTabStore.getState().hydrate() + useTabStore.getState().runSaveEffect() + await vi.waitFor(() => + expect(useTabStore.getState().tabsHydrated).toBe(true) + ) + // The save is debounced by 500ms; give it more than that to fire. + await new Promise((resolve) => setTimeout(resolve, 700)) + expect(saveOpenedTabs).not.toHaveBeenCalled() + }) + + // The reason this window can exist at all. `opened_tabs` is one shared list + // and focus is mirrored across clients, so adopting the workspace's snapshot + // would replace this window's tab AND drag it onto whatever the workspace is + // looking at. + it("is not dragged onto the workspace's conversation when the workspace switches tabs", () => { + useTabStore.getState().hydrate() + const seeded = useTabStore.getState().activeTabId + + useTabStore.getState().handleTabsChanged(workspaceSnapshot) + + const state = useTabStore.getState() + expect(state.rawTabs).toHaveLength(1) + expect(state.rawTabs[0].conversationId).toBe(7) + expect(state.activeTabId).toBe(seeded) + }) + + it("does not refetch the shared tab set", async () => { + useTabStore.getState().hydrate() + await useTabStore.getState().refetchTabs() + expect(listOpenedTabs).not.toHaveBeenCalled() + }) + + // localStorage is shared with the workspace window, so the detached view must + // neither restore the workspace's split tree (it would render empty groups + // around its single tab) nor write its own state back over it. + it("keeps the workspace's device-local state to itself", async () => { + localStorage.setItem(TAB_GROUPS_KEY, SPLIT_BLOB) + enterWindow(conversationWindowRoute(TARGET)) + + useTabStore.getState().hydrate() + useTabStore.getState().persistLastActiveContext() + + expect(leafIds(useTabStore.getState().groupLayout)).toEqual([ROOT_GROUP_ID]) + expect(saveLastActiveContext).not.toHaveBeenCalled() + // Longer than `schedulePersistGroupState`'s 300ms trailing debounce. + await new Promise((resolve) => setTimeout(resolve, 500)) + expect(localStorage.getItem(TAB_GROUPS_KEY)).toBe(SPLIT_BLOB) + }) +}) + +// Same calls on the ordinary workspace URL: every gate above has to be keyed on +// the marker, not simply switched off. +describe("ordinary workspace window", () => { + beforeEach(() => { + enterWindow("/workspace") + }) + + it("restores the split tree from device-local state", () => { + localStorage.setItem(TAB_GROUPS_KEY, SPLIT_BLOB) + enterWindow("/workspace") + + expect(leafIds(useTabStore.getState().groupLayout)).toEqual([ + ROOT_GROUP_ID, + "g-second", + ]) + }) + + it("hydrates from opened_tabs", async () => { + vi.mocked(listOpenedTabs).mockResolvedValueOnce({ + version: 1, + items: [ + { + folder_id: 4, + conversation_id: 7, + agent_type: "claude_code", + is_pinned: false, + is_active: true, + }, + ], + } as Awaited>) + + useTabStore.getState().hydrate() + await vi.waitFor(() => + expect(useTabStore.getState().tabsHydrated).toBe(true) + ) + expect(listOpenedTabs).toHaveBeenCalled() + }) + + it("adopts the workspace snapshot and mirrors its focus", async () => { + useTabStore.getState().hydrate() + await vi.waitFor(() => + expect(useTabStore.getState().tabsHydrated).toBe(true) + ) + + useTabStore.getState().handleTabsChanged(workspaceSnapshot) + + const state = useTabStore.getState() + expect(state.rawTabs.map((t) => t.conversationId)).toContain(99) + expect( + state.rawTabs.find((t) => t.id === state.activeTabId)?.conversationId + ).toBe(99) + }) + + it("saves its tab set", async () => { + useTabStore.getState().hydrate() + await vi.waitFor(() => + expect(useTabStore.getState().tabsHydrated).toBe(true) + ) + useTabStore.getState().openTab(4, 7, "claude_code", true) + useTabStore.getState().runSaveEffect() + await vi.waitFor(() => expect(saveOpenedTabs).toHaveBeenCalled(), { + timeout: 2000, + }) + }) +}) diff --git a/src/stores/tab-store.ts b/src/stores/tab-store.ts index 0d76967df9..65026a1dea 100644 --- a/src/stores/tab-store.ts +++ b/src/stores/tab-store.ts @@ -9,6 +9,7 @@ import { } from "@/lib/api" import { resolveDefaultAgent } from "@/lib/resolve-default-agent" import { formatConversationTitle } from "@/lib/conversation-title" +import { conversationWindowTarget } from "@/lib/conversation-window" import { firstLeafId, isLayoutNode, @@ -334,6 +335,27 @@ const TAB_GROUPS_STORAGE_KEY = "workspace:tab-groups:v1" * suppression, not the user, so nothing about it needs to persist. */ const TAB_ORIGIN = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +/** + * True in a window opened by "Open in New Window" (`?conversationWindow=1`). + * + * `opened_tabs` is ONE shared, CAS-versioned list broadcast to every client, and + * focus is mirrored across them (see `applyRemoteSnapshot`). A detached window + * that took part in that would show the workspace's whole tab set and follow it + * onto whatever conversation the workspace focused next — the opposite of + * having the conversation in its own window. So it seeds its one tab in + * `hydrate` and stays out of the sync entirely: no save, no snapshot apply, no + * refetch, and no device-local writes (`persistLastActiveContext` here, plus + * everything already parked behind `tabsSnapshotLoaded` — the group blob and + * the composer-draft sweep, which are localStorage and therefore shared with + * the workspace window). + * + * The workspace keeps its own tab for the conversation, which is what makes + * closing this window a non-event: the session is still open over there. + */ +function isDetachedConversationWindow(): boolean { + return conversationWindowTarget() !== null +} + // ── React-land dependencies, injected by TabRuntimeEffects ───────────────────── // Kept out of the reactive store state so updating them never notifies // consumers; actions read them directly. @@ -691,6 +713,11 @@ function readPersistedGroupState(): { tileByGroup: {} as Record, }) if (typeof window === "undefined") return fallback() + // A detached conversation window shows exactly one tab, so the workspace's + // split tree would only give it empty groups — and it never writes the blob + // back (`persistGroupState` stays parked behind `tabsSnapshotLoaded`), so + // there is nothing to restore for it either. + if (isDetachedConversationWindow()) return fallback() try { const raw = localStorage.getItem(TAB_GROUPS_STORAGE_KEY) if (raw) { @@ -2048,6 +2075,34 @@ export const useTabStore = create()((set, get) => ({ }, hydrate: () => { + // Detached window: seed the one tab it was opened for and stop. Nothing + // here is async, so there is no cancellation to hand back. `tabsSnapshot- + // Loaded` deliberately stays false — the paths it parks (the group-blob + // write, the composer-draft sweep) are shared localStorage that belongs to + // the workspace window. See `isDetachedConversationWindow`. + const detached = conversationWindowTarget() + if (detached) { + const seeded: TabItemInternal = { + id: makeConversationTabId( + detached.folderId, + detached.agentType, + detached.conversationId + ), + kind: "conversation", + folderId: detached.folderId, + conversationId: detached.conversationId, + agentType: detached.agentType, + title: runtime.labels.loadingConversation, + // The window exists for this conversation; nothing should be able to + // preview-replace it out from under itself. + isPinned: true, + } + set({ rawTabs: [seeded], activeTabId: seeded.id, tabsHydrated: true }) + recomputeTabs() + applyGroupInvariants() + return () => {} + } + let cancelled = false void (async () => { let snapshotLoaded = false @@ -2187,6 +2242,9 @@ export const useTabStore = create()((set, get) => ({ }, runSaveEffect: () => { + // A detached window's tab set is its own; pushing it would replace the + // workspace's list with this single conversation on every other client. + if (isDetachedConversationWindow()) return const st = get() if (!st.tabsHydrated) return @@ -2377,6 +2435,10 @@ export const useTabStore = create()((set, get) => ({ }, handleTabsChanged: (change) => { + // Inbound half of the same rule: adopting the workspace's snapshot would + // pull its whole tab set in here AND mirror its focus, dragging this window + // off the conversation it was opened for. + if (isDetachedConversationWindow()) return if (change.origin === TAB_ORIGIN) { // Our own accepted save, echoed back: nothing to apply, but the snapshot // is authoritative — record it as the merge ancestor in case it beats the @@ -2399,6 +2461,7 @@ export const useTabStore = create()((set, get) => ({ }, refetchTabs: async () => { + if (isDetachedConversationWindow()) return try { const snap = await listOpenedTabs() const change: TabsChanged = { @@ -2593,6 +2656,9 @@ export const useTabStore = create()((set, get) => ({ }, persistLastActiveContext: () => { + // Device-local and shared with the workspace window: the next cold start + // should resume what the WORKSPACE was last on, not a detached view. + if (isDetachedConversationWindow()) return const st = get() if (!st.tabsHydrated) return const active = st.rawTabs.find((t) => t.id === st.activeTabId)