Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
11 changes: 10 additions & 1 deletion src-tauri/src/commands/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2479,6 +2479,7 @@ mod tests {
position: 0,
is_active: false,
is_pinned: true,
title_override: None,
}
}

Expand All @@ -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 {
Expand All @@ -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())
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/db/entities/opened_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub struct Model {
pub position: i32,
pub is_active: bool,
pub is_pinned: bool,
pub title_override: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}
2 changes: 2 additions & 0 deletions src-tauri/src/db/migration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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),
]
}
}
8 changes: 8 additions & 0 deletions src-tauri/src/db/service/tab_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub async fn list_all_tabs<C: ConnectionTrait>(conn: &C) -> Result<Vec<OpenedTab
position: r.position,
is_active: r.is_active,
is_pinned: r.is_pinned,
title_override: r.title_override,
})
})
.collect())
Expand Down Expand Up @@ -121,6 +122,12 @@ pub async fn save_all_tabs<C: ConnectionTrait>(
} 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,
Expand All @@ -130,6 +137,7 @@ pub async fn save_all_tabs<C: ConnectionTrait>(
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),
};
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/models/folder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// Response for `list_opened_tabs`: the persisted tab set plus the current
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
8 changes: 8 additions & 0 deletions src/components/chat/sub-agent-overlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -84,6 +90,8 @@ function source(
describe("SubAgentOverlay", () => {
beforeEach(() => {
bindings = {}
mockOpenConversationTab.mockReset()
mockOpenConversationTab.mockResolvedValue(undefined)
mockedHook.mockReset()
mockedHook.mockImplementation((id: string) => ({
binding: bindings[id],
Expand Down
45 changes: 34 additions & 11 deletions src/components/chat/sub-agent-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 = (
<div className="min-w-0 flex-1 space-y-1">
Expand Down Expand Up @@ -166,18 +176,31 @@ const SubAgentOverlayRow = memo(function SubAgentOverlayRow({
return (
<>
{clickable ? (
<button
type="button"
<div
data-testid="sub-agent-row"
onClick={() => setDialogOpen(true)}
className="flex w-full items-center gap-2 rounded-lg border bg-transparent px-2 py-1.5 text-left transition-colors hover:bg-muted/60"
// No aria-label: let the row content (agent name + task) name the
// button so screen readers can tell rows apart. `title` stays for the
// pointer tooltip.
title={t("openDetail")}
className="flex w-full items-stretch overflow-hidden rounded-lg border bg-transparent transition-colors hover:bg-muted/60"
>
{rowBody}
</button>
<button
type="button"
onClick={() => setDialogOpen(true)}
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
// No aria-label: let the row content (agent name + task) name the
// button so screen readers can tell rows apart. `title` stays for the
// pointer tooltip.
title={t("openDetail")}
>
{rowBody}
</button>
<button
type="button"
onClick={handleOpenTab}
className="flex shrink-0 items-center border-l px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t("openInTab")}
aria-label={t("openInTab")}
>
<PanelRight className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div
data-testid="sub-agent-row"
Expand Down
9 changes: 9 additions & 0 deletions src/components/message/delegated-sub-thread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -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
Expand Down Expand Up @@ -109,6 +114,8 @@ function childConnWith(pendingPermission: unknown) {
describe("DelegatedSubThread", () => {
beforeEach(() => {
mockChildConnection = undefined
mockOpenConversationTab.mockReset()
mockOpenConversationTab.mockResolvedValue(undefined)
mockedHook.mockReturnValue({
binding: undefined,
detail: null,
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 38 additions & 13 deletions src/components/message/delegated-sub-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@
*/

import { useState } from "react"
import { Eye } from "lucide-react"
import { Eye, PanelRight } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"

import { AgentIcon } from "@/components/agent-icon"
import { AGENT_LABELS } from "@/lib/types"
import type { ToolCallState } from "@/lib/adapters/ai-elements-adapter"
import { StatusBadge } from "@/components/message/delegation-status-badge"
import { SubAgentSessionDialog } from "@/components/message/sub-agent-session-dialog"
import { useDelegationCardModel } from "@/hooks/use-delegation-card-model"
import { useOpenConversationTab } from "@/hooks/use-open-conversation-tab"

interface Props {
parentToolUseId: string
Expand Down Expand Up @@ -77,6 +79,7 @@ export function DelegatedSubThread({
state,
meta,
})
const openConversationTab = useOpenConversationTab()

// A snapshot replay with an empty/unparseable input AND no live binding has
// no useful card to draw — fall through to the standard renderer instead of
Expand All @@ -85,6 +88,14 @@ export function DelegatedSubThread({
return null
}

const handleOpenTab = () => {
if (childConversationId == null) return
openConversationTab(childConversationId, { title: task }).catch((err) => {
console.error("[DelegatedSubThread] open conversation tab failed:", err)
toast.error(t("openInTabFailed"))
})
}

return (
<div
data-testid="delegated-sub-thread"
Expand Down Expand Up @@ -122,18 +133,32 @@ export function DelegatedSubThread({
</div>
</div>
{childConversationId != null && (
<button
type="button"
onClick={() => setDialogOpen(true)}
className="shrink-0 flex items-center gap-1.5 px-3 border-l border-border text-xs font-medium text-foreground/80 hover:bg-muted/60 hover:text-foreground transition-colors"
title={t("openDetail")}
aria-label={t("openDetail")}
>
<Eye className="h-3.5 w-3.5" />
<span className="hidden @[24rem]/delegcard:inline">
{t("openDetail")}
</span>
</button>
<div className="shrink-0 flex border-l border-border">
<button
type="button"
onClick={() => setDialogOpen(true)}
className="flex items-center gap-1.5 px-3 text-xs font-medium text-foreground/80 transition-colors hover:bg-muted/60 hover:text-foreground"
title={t("openDetail")}
aria-label={t("openDetail")}
>
<Eye className="h-3.5 w-3.5" />
<span className="hidden @[24rem]/delegcard:inline">
{t("openDetail")}
</span>
</button>
<button
type="button"
onClick={handleOpenTab}
className="flex items-center gap-1.5 border-l border-border px-3 text-xs font-medium text-foreground/80 transition-colors hover:bg-muted/60 hover:text-foreground"
title={t("openInTab")}
aria-label={t("openInTab")}
>
<PanelRight className="h-3.5 w-3.5" />
<span className="hidden @[30rem]/delegcard:inline">
{t("openInTab")}
</span>
</button>
</div>
)}
</div>
{childConversationId != null && (
Expand Down
Loading
Loading