Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ async function renderSortableTab({
const module = await import('./SortableTab')
return module.default({
tab: makeTerminalTab() as never,
agent: null,
unifiedTabId: 'terminal-tab-1',
groupId: 'group-1',
tabCount: 1,
Expand Down
27 changes: 17 additions & 10 deletions src/renderer/src/components/tab-bar/SortableTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { useCallback, useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { X, Minimize2, Pin } from 'lucide-react'
import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration'
import { useTabAgent } from '@/lib/use-tab-agent'
import { getAgentLabel } from '@/lib/agent-catalog'
import { basename } from '@/lib/path'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { Input } from '@/components/ui/input'
import { TabHoverCard } from './TabHoverCard'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { TerminalAgent } from '../../../../shared/terminal-agent'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
import { useAppStore } from '../../store'
import {
Expand All @@ -33,9 +33,18 @@ import {
terminalTabHasUnreadActivity
} from './terminal-tab-activity-status'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus'
import type { NativeChatTabSession } from './tab-session-history-switch'

type SortableTabProps = {
tab: TerminalTab
/** What the strip draws for a terminal or native chat tab; never a source of agent identity. */
type SortableTabModel = Pick<
TerminalTab,
'id' | 'worktreeId' | 'title' | 'customTitle' | 'color' | 'shellOverride' | 'launchAgent'
>

export type SortableTabProps = {
tab: SortableTabModel
/** The tab's agent, resolved by the caller from its own source of truth (pane signals or the chat's provider). */
agent: TerminalAgent | null
unifiedTabId: string
groupId: string
tabCount: number
Expand Down Expand Up @@ -63,12 +72,13 @@ type SortableTabProps = {
/** Toggle the tab between terminal and native chat view. */
onToggleViewMode?: () => void
canSplitTerminal?: boolean
/** Set only for a native chat tab: the chat session it shows. */
structuredSessionId?: string
/** Set only for a native chat tab. */
nativeChat?: NativeChatTabSession
}

export default function SortableTab({
tab,
agent: tabAgent,
unifiedTabId,
groupId,
tabCount,
Expand All @@ -93,7 +103,7 @@ export default function SortableTab({
isChatView = false,
onToggleViewMode,
canSplitTerminal = true,
structuredSessionId
nativeChat
}: SortableTabProps): React.JSX.Element {
// Why: agent-completion unread exists even with terminal-attention off; collapse both sources to one primitive so unrelated tabs don't re-render.
const hasUnreadActivity = useAppStore((s) =>
Expand All @@ -118,9 +128,6 @@ export default function SortableTab({
// Why: shellOverride is stamped at create time, so changing the default shell later won't repaint existing tabs.
const shellForIcon = tab.shellOverride

// Why: use hook status + title evidence so the icon reflects the harness running now, not just the launch command.
const tabAgent = useTabAgent(tab)

// Why: with a provider icon shown, strip the agent's own leading glyph so the tab doesn't show two icons for one agent.
const displayTitle =
tab.customTitle ?? (tabAgent ? stripLeadingAgentTitleDecoration(tab.title) : tab.title)
Expand Down Expand Up @@ -412,7 +419,7 @@ export default function SortableTab({
isChatView={isChatView}
onToggleViewMode={onToggleViewMode}
canSplitTerminal={canSplitTerminal}
structuredSessionId={structuredSessionId}
nativeChat={nativeChat}
/>
</>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ vi.mock('@dnd-kit/sortable', () => ({
})
}))

vi.mock('@/lib/use-tab-agent', () => ({ useTabAgent: () => null }))

vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Expand Down Expand Up @@ -144,6 +142,7 @@ function Harness({ tab }: { tab: TerminalTab }): ReactElement {
return (
<SortableTab
tab={tab}
agent={null}
unifiedTabId="unified-1"
groupId="group-1"
tabCount={1}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,17 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('./TabSessionSurfaceSwitchMenuItems', () => ({
TabSessionSurfaceSwitchMenuItems: ({
tab,
structuredSessionId,
nativeChat,
leadingSeparator
}: {
tab: { id: string }
structuredSessionId?: string
nativeChat?: { sessionId: string }
leadingSeparator: boolean
}) => (
<div
data-testid="session-surface-switch"
data-tab-id={tab.id}
data-structured-session-id={structuredSessionId ?? ''}
data-structured-session-id={nativeChat?.sessionId ?? ''}
data-leading-separator={String(leadingSeparator)}
/>
)
Expand Down Expand Up @@ -116,16 +116,7 @@ function renderMenu(overrides: Partial<ComponentProps<typeof SortableTabContextM
act(() => {
root.render(
<SortableTabContextMenu
tab={{
id: 'term-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'bash',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}}
tab={{ id: 'term-1', worktreeId: 'wt-1', color: null }}
unifiedTabId="tab-1"
groupId="group-1"
isActive
Expand Down Expand Up @@ -301,7 +292,7 @@ describe('SortableTabContextMenu', () => {
})

it('offers the session-history chat/CLI move right above Pin Tab', () => {
const { container } = renderMenu({ structuredSessionId: 'orca-chat-1' })
const { container } = renderMenu({ nativeChat: { sessionId: 'orca-chat-1', agent: 'codex' } })
const marker = getSurfaceSwitchMarker(container)

expect(marker.getAttribute('data-tab-id')).toBe('term-1')
Expand Down
11 changes: 6 additions & 5 deletions src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortc
import { translate } from '@/i18n/i18n'
import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection'
import { TabSessionSurfaceSwitchMenuItems } from './TabSessionSurfaceSwitchMenuItems'
import type { NativeChatTabSession } from './tab-session-history-switch'
import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing'
import { CopyTabIdMenuItem } from './CopyTabIdMenuItem'

Expand Down Expand Up @@ -89,7 +90,7 @@ const TAB_COLORS = [
] as const

type SortableTabContextMenuProps = {
tab: TerminalTab
tab: Pick<TerminalTab, 'id' | 'worktreeId' | 'color'>
unifiedTabId: string
groupId: string
isActive: boolean
Expand Down Expand Up @@ -118,8 +119,8 @@ type SortableTabContextMenuProps = {
/** Toggle the tab between terminal and native chat view. */
onToggleViewMode?: () => void
canSplitTerminal?: boolean
/** Set only for a native chat tab: the chat session it shows. */
structuredSessionId?: string
/** Set only for a native chat tab. */
nativeChat?: NativeChatTabSession
}

export function SortableTabContextMenu({
Expand All @@ -146,7 +147,7 @@ export function SortableTabContextMenu({
isChatView = false,
onToggleViewMode,
canSplitTerminal = true,
structuredSessionId
nativeChat
}: SortableTabContextMenuProps): React.JSX.Element {
const keybindings = useAppStore((state) => state.keybindings)
const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings)
Expand Down Expand Up @@ -193,7 +194,7 @@ export function SortableTabContextMenu({
) : null}
<TabSessionSurfaceSwitchMenuItems
tab={tab}
structuredSessionId={structuredSessionId}
nativeChat={nativeChat}
leadingSeparator={!showTerminalViewSwitch}
/>
<DropdownMenuSeparator />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ vi.mock('./SortableTab', () => ({
}
}))

vi.mock('./TerminalSortableTab', () => ({
default: function SortableTab(props: Record<string, unknown>) {
return { type: 'SortableTab', props }
}
}))

vi.mock('./EditorFileTab', () => ({
default: function EditorFileTab(props: Record<string, unknown>) {
return { type: 'EditorFileTab', props }
Expand Down
43 changes: 40 additions & 3 deletions src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { i18n } from '@/i18n/i18n'
import type { TabBarItem } from './tab-bar-item-model'
import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types'
import type { AgentType } from '../../../../shared/agent-status-types'
import {
renderTabBarItems,
type TabBarItemSurfaceProps,
Expand All @@ -22,6 +23,8 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true

type TabProps = {
tab?: { id: string; title: string }
agent?: string | null
nativeChat?: { sessionId: string; agent: string | undefined }
isActive: boolean
isPinned: boolean
onActivate: (id: string) => void
Expand Down Expand Up @@ -53,7 +56,17 @@ vi.mock('./EditorFileTab', () => ({
}))

/** Rebuilt on every call, like the strip's projections: equal content, never the same objects. */
function buildItems({ terminalPinned = false, browserTitle = 'Example' } = {}): TabBarItem[] {
function buildItems({
terminalPinned = false,
browserTitle = 'Example',
sessionLabel = 'Session',
sessionAgent
}: {
terminalPinned?: boolean
browserTitle?: string
sessionLabel?: string
sessionAgent?: AgentType
} = {}): TabBarItem[] {
return [
{
type: 'terminal',
Expand Down Expand Up @@ -117,7 +130,8 @@ function buildItems({ terminalPinned = false, browserTitle = 'Example' } = {}):
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'agent-session',
label: 'Session',
...(sessionAgent ? { agentSessionAgent: sessionAgent } : {}),
label: sessionLabel,
customLabel: null,
color: null,
sortOrder: 3,
Expand All @@ -133,6 +147,8 @@ type StripInputs = {
managedBrowserCreationEnabled?: boolean
terminalPinned?: boolean
browserTitle?: string
sessionLabel?: string
sessionAgent?: AgentType
activeTabType?: WorkspaceVisibleTabType
activeClientHostedBrowserRowId?: string | null
statusByRelativePath?: TabBarItemSurfaceRuntime['statusByRelativePath']
Expand All @@ -144,6 +160,8 @@ function Strip({
managedBrowserCreationEnabled = false,
terminalPinned,
browserTitle,
sessionLabel,
sessionAgent,
activeTabType = 'terminal',
activeClientHostedBrowserRowId = null,
statusByRelativePath = STATUS_BY_RELATIVE_PATH
Expand Down Expand Up @@ -187,7 +205,7 @@ function Strip({
return (
<>
{renderTabBarItems({
items: buildItems({ terminalPinned, browserTitle }),
items: buildItems({ terminalPinned, browserTitle, sessionLabel, sessionAgent }),
props,
runtime,
actions,
Expand Down Expand Up @@ -338,6 +356,25 @@ describe('tab strip rows', () => {
expect(tabRenders).toHaveLength(TAB_IDS.length + 1)
})

it.each([
'Hermes automation deprecation',
'Claude Code review',
'Review Grok usage',
'OpenCode launch-flow design'
])('shows a native chat with its own provider when its name is "%s"', (sessionLabel) => {
renderStrip({ sessionLabel, sessionAgent: 'codex' })

expect(lastRender('session-1').tab?.title).toBe(sessionLabel)
expect(lastRender('session-1').agent).toBe('codex')
expect(lastRender('session-1').nativeChat).toEqual({ sessionId: 'session-1', agent: 'codex' })
})

it('shows no agent for a native chat whose provider is not recorded', () => {
renderStrip({ sessionLabel: 'Hermes automation deprecation' })

expect(lastRender('session-1').agent).toBeNull()
})

it.each(TAB_IDS)('retires a client-hosted row selection when %s is activated', (tabId) => {
renderStrip()
selectClientHostedBrowserRow({
Expand Down
32 changes: 14 additions & 18 deletions src/renderer/src/components/tab-bar/TabBarItemRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { shallow } from 'zustand/shallow'
import type { GitFileStatus } from '../../../../shared/git-status-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import type { OpenFile } from '../../store/slices/editor'
import SortableTab from './SortableTab'
import TerminalSortableTab from './TerminalSortableTab'
import EditorFileTab from './EditorFileTab'
import BrowserTab from './BrowserTab'
import type { DropIndicator } from './drop-indicator'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
import { getTabDragLabel, resolveTerminalItemTab, type TabBarItem } from './tab-bar-item-model'
import type { TabBarItemActions } from './use-tab-bar-item-actions'
import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name'
import { agentTypeToIconAgent } from '@/lib/agent-status'

// Why only values and `actions`: anything a tab draws must be a compared prop, or a skipped render shows it stale.
type TabBarItemRowProps = {
Expand Down Expand Up @@ -96,7 +96,7 @@ function TabBarItemRow({
}
if (item.type === 'terminal') {
return (
<SortableTab
<TerminalSortableTab
{...sortableTabProps}
tab={resolveTerminalItemTab(item.data, generatedTabTitlesEnabled)}
canToggleViewMode={canToggleViewMode}
Expand All @@ -109,28 +109,24 @@ function TabBarItemRow({
)
}
if (item.type === 'agent-session') {
const structuredTab: TerminalTab = {
id: item.id,
ptyId: null,
worktreeId,
title: conversationName ?? item.data.label,
customTitle: item.data.customLabel,
color: item.data.color,
sortOrder: item.data.sortOrder,
createdAt: item.data.createdAt,
...(isTuiAgent(item.data.agentSessionAgent)
? { launchAgent: item.data.agentSessionAgent }
: {})
}
const nativeChat = { sessionId: item.data.entityId, agent: item.data.agentSessionAgent }
return (
<SortableTab
{...sortableTabProps}
tab={structuredTab}
tab={{
id: item.id,
worktreeId,
title: conversationName ?? item.data.label,
customTitle: item.data.customLabel,
color: item.data.color
}}
// Why: a chat records its provider, so its title or turn state must never re-identify it.
agent={agentTypeToIconAgent(nativeChat.agent)}
isExpanded={false}
onActivate={actions.activateAgentSession}
onToggleExpand={() => {}}
canSplitTerminal={false}
structuredSessionId={item.data.entityId}
nativeChat={nativeChat}
/>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,10 @@ function renderItemsNow(structuredSessionId?: string): void {
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<TabSessionSurfaceSwitchMenuItems
tab={{ id: 'tab-1', worktreeId: 'wt-1', launchAgent: 'claude' }}
structuredSessionId={structuredSessionId}
tab={{ id: 'tab-1', worktreeId: 'wt-1' }}
nativeChat={
structuredSessionId ? { sessionId: structuredSessionId, agent: 'claude' } : undefined
}
leadingSeparator
/>
</DropdownMenuContent>
Expand Down
Loading
Loading