From 07fdc27caaaf6445c66104c7d3bc302a31623ae1 Mon Sep 17 00:00:00 2001 From: H1d3rOne Date: Wed, 17 Jun 2026 17:25:35 +0800 Subject: [PATCH 1/3] Add multi-agent collaboration tabs --- src-tauri/src/commands/conversations.rs | 11 +- src-tauri/src/db/entities/opened_tab.rs | 1 + ...260617_000001_opened_tab_title_override.rs | 35 +++ src-tauri/src/db/migration/mod.rs | 2 + src-tauri/src/db/service/tab_service.rs | 8 + src-tauri/src/models/folder.rs | 5 + src/components/chat/sub-agent-overlay.tsx | 45 +++- .../message/delegated-sub-thread.tsx | 51 +++- .../message/sub-agent-session-dialog.tsx | 30 +++ src/components/tabs/tab-bar.tsx | 127 ++++++---- .../workspace/deep-link-bootstrap.tsx | 35 ++- src/contexts/tab-context.test.tsx | 92 ++++++- src/contexts/tab-context.tsx | 232 +++++++++++++++--- src/hooks/use-open-conversation-tab.ts | 70 ++++++ src/i18n/messages/ar.json | 9 +- src/i18n/messages/de.json | 9 +- src/i18n/messages/en.json | 9 +- src/i18n/messages/es.json | 9 +- src/i18n/messages/fr.json | 9 +- src/i18n/messages/ja.json | 9 +- src/i18n/messages/ko.json | 9 +- src/i18n/messages/pt.json | 9 +- src/i18n/messages/zh-CN.json | 9 +- src/i18n/messages/zh-TW.json | 9 +- src/lib/types.ts | 3 + 25 files changed, 695 insertions(+), 142 deletions(-) create mode 100644 src-tauri/src/db/migration/m20260617_000001_opened_tab_title_override.rs create mode 100644 src/hooks/use-open-conversation-tab.ts diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 420bfaea7..71acbf272 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -2479,6 +2479,7 @@ mod tests { position: 0, is_active: false, is_pinned: true, + title_override: None, } } @@ -2495,8 +2496,10 @@ mod tests { let (broadcaster, emitter) = sync_test_emitter(); let mut rx = broadcaster.subscribe(); + let mut delegated_tab = conv_tab(folder_id, c1, AgentType::ClaudeCode); + delegated_tab.title_override = Some("Backend verifier".to_string()); let items = vec![ - conv_tab(folder_id, c1, AgentType::ClaudeCode), + delegated_tab, conv_tab(folder_id, c2, AgentType::Codex), // A draft (conversation_id == None) — must NOT persist. OpenedTab { @@ -2507,6 +2510,7 @@ mod tests { position: 2, is_active: true, is_pinned: true, + title_override: None, }, ]; let outcome = save_opened_tabs_core(&db.conn, &emitter, items, 0, "win-a".into()) @@ -2521,10 +2525,15 @@ mod tests { assert_eq!(evt.payload["version"], 1); assert_eq!(evt.payload["origin"], "win-a"); assert_eq!(evt.payload["tabs"].as_array().unwrap().len(), 2); + assert_eq!(evt.payload["tabs"][0]["title_override"], "Backend verifier"); let snap = list_opened_tabs_core(&db.conn).await.expect("list"); assert_eq!(snap.items.len(), 2); assert_eq!(snap.version, 1); + assert_eq!( + snap.items[0].title_override.as_deref(), + Some("Backend verifier") + ); } #[tokio::test] diff --git a/src-tauri/src/db/entities/opened_tab.rs b/src-tauri/src/db/entities/opened_tab.rs index 527bf0ec2..c7f8a0cea 100644 --- a/src-tauri/src/db/entities/opened_tab.rs +++ b/src-tauri/src/db/entities/opened_tab.rs @@ -11,6 +11,7 @@ pub struct Model { pub position: i32, pub is_active: bool, pub is_pinned: bool, + pub title_override: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, } diff --git a/src-tauri/src/db/migration/m20260617_000001_opened_tab_title_override.rs b/src-tauri/src/db/migration/m20260617_000001_opened_tab_title_override.rs new file mode 100644 index 000000000..e1f8bbb99 --- /dev/null +++ b/src-tauri/src/db/migration/m20260617_000001_opened_tab_title_override.rs @@ -0,0 +1,35 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(OpenedTab::Table) + .add_column(ColumnDef::new(OpenedTab::TitleOverride).text().null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(OpenedTab::Table) + .drop_column(OpenedTab::TitleOverride) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum OpenedTab { + Table, + TitleOverride, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 002188455..d6fefb2c5 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -21,6 +21,7 @@ mod m20260607_000001_folder_parent_id; mod m20260608_000001_conversation_title_locked; mod m20260610_000001_conversation_pinned_at; mod m20260611_000001_folder_is_chat; +mod m20260617_000001_opened_tab_title_override; pub struct Migrator; #[async_trait::async_trait] @@ -48,6 +49,7 @@ impl MigratorTrait for Migrator { Box::new(m20260608_000001_conversation_title_locked::Migration), Box::new(m20260610_000001_conversation_pinned_at::Migration), Box::new(m20260611_000001_folder_is_chat::Migration), + Box::new(m20260617_000001_opened_tab_title_override::Migration), ] } } diff --git a/src-tauri/src/db/service/tab_service.rs b/src-tauri/src/db/service/tab_service.rs index 875ab5ffe..057824422 100644 --- a/src-tauri/src/db/service/tab_service.rs +++ b/src-tauri/src/db/service/tab_service.rs @@ -68,6 +68,7 @@ pub async fn list_all_tabs(conn: &C) -> Result( } else { false }; + let title_override = item + .title_override + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned); let active = opened_tab::ActiveModel { id: NotSet, @@ -130,6 +137,7 @@ pub async fn save_all_tabs( position: Set(item.position), is_active: Set(is_active), is_pinned: Set(item.is_pinned), + title_override: Set(title_override), created_at: Set(now), updated_at: Set(now), }; diff --git a/src-tauri/src/models/folder.rs b/src-tauri/src/models/folder.rs index d0c8eeedc..d6dddbb01 100644 --- a/src-tauri/src/models/folder.rs +++ b/src-tauri/src/models/folder.rs @@ -39,6 +39,11 @@ pub struct OpenedTab { pub position: i32, pub is_active: bool, pub is_pinned: bool, + /// Optional display-title override for restored/synced tabs. Used by + /// delegated sub-agent tabs so their tab label can remain the role/task + /// name instead of falling back to the persisted conversation title. + #[serde(default)] + pub title_override: Option, } /// Response for `list_opened_tabs`: the persisted tab set plus the current diff --git a/src/components/chat/sub-agent-overlay.tsx b/src/components/chat/sub-agent-overlay.tsx index 7afe3e434..c9c915682 100644 --- a/src/components/chat/sub-agent-overlay.tsx +++ b/src/components/chat/sub-agent-overlay.tsx @@ -17,7 +17,8 @@ import { memo, useState } from "react" import { useTranslations } from "next-intl" -import { BotIcon, ChevronDownIcon } from "lucide-react" +import { BotIcon, ChevronDownIcon, PanelRight } from "lucide-react" +import { toast } from "sonner" import { AgentIcon } from "@/components/agent-icon" import { CollapsedOverlayChip } from "@/components/chat/collapsed-overlay-chip" @@ -29,6 +30,7 @@ import { useDelegationCardModel, type DelegationCardSource, } from "@/hooks/use-delegation-card-model" +import { useOpenConversationTab } from "@/hooks/use-open-conversation-tab" import { AGENT_LABELS } from "@/lib/types" interface SubAgentOverlayProps { @@ -124,6 +126,7 @@ const SubAgentOverlayRow = memo(function SubAgentOverlayRow({ childConversationId, childConnectionId, } = useDelegationCardModel(source) + const openConversationTab = useOpenConversationTab() // Unlike the inline DelegatedSubThread (which falls through to the generic // tool renderer when nothing resolves), the overlay always renders one row @@ -132,6 +135,13 @@ const SubAgentOverlayRow = memo(function SubAgentOverlayRow({ // degrade gracefully: unknown agent → neutral dot + "Sub-agent" label, // missing child id → non-clickable. const clickable = childConversationId != null + const handleOpenTab = () => { + if (childConversationId == null) return + openConversationTab(childConversationId, { title: task }).catch((err) => { + console.error("[SubAgentOverlay] open conversation tab failed:", err) + toast.error(t("openInTabFailed")) + }) + } const rowBody = (
@@ -166,18 +176,31 @@ const SubAgentOverlayRow = memo(function SubAgentOverlayRow({ return ( <> {clickable ? ( - + + +
) : (
{ + if (childConversationId == null) return + openConversationTab(childConversationId, { title: task }).catch((err) => { + console.error("[DelegatedSubThread] open conversation tab failed:", err) + toast.error(t("openInTabFailed")) + }) + } + return (
{childConversationId != null && ( - +
+ + +
)}
{childConversationId != null && ( diff --git a/src/components/message/sub-agent-session-dialog.tsx b/src/components/message/sub-agent-session-dialog.tsx index 5ce8433c8..e4eb9be9f 100644 --- a/src/components/message/sub-agent-session-dialog.tsx +++ b/src/components/message/sub-agent-session-dialog.tsx @@ -23,9 +23,12 @@ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react" import { useTranslations } from "next-intl" +import { PanelRight } from "lucide-react" +import { toast } from "sonner" import { AgentIcon } from "@/components/agent-icon" import { MessageListView } from "@/components/message/message-list-view" +import { Button } from "@/components/ui/button" import { Dialog, DialogContent, @@ -41,6 +44,7 @@ import { } from "@/contexts/acp-connections-context" import { PermissionDialog } from "@/components/chat/permission-dialog" import { AGENT_LABELS, type AgentType } from "@/lib/types" +import { useOpenConversationTab } from "@/hooks/use-open-conversation-tab" interface Props { open: boolean @@ -249,6 +253,7 @@ export function SubAgentSessionDialog({ childConnectionId={childConnectionId} agentType={agentType} kickoffTask={kickoffTask} + onRequestClose={() => onOpenChange(false)} /> ) : null} @@ -261,11 +266,13 @@ function SubAgentSessionBody({ childConnectionId, agentType, kickoffTask, + onRequestClose, }: { childConversationId: number childConnectionId: string | null agentType: AgentType | null kickoffTask?: string | null + onRequestClose: () => void }) { const t = useTranslations("Folder.chat.delegation") @@ -274,6 +281,18 @@ function SubAgentSessionBody({ const isChildStreaming = connStatus === "prompting" const { refetchDetail, setLiveOwnsActiveTurn } = useConversationRuntime() + const openConversationTab = useOpenConversationTab() + const handleOpenTab = useCallback(() => { + openConversationTab(childConversationId, { title: kickoffTask }) + .then(() => onRequestClose()) + .catch((err) => { + console.error( + "[SubAgentSessionDialog] open conversation tab failed:", + err + ) + toast.error(t("openInTabFailed")) + }) + }, [childConversationId, kickoffTask, onRequestClose, openConversationTab, t]) // Enter delegation-child viewer mode: mark the session live-owned and record // the known kickoff task. `getTimelineTurns` then (a) synthesizes the kickoff @@ -336,6 +355,17 @@ function SubAgentSessionBody({ {agentType ? AGENT_LABELS[agentType] : t("unknownAgent")} + {childPendingPermission && (
diff --git a/src/components/tabs/tab-bar.tsx b/src/components/tabs/tab-bar.tsx index 04d890268..876714258 100644 --- a/src/components/tabs/tab-bar.tsx +++ b/src/components/tabs/tab-bar.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Reorder } from "motion/react" +import { Columns3, PanelTop } from "lucide-react" +import { useTranslations } from "next-intl" import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" import type { TabItem as TabItemData } from "@/contexts/tab-context" @@ -9,10 +11,12 @@ import { useWorkspaceContext } from "@/contexts/workspace-context" import { useIsCoarsePointer } from "@/hooks/use-is-coarse-pointer" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" +import { Button } from "@/components/ui/button" import { TabItem } from "./tab-item" import { cn } from "@/lib/utils" export function TabBar() { + const t = useTranslations("Folder.tabs") const { tabs, activeTabId, @@ -111,57 +115,84 @@ export function TabBar() { () => setTouchSortingTabId(null), [] ) + const viewToggleLabel = isTileMode + ? t("showCurrentTabOnly") + : t("tileAllTabs") if (tabs.length === 0) return null return ( - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className={cn( - "h-10 pt-1.5 px-1.5 flex items-stretch gap-1.5 border-b border-border", - "overflow-x-scroll", - isHovered - ? [ - "pb-0.5", - "[&::-webkit-scrollbar]:h-1", - "[&::-webkit-scrollbar-track]:bg-transparent", - "[&::-webkit-scrollbar-thumb]:rounded-full", - "[&::-webkit-scrollbar-thumb]:bg-border", - ] - : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"] - )} - > - {tabs.map((tab) => { - const folderInfo = folderIndex.get(tab.folderId) - return ( - - ) - })} - +
+ setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className={cn( + "min-w-0 flex-1 pt-1.5 px-1.5 flex items-stretch gap-1.5", + "overflow-x-scroll", + isHovered + ? [ + "pb-0.5", + "[&::-webkit-scrollbar]:h-1", + "[&::-webkit-scrollbar-track]:bg-transparent", + "[&::-webkit-scrollbar-thumb]:rounded-full", + "[&::-webkit-scrollbar-thumb]:bg-border", + ] + : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"] + )} + > + {tabs.map((tab) => { + const folderInfo = folderIndex.get(tab.folderId) + return ( + + ) + })} + +
+ +
+
) } diff --git a/src/components/workspace/deep-link-bootstrap.tsx b/src/components/workspace/deep-link-bootstrap.tsx index 7c2021e13..a2e2afec0 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 { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" +import { listAllConversations } from "@/lib/api" import type { AgentType } from "@/lib/types" /** @@ -60,18 +61,36 @@ export function DeepLinkBootstrap() { } } - const hasConv = conversations.some( - (c) => - c.id === conversationId && - c.folder_id === folderId && - c.agent_type === rawAgent - ) - if (!hasConv) { + const linkedConversation = + conversations.find( + (c) => + c.id === conversationId && + c.folder_id === folderId && + c.agent_type === rawAgent + ) ?? + ( + await listAllConversations({ + folder_ids: [folderId], + include_children: true, + }) + ).find( + (c) => + c.id === conversationId && + c.folder_id === folderId && + c.agent_type === rawAgent + ) + if (!linkedConversation) { toast.error("Linked conversation not found") return } - openTab(folderId, conversationId, rawAgent, true) + openTab( + folderId, + conversationId, + rawAgent, + true, + linkedConversation.title ?? undefined + ) } finally { clearUrl() } diff --git a/src/contexts/tab-context.test.tsx b/src/contexts/tab-context.test.tsx index aaf8ee4d8..5f63c1e48 100644 --- a/src/contexts/tab-context.test.tsx +++ b/src/contexts/tab-context.test.tsx @@ -14,6 +14,7 @@ import type { const listOpenedTabsMock = vi.fn() const saveOpenedTabsMock = vi.fn() +const getFolderConversationMock = vi.fn() const setActiveFolderIdMock = vi.fn() const activateConversationPaneMock = vi.fn() const disconnectMock = vi.fn() @@ -36,6 +37,8 @@ vi.mock("next-intl", () => { vi.mock("@/lib/api", () => ({ listOpenedTabs: (...args: unknown[]) => listOpenedTabsMock(...args), saveOpenedTabs: (...args: unknown[]) => saveOpenedTabsMock(...args), + getFolderConversation: (...args: unknown[]) => + getFolderConversationMock(...args), })) vi.mock("@/lib/platform", () => ({ @@ -179,6 +182,9 @@ function Probe() { {activeTab?.folderId ?? "none"} + + {ctx.tabs.map((tab) => tab.title).join("|")} +
) } @@ -213,6 +219,7 @@ describe("TabProvider tab state transitions", () => { version: 1, tabs: [], }) + getFolderConversationMock.mockRejectedValue(new Error("not found")) tabsChangedHandler = null subscribeMock.mockImplementation( (event: string, handler: (change: TabsChanged) => void) => { @@ -548,7 +555,8 @@ describe("TabProvider tab state transitions", () => { function tabItem( folderId: number, conversationId: number, - isActive = false + isActive = false, + titleOverride: string | null = null ): OpenedTab { return { id: conversationId, @@ -558,6 +566,7 @@ function tabItem( position: 0, is_active: isActive, is_pinned: true, + title_override: titleOverride, } } @@ -572,6 +581,7 @@ describe("TabProvider cross-client sync", () => { version: 1, tabs: [], }) + getFolderConversationMock.mockRejectedValue(new Error("not found")) tabsChangedHandler = null subscribeMock.mockImplementation( (event: string, handler: (change: TabsChanged) => void) => { @@ -849,6 +859,86 @@ describe("TabProvider cross-client sync", () => { expect(screen.getByTestId("active")).toHaveTextContent("conv-1-codex-2") }) + it("restores a delegated tab responsibility title from opened_tabs", async () => { + listOpenedTabsMock.mockResolvedValue({ + items: [tabItem(1, 1, true, "Backend verifier")], + version: 3, + }) + await renderHydrated() + + expect(screen.getByTestId("titles")).toHaveTextContent("Backend verifier") + }) + + it("persists delegated tab responsibility titles", async () => { + await renderHydrated() + saveOpenedTabsMock.mockClear() + + act(() => { + latestContext?.openTab(1, 1, "codex", true, "Backend verifier", { + titleOverride: true, + }) + }) + + await waitFor(() => expect(saveOpenedTabsMock).toHaveBeenCalled(), { + timeout: 2000, + }) + const calls = saveOpenedTabsMock.mock.calls + const items = calls[calls.length - 1][0] as OpenedTab[] + expect(items[0]).toMatchObject({ + conversation_id: 1, + title_override: "Backend verifier", + }) + }) + + it("applies a remote delegated tab responsibility title", async () => { + await renderHydrated() + expect(tabsChangedHandler).not.toBeNull() + + act(() => { + tabsChangedHandler?.({ + version: 1, + origin: "other-device", + tabs: [tabItem(1, 1, true, "Frontend verifier")], + }) + }) + + expect(screen.getByTestId("titles")).toHaveTextContent("Frontend verifier") + }) + + it("resolves a restored delegation-child tab title outside the root conversation list", async () => { + listOpenedTabsMock.mockResolvedValue({ + items: [tabItem(1, 99, true)], + version: 3, + }) + getFolderConversationMock.mockResolvedValue({ + summary: { + id: 99, + folder_id: 1, + title: "Backend verifier", + title_locked: false, + agent_type: "codex", + status: "completed", + model: null, + git_branch: null, + external_id: null, + message_count: 1, + created_at: "2026-05-24T00:00:00Z", + updated_at: "2026-05-24T00:00:00Z", + pinned_at: null, + parent_id: 1, + parent_tool_use_id: "toolu-child", + delegation_call_id: "delegate-1", + }, + turns: [], + }) + await renderHydrated() + + await waitFor(() => { + expect(screen.getByTestId("titles")).toHaveTextContent("Backend verifier") + }) + expect(getFolderConversationMock).toHaveBeenCalledWith(99) + }) + it("cancels a pending local save when a remote snapshot supersedes it", async () => { listOpenedTabsMock.mockResolvedValue({ items: [tabItem(1, 1, true), tabItem(1, 2)], diff --git a/src/contexts/tab-context.tsx b/src/contexts/tab-context.tsx index e9a3fca36..acec455f4 100644 --- a/src/contexts/tab-context.tsx +++ b/src/contexts/tab-context.tsx @@ -16,7 +16,11 @@ import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useAcpActions } from "@/contexts/acp-connections-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useSortedAvailableAgents } from "@/hooks/use-sorted-available-agents" -import { listOpenedTabs, saveOpenedTabs } from "@/lib/api" +import { + getFolderConversation, + listOpenedTabs, + saveOpenedTabs, +} from "@/lib/api" import { onTransportReconnect, subscribe } from "@/lib/platform" import { resolveDefaultAgent } from "@/lib/resolve-default-agent" import { formatConversationTitle } from "@/lib/conversation-title" @@ -29,6 +33,7 @@ import { TABS_CHANGED_EVENT, type AgentType, type ConversationStatus, + type DbConversationSummary, type OpenedTab, type TabsChanged, } from "@/lib/types" @@ -47,6 +52,13 @@ interface TabItemInternal { isPinned: boolean workingDir?: string status?: ConversationStatus + /** + * Display title override for tabs opened from a contextual entry point (for + * example a delegated sub-agent whose "role" is clearer than the persisted + * conversation title). Conversation-bound tabs persist this to `opened_tabs` + * so restored/synced clients keep the responsibility label after restart. + */ + titleOverride?: string /** * Marks `agentType` as a system best-guess that should be replaced once * the agent list becomes fresh. True for draft tabs whose default came @@ -85,7 +97,11 @@ interface TabContextValue { conversationId: number, agentType: AgentType, pin?: boolean, - title?: string + title?: string, + options?: { + placement?: "end" | "afterActive" + titleOverride?: boolean + } ) => void closeTab: (tabId: string) => void closeConversationTab: ( @@ -255,6 +271,11 @@ const TILE_MODE_STORAGE_KEY = "workspace:tile-mode" * suppression, not the user, so nothing about it needs to persist. */ const TAB_ORIGIN = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +function normalizeTabTitleOverride(value: string | null | undefined) { + const formatted = formatConversationTitle(value) + return formatted || undefined +} + /** Build the persisted (synced) tab payload: conversation-bound tabs only * (drafts are device-local), `position` = display index, and `is_active` set on * the focused tab so focus mirrors across clients. (A draft- or null-focus @@ -275,6 +296,7 @@ function buildPersistItems( position: i, is_active: tab.id === activeTabId, is_pinned: tab.isPinned, + title_override: normalizeTabTitleOverride(tab.titleOverride) ?? null, })) } @@ -299,7 +321,18 @@ export function TabProvider({ children }: TabProviderProps) { const { rawTabs, activeTabId, previewReplacedTabIds, draftRetargetRequests } = tabState const [tabsHydrated, setTabsHydrated] = useState(false) - + // Root conversations are already supplied by AppWorkspace for sidebar tabs. + // Delegation children, however, are intentionally excluded from that root + // list; when a child tab is restored from `opened_tabs` after a process + // restart, it would otherwise stay stuck on the hydrate seed title + // ("Loading..."). Keep a small per-open-tab summary cache populated from the + // detail endpoint so restored child tabs can derive their title/status without + // leaking child rows into the sidebar list. + const [tabConversationSummaries, setTabConversationSummaries] = useState< + Map + >(() => new Map()) + const tabSummaryFetchesRef = useRef>(new Set()) + const tabSummaryFailuresRef = useRef>(new Set()) // ── Cross-client open-tab sync (see TAB_ORIGIN / `tabs://changed`) ────────── // `versionRef` — last workspace tab version this client has observed/applied; // every save sends it as the CAS `expected_version`. @@ -537,25 +570,30 @@ export function TabProvider({ children }: TabProviderProps) { const snap = await listOpenedTabs() if (cancelled) return versionRef.current = snap.version - const restored: TabItemInternal[] = snap.items.map((it) => ({ - id: - it.conversation_id != null - ? makeConversationTabId( - it.folder_id, - it.agent_type, - it.conversation_id - ) - : makeNewConversationTabId(), - kind: "conversation", - folderId: it.folder_id, - conversationId: it.conversation_id, - agentType: it.agent_type, - title: - it.conversation_id != null - ? t("loadingConversation") - : t("newConversation"), - isPinned: it.is_pinned, - })) + const restored: TabItemInternal[] = snap.items.map((it) => { + const titleOverride = normalizeTabTitleOverride(it.title_override) + return { + id: + it.conversation_id != null + ? makeConversationTabId( + it.folder_id, + it.agent_type, + it.conversation_id + ) + : makeNewConversationTabId(), + kind: "conversation", + folderId: it.folder_id, + conversationId: it.conversation_id, + agentType: it.agent_type, + title: + titleOverride ?? + (it.conversation_id != null + ? t("loadingConversation") + : t("newConversation")), + titleOverride, + isPinned: it.is_pinned, + } + }) // Focus the synced-active tab; fall back to the first tab when the // persisted set has no active marker (e.g. last saved from a draft). const activeItem = snap.items.find( @@ -677,14 +715,85 @@ export function TabProvider({ children }: TabProviderProps) { [] ) + // Hydrated opened tabs can include delegation-child conversations. Those + // rows are deliberately absent from AppWorkspace's root-only `conversations` + // list, so fetch their summaries directly; otherwise their seed title would + // remain "Loading..." forever after an app/process restart. + useEffect(() => { + if (!tabsHydrated) return + + const openConversationIds = new Set( + rawTabs + .map((tab) => tab.conversationId) + .filter((id): id is number => id != null) + ) + + if (tabConversationSummaries.size > 0) { + setTabConversationSummaries((prev) => { + let changed = false + const next = new Map() + for (const [id, summary] of prev) { + if (openConversationIds.has(id)) { + next.set(id, summary) + } else { + changed = true + } + } + return changed ? next : prev + }) + } + + for (const id of Array.from(tabSummaryFailuresRef.current)) { + if (!openConversationIds.has(id)) { + tabSummaryFailuresRef.current.delete(id) + } + } + + const rootConversationIds = new Set(conversations.map((c) => c.id)) + for (const tab of rawTabs) { + const id = tab.conversationId + if (id == null) continue + if (rootConversationIds.has(id)) continue + if (tabConversationSummaries.has(id)) continue + if (tabSummaryFetchesRef.current.has(id)) continue + if (tabSummaryFailuresRef.current.has(id)) continue + + tabSummaryFetchesRef.current.add(id) + void getFolderConversation(id) + .then((detail) => { + setTabConversationSummaries((prev) => { + const current = prev.get(id) + if (current === detail.summary) return prev + const next = new Map(prev) + next.set(id, detail.summary) + return next + }) + }) + .catch((err: unknown) => { + tabSummaryFailuresRef.current.add(id) + console.warn( + "[TabProvider] getFolderConversation for opened tab failed:", + id, + err + ) + }) + .finally(() => { + tabSummaryFetchesRef.current.delete(id) + }) + } + }, [tabsHydrated, rawTabs, conversations, tabConversationSummaries]) + // Pre-index conversations for O(1) lookup in tabs derivation const conversationMap = useMemo(() => { - const m = new Map() + const m = new Map() + for (const c of tabConversationSummaries.values()) { + m.set(`${c.folder_id}-${c.agent_type}-${c.id}`, c) + } for (const c of conversations) { m.set(`${c.folder_id}-${c.agent_type}-${c.id}`, c) } return m - }, [conversations]) + }, [conversations, tabConversationSummaries]) // Derive tabs with up-to-date titles and status from conversations const tabs = useMemo(() => { @@ -695,8 +804,13 @@ export function TabProvider({ children }: TabProviderProps) { `${tab.folderId}-${tab.agentType}-${tab.conversationId}` ) if (conv) { + const overrideTitle = tab.titleOverride + ? formatConversationTitle(tab.titleOverride) + : "" const newTitle = - formatConversationTitle(conv.title) || t("untitledConversation") + overrideTitle || + formatConversationTitle(conv.title) || + t("untitledConversation") const newStatus = conv.status as ConversationStatus | undefined if (tab.title !== newTitle || tab.status !== newStatus) { return { ...tab, title: newTitle, status: newStatus } @@ -713,7 +827,11 @@ export function TabProvider({ children }: TabProviderProps) { conversationId: number, agentType: AgentType, pin = false, - title?: string + title?: string, + options?: { + placement?: "end" | "afterActive" + titleOverride?: boolean + } ) => { setTabState((prevState) => { const existingIndex = findTabIndexForConversation( @@ -722,14 +840,26 @@ export function TabProvider({ children }: TabProviderProps) { agentType, conversationId ) + const formattedTitle = + title != null + ? formatConversationTitle(title) || undefined + : undefined + const titleOverride = options?.titleOverride + ? formattedTitle + : undefined if (existingIndex >= 0) { const activateTabId = prevState.rawTabs[existingIndex].id - if (pin && !prevState.rawTabs[existingIndex].isPinned) { + if ( + (pin && !prevState.rawTabs[existingIndex].isPinned) || + (titleOverride && + prevState.rawTabs[existingIndex].titleOverride !== titleOverride) + ) { const updated = [...prevState.rawTabs] updated[existingIndex] = { ...updated[existingIndex], - isPinned: true, + ...(pin ? { isPinned: true } : {}), + ...(titleOverride ? { title: titleOverride, titleOverride } : {}), } return { ...prevState, @@ -745,15 +875,16 @@ export function TabProvider({ children }: TabProviderProps) { // raw Markdown, before the `tabs` memo re-derives it from the refreshed // conversation list. const resolvedTitle = - formatConversationTitle( - title ?? - conversationsRef.current.find( - (c) => - c.id === conversationId && - c.agent_type === agentType && - c.folder_id === folderId - )?.title - ) || t("untitledConversation") + formattedTitle ?? + (formatConversationTitle( + conversationsRef.current.find( + (c) => + c.id === conversationId && + c.agent_type === agentType && + c.folder_id === folderId + )?.title + ) || + t("untitledConversation")) const tabId = makeConversationTabId(folderId, agentType, conversationId) const newTab: TabItemInternal = { @@ -763,13 +894,28 @@ export function TabProvider({ children }: TabProviderProps) { conversationId, agentType, title: resolvedTitle, + titleOverride, isPinned: pin, } + const insertTab = (items: TabItemInternal[]) => { + if (options?.placement !== "afterActive") { + return [...items, newTab] + } + const activeIndex = prevState.activeTabId + ? items.findIndex((tab) => tab.id === prevState.activeTabId) + : -1 + if (activeIndex < 0) { + return [...items, newTab] + } + const next = items.slice() + next.splice(activeIndex + 1, 0, newTab) + return next + } if (pin) { return { ...prevState, - rawTabs: [...prevState.rawTabs, newTab], + rawTabs: insertTab(prevState.rawTabs), activeTabId: tabId, } } @@ -792,7 +938,7 @@ export function TabProvider({ children }: TabProviderProps) { return { ...prevState, - rawTabs: [...prevState.rawTabs, newTab], + rawTabs: insertTab(prevState.rawTabs), activeTabId: tabId, } }) @@ -911,6 +1057,11 @@ export function TabProvider({ children }: TabProviderProps) { tb.folderId === it.folder_id && tb.agentType === it.agent_type ) + const remoteCarriesTitleOverride = + Object.prototype.hasOwnProperty.call(it, "title_override") + const titleOverride = remoteCarriesTitleOverride + ? normalizeTabTitleOverride(it.title_override) + : existing?.titleOverride return { id: existing?.id ?? canonicalId, kind: "conversation", @@ -919,7 +1070,8 @@ export function TabProvider({ children }: TabProviderProps) { agentType: it.agent_type, // Title/status are re-derived from `conversations` by the `tabs` // memo; carry the live runtime id forward for any tab already open. - title: existing?.title ?? t("loadingConversation"), + title: titleOverride ?? existing?.title ?? t("loadingConversation"), + titleOverride, isPinned: it.is_pinned, runtimeConversationId: existing?.runtimeConversationId, status: existing?.status, diff --git a/src/hooks/use-open-conversation-tab.ts b/src/hooks/use-open-conversation-tab.ts new file mode 100644 index 000000000..3de79ef8a --- /dev/null +++ b/src/hooks/use-open-conversation-tab.ts @@ -0,0 +1,70 @@ +"use client" + +import { useCallback } from "react" + +import { useAppWorkspace } from "@/contexts/app-workspace-context" +import { useTabContext } from "@/contexts/tab-context" +import { listAllConversations } from "@/lib/api" +import type { DbConversationSummary } from "@/lib/types" + +function deriveTabTitleFromRole(title: string | null | undefined) { + // Drop common list / heading prefixes from delegated task prompts so the tab + // reads like a responsibility label instead of a raw prompt fragment. + return title + ?.split(/\r?\n/) + .map((line) => line.trim()) + .map((line) => + line + .replace(/^(?:[-*+]\s+|\d+[.)]\s+|#{1,6}\s+)/, "") + .replace(/^(?:职责|角色|任务|role|task)\s*[::]\s*/i, "") + .trim() + ) + .find(Boolean) +} + +export function useOpenConversationTab() { + const { conversations, folders, addFolderToWorkspaceById } = useAppWorkspace() + const { openTab } = useTabContext() + + return useCallback( + async ( + conversationId: number, + options?: { + placement?: "end" | "afterActive" + title?: string | null + } + ): Promise => { + const conversation = + conversations.find((item) => item.id === conversationId) ?? + ( + await listAllConversations({ + include_children: true, + }) + ).find((item) => item.id === conversationId) + + if (!conversation) { + throw new Error(`Conversation ${conversationId} not found`) + } + + if (!folders.some((folder) => folder.id === conversation.folder_id)) { + await addFolderToWorkspaceById(conversation.folder_id) + } + + const roleTitle = deriveTabTitleFromRole(options?.title) + openTab( + conversation.folder_id, + conversation.id, + conversation.agent_type, + true, + roleTitle ?? conversation.title ?? undefined, + { + placement: options?.placement ?? "afterActive", + titleOverride: roleTitle != null, + } + ) + + return conversation + }, + [addFolderToWorkspaceById, conversations, folders, openTab] + ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 1667f3d5d..9e539e21d 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1180,7 +1180,10 @@ "closeOthers": "إغلاق البقية", "closeAll": "إغلاق الكل", "tileDisplay": "عرض متجانب", - "untileDisplay": "إلغاء التجانب" + "untileDisplay": "إلغاء التجانب", + "viewToggle": "العرض", + "tileAllTabs": "عرض كل علامات التبويب جنبًا إلى جنب", + "showCurrentTabOnly": "عرض علامة التبويب الحالية فقط" }, "fileWorkspace": { "files": "الملفات", @@ -2061,7 +2064,9 @@ "child_unknown": "الوكيل الفرعي: خطأ", "unknown": "مهمة غير معروفة" } - } + }, + "openInTab": "فتح في تبويب", + "openInTabFailed": "تعذر فتح تبويب المحادثة" }, "contentParts": { "showingTailOutput": "يتم عرض نهاية المخرجات أثناء البث لتحسين الأداء.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 8a9b3ca48..882814799 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1180,7 +1180,10 @@ "closeOthers": "Andere schließen", "closeAll": "Alle schließen", "tileDisplay": "Kachelansicht", - "untileDisplay": "Kachel beenden" + "untileDisplay": "Kachel beenden", + "viewToggle": "Ansicht", + "tileAllTabs": "Alle Tabs nebeneinander anzeigen", + "showCurrentTabOnly": "Nur aktuellen Tab anzeigen" }, "fileWorkspace": { "files": "Dateien", @@ -2061,7 +2064,9 @@ "child_unknown": "Subagent: Fehler", "unknown": "unbekannte Aufgabe" } - } + }, + "openInTab": "In Tab öffnen", + "openInTabFailed": "Unterhaltungstab konnte nicht geöffnet werden" }, "contentParts": { "showingTailOutput": "Zur besseren Performance wird während des Streamings nur die Endausgabe angezeigt.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 552975922..7c63fd242 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1180,7 +1180,10 @@ "closeOthers": "Close Others", "closeAll": "Close All", "tileDisplay": "Tile Display", - "untileDisplay": "Exit Tile" + "untileDisplay": "Exit Tile", + "viewToggle": "View", + "tileAllTabs": "Tile all tabs", + "showCurrentTabOnly": "Show current tab only" }, "fileWorkspace": { "files": "Files", @@ -2061,7 +2064,9 @@ "child_unknown": "subagent unknown error", "unknown": "unknown task" } - } + }, + "openInTab": "Open in tab", + "openInTabFailed": "Failed to open conversation tab" }, "contentParts": { "showingTailOutput": "Showing tail output while streaming for performance.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 33fba2892..7c7aff83a 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1180,7 +1180,10 @@ "closeOthers": "Cerrar otros", "closeAll": "Cerrar todo", "tileDisplay": "Vista en mosaico", - "untileDisplay": "Salir de mosaico" + "untileDisplay": "Salir de mosaico", + "viewToggle": "Vista", + "tileAllTabs": "Mostrar todas las pestañas en paralelo", + "showCurrentTabOnly": "Mostrar solo la pestaña actual" }, "fileWorkspace": { "files": "Archivos", @@ -2061,7 +2064,9 @@ "child_unknown": "subagente: error", "unknown": "tarea desconocida" } - } + }, + "openInTab": "Abrir en pestaña", + "openInTabFailed": "No se pudo abrir la pestaña de conversación" }, "contentParts": { "showingTailOutput": "Mostrando la salida final durante el streaming para mejorar el rendimiento.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 4586df1ae..ff2a5c1b4 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1180,7 +1180,10 @@ "closeOthers": "Fermer les autres", "closeAll": "Tout fermer", "tileDisplay": "Affichage en mosaïque", - "untileDisplay": "Quitter la mosaïque" + "untileDisplay": "Quitter la mosaïque", + "viewToggle": "Affichage", + "tileAllTabs": "Afficher tous les onglets côte à côte", + "showCurrentTabOnly": "Afficher seulement l’onglet actuel" }, "fileWorkspace": { "files": "Fichiers", @@ -2061,7 +2064,9 @@ "child_unknown": "sous-agent : erreur", "unknown": "tâche inconnue" } - } + }, + "openInTab": "Ouvrir dans un onglet", + "openInTabFailed": "Impossible d’ouvrir l’onglet de conversation" }, "contentParts": { "showingTailOutput": "Affichage de la fin de la sortie pendant le streaming pour de meilleures performances.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 8a1769a9c..9cde12ce7 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1180,7 +1180,10 @@ "closeOthers": "他を閉じる", "closeAll": "すべて閉じる", "tileDisplay": "タイル表示", - "untileDisplay": "タイル解除" + "untileDisplay": "タイル解除", + "viewToggle": "表示", + "tileAllTabs": "すべてのタブを並べて表示", + "showCurrentTabOnly": "現在のタブのみ表示" }, "fileWorkspace": { "files": "ファイル", @@ -2061,7 +2064,9 @@ "child_unknown": "サブエージェント: エラー", "unknown": "不明なタスク" } - } + }, + "openInTab": "タブで開く", + "openInTabFailed": "会話タブを開けませんでした" }, "contentParts": { "showingTailOutput": "パフォーマンスのため、ストリーミング中は末尾出力を表示しています。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index bac98d6c3..a25448e73 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1180,7 +1180,10 @@ "closeOthers": "다른 항목 닫기", "closeAll": "모두 닫기", "tileDisplay": "타일 표시", - "untileDisplay": "타일 해제" + "untileDisplay": "타일 해제", + "viewToggle": "보기", + "tileAllTabs": "모든 탭 나란히 표시", + "showCurrentTabOnly": "현재 탭만 표시" }, "fileWorkspace": { "files": "파일", @@ -2061,7 +2064,9 @@ "child_unknown": "서브에이전트: 오류", "unknown": "알 수 없는 작업" } - } + }, + "openInTab": "탭에서 열기", + "openInTabFailed": "대화 탭을 열지 못했습니다" }, "contentParts": { "showingTailOutput": "성능을 위해 스트리밍 중에는 출력의 끝부분만 표시합니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 711ad7b42..657cb4554 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1180,7 +1180,10 @@ "closeOthers": "Fechar outros", "closeAll": "Fechar tudo", "tileDisplay": "Exibição em mosaico", - "untileDisplay": "Sair do mosaico" + "untileDisplay": "Sair do mosaico", + "viewToggle": "Visualização", + "tileAllTabs": "Mostrar todas as abas lado a lado", + "showCurrentTabOnly": "Mostrar apenas a aba atual" }, "fileWorkspace": { "files": "Arquivos", @@ -2061,7 +2064,9 @@ "child_unknown": "subagente: erro", "unknown": "tarefa desconhecida" } - } + }, + "openInTab": "Abrir em aba", + "openInTabFailed": "Falha ao abrir a aba da conversa" }, "contentParts": { "showingTailOutput": "Mostrando a saída final durante o streaming para melhor desempenho.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 4c099d44a..d303dbacf 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1180,7 +1180,10 @@ "closeOthers": "关闭其它", "closeAll": "关闭所有", "tileDisplay": "平铺显示", - "untileDisplay": "取消平铺" + "untileDisplay": "取消平铺", + "viewToggle": "视图", + "tileAllTabs": "并列显示所有标签页", + "showCurrentTabOnly": "只显示当前标签页" }, "fileWorkspace": { "files": "文件", @@ -2061,7 +2064,9 @@ "child_unknown": "子代理未知错误", "unknown": "未知任务" } - } + }, + "openInTab": "新标签页", + "openInTabFailed": "打开会话标签页失败" }, "contentParts": { "showingTailOutput": "为保证性能,流式输出时仅显示尾部内容。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 70669f9e6..a01cd2abe 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1180,7 +1180,10 @@ "closeOthers": "關閉其它", "closeAll": "關閉所有", "tileDisplay": "平鋪顯示", - "untileDisplay": "取消平鋪" + "untileDisplay": "取消平鋪", + "viewToggle": "視圖", + "tileAllTabs": "並列顯示所有標籤頁", + "showCurrentTabOnly": "只顯示目前標籤頁" }, "fileWorkspace": { "files": "檔案", @@ -2061,7 +2064,9 @@ "child_unknown": "子代理未知錯誤", "unknown": "未知任務" } - } + }, + "openInTab": "新標籤頁", + "openInTabFailed": "開啟會話標籤頁失敗" }, "contentParts": { "showingTailOutput": "為確保效能,串流輸出時僅顯示尾端內容。", diff --git a/src/lib/types.ts b/src/lib/types.ts index 627a78d88..42a208b1c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -288,6 +288,9 @@ export interface OpenedTab { position: number is_active: boolean is_pinned: boolean + /** Optional display-title override for restored/synced tabs, e.g. + * delegated sub-agent tabs named by their responsibility. */ + title_override?: string | null } export interface DbConversationSummary { From cf9d6bba400ce5e265f832658158b463132987ef Mon Sep 17 00:00:00 2001 From: H1d3rOne Date: Wed, 17 Jun 2026 18:07:31 +0800 Subject: [PATCH 2/3] Fix multi-agent tab tests --- src/components/chat/sub-agent-overlay.test.tsx | 8 ++++++++ src/components/message/delegated-sub-thread.test.tsx | 9 +++++++++ .../message/sub-agent-session-dialog.test.tsx | 7 +++++++ src/components/settings/channel-events-tab.test.tsx | 11 +++++------ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/components/chat/sub-agent-overlay.test.tsx b/src/components/chat/sub-agent-overlay.test.tsx index e1a161be3..a3a21676d 100644 --- a/src/components/chat/sub-agent-overlay.test.tsx +++ b/src/components/chat/sub-agent-overlay.test.tsx @@ -29,6 +29,12 @@ vi.mock("@/contexts/acp-connections-context", async () => { } }) +const mockOpenConversationTab = vi.fn(() => Promise.resolve()) + +vi.mock("@/hooks/use-open-conversation-tab", () => ({ + useOpenConversationTab: () => mockOpenConversationTab, +})) + // SubAgentSessionDialog pulls in MessageListView + the runtime provider tree. // Stub it to a sentinel exposing the open state + target conversation id. vi.mock("@/components/message/sub-agent-session-dialog", () => ({ @@ -84,6 +90,8 @@ function source( describe("SubAgentOverlay", () => { beforeEach(() => { bindings = {} + mockOpenConversationTab.mockReset() + mockOpenConversationTab.mockResolvedValue(undefined) mockedHook.mockReset() mockedHook.mockImplementation((id: string) => ({ binding: bindings[id], diff --git a/src/components/message/delegated-sub-thread.test.tsx b/src/components/message/delegated-sub-thread.test.tsx index 29e94214b..d87535a00 100644 --- a/src/components/message/delegated-sub-thread.test.tsx +++ b/src/components/message/delegated-sub-thread.test.tsx @@ -15,6 +15,7 @@ vi.mock("@/hooks/use-delegated-sub-session", () => ({ // connections store (to badge "awaiting approval"). It renders no body and // answers no permission inline — so those are the only contexts to stub. let mockChildConnection: unknown = undefined +const mockOpenConversationTab = vi.fn(() => Promise.resolve()) vi.mock("@/contexts/acp-connections-context", async () => { const actual = await vi.importActual< @@ -31,6 +32,10 @@ vi.mock("@/contexts/acp-connections-context", async () => { } }) +vi.mock("@/hooks/use-open-conversation-tab", () => ({ + useOpenConversationTab: () => mockOpenConversationTab, +})) + // SubAgentSessionDialog pulls in MessageListView + useConversationRuntime, which // would require the full runtime provider tree. Stub it to a sentinel exposing // the open state + child id so we can assert the "Open conversation" button @@ -109,6 +114,8 @@ function childConnWith(pendingPermission: unknown) { describe("DelegatedSubThread", () => { beforeEach(() => { mockChildConnection = undefined + mockOpenConversationTab.mockReset() + mockOpenConversationTab.mockResolvedValue(undefined) mockedHook.mockReturnValue({ binding: undefined, detail: null, @@ -353,6 +360,8 @@ describe("DelegatedSubThread", () => { describe("DelegatedSubThread (async ack semantics)", () => { beforeEach(() => { mockChildConnection = undefined + mockOpenConversationTab.mockReset() + mockOpenConversationTab.mockResolvedValue(undefined) mockedHook.mockReturnValue({ binding: undefined, detail: null, diff --git a/src/components/message/sub-agent-session-dialog.test.tsx b/src/components/message/sub-agent-session-dialog.test.tsx index 29b750b2b..f278a5676 100644 --- a/src/components/message/sub-agent-session-dialog.test.tsx +++ b/src/components/message/sub-agent-session-dialog.test.tsx @@ -17,6 +17,7 @@ const mockSetLiveOwnsActiveTurn = vi.fn() const mockGetSession = vi.fn() const mockGetTimelineTurns = vi.fn(() => []) const mockRespondPermission = vi.fn() +const mockOpenConversationTab = vi.fn(() => Promise.resolve()) // syncTurnMetadata returns a cancel function; hand back a spy so tests can // assert both that the backfill is kicked off and that it's cancelled on close. const mockSyncCancel = vi.fn() @@ -118,6 +119,10 @@ vi.mock("@/hooks/use-conversation-detail", () => ({ useConversationDetail: () => mockDetailState, })) +vi.mock("@/hooks/use-open-conversation-tab", () => ({ + useOpenConversationTab: () => mockOpenConversationTab, +})) + // MessageListView pulls in the full runtime provider + virtualization // stack. Stub it to a sentinel that records the props we care about, // so the read-only-mode test can assert that no `onReload`/`onNewSession`/ @@ -194,6 +199,8 @@ describe("SubAgentSessionDialog", () => { mockGetSession.mockReset() mockGetTimelineTurns.mockClear() mockRespondPermission.mockReset() + mockOpenConversationTab.mockReset() + mockOpenConversationTab.mockResolvedValue(undefined) mockSyncCancel.mockReset() mockSyncTurnMetadata.mockClear() mockSyncTurnMetadata.mockReturnValue(mockSyncCancel) diff --git a/src/components/settings/channel-events-tab.test.tsx b/src/components/settings/channel-events-tab.test.tsx index bbc92b79b..0df0cc02b 100644 --- a/src/components/settings/channel-events-tab.test.tsx +++ b/src/components/settings/channel-events-tab.test.tsx @@ -56,11 +56,11 @@ describe("ChannelEventsTab event filter (opt-in user_prompt_sent)", () => { it("defaults user_prompt_sent OFF under a null filter while other events stay ON", async () => { mockGetFilter.mockResolvedValue(null) renderTab() - await waitFor(() => expect(mockGetFilter).toHaveBeenCalled()) + const userMessageSwitch = await screen.findByRole("switch", { + name: "User Message", + }) - expect( - screen.getByRole("switch", { name: "User Message" }) - ).not.toBeChecked() + expect(userMessageSwitch).not.toBeChecked() expect(screen.getByRole("switch", { name: "Turn Complete" })).toBeChecked() expect( screen.getByRole("switch", { name: "Permission Request" }) @@ -70,9 +70,8 @@ describe("ChannelEventsTab event filter (opt-in user_prompt_sent)", () => { it("enabling user_prompt_sent persists an explicit list including it (never null)", async () => { mockGetFilter.mockResolvedValue(null) renderTab() - await waitFor(() => expect(mockGetFilter).toHaveBeenCalled()) - fireEvent.click(screen.getByRole("switch", { name: "User Message" })) + fireEvent.click(await screen.findByRole("switch", { name: "User Message" })) await waitFor(() => expect(mockSetFilter).toHaveBeenCalled()) const calls = mockSetFilter.mock.calls From e4b4184867529d1867b0cfd96f9ced1ad92aae19 Mon Sep 17 00:00:00 2001 From: H1d3rOne Date: Wed, 17 Jun 2026 20:07:50 +0800 Subject: [PATCH 3/3] Use port 4000 for desktop dev --- next.config.ts | 2 +- src-tauri/tauri.conf.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/next.config.ts b/next.config.ts index e8b885fa7..6122aa18b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -31,7 +31,7 @@ const nextConfig: NextConfig = { images: { unoptimized: true, }, - assetPrefix: isProd ? undefined : `http://${internalHost}:3000`, + assetPrefix: isProd ? undefined : `http://${internalHost}:4000`, } export default withNextIntl(nextConfig) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4af7e78c1..ad75c8222 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,8 +4,8 @@ "version": "0.15.12", "identifier": "app.codeg", "build": { - "beforeDevCommand": "pnpm tauri:before-dev", - "devUrl": "http://localhost:3000", + "beforeDevCommand": "pnpm tauri:prepare-sidecars && pnpm exec next dev --turbopack -p 4000", + "devUrl": "http://localhost:4000", "beforeBuildCommand": "pnpm tauri:before-build", "frontendDist": "../out" },