diff --git a/src/renderer/src/components/native-chat/structured-agent-session-pr-link-observer.ts b/src/renderer/src/components/native-chat/structured-agent-session-pr-link-observer.ts new file mode 100644 index 00000000000..64c3b94b411 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-pr-link-observer.ts @@ -0,0 +1,44 @@ +import { useAppStore } from '@/store' +import { sameStructuredReadTarget } from '@/attention/agent-subject-read-actions' +import { structuredAgentSessionTargetForTab } from '@/runtime/structured-agent-session-owner' +import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' +import { getStructuredAgentSessionTabs } from './structured-agent-session-tabs' + +/** Records PRs a chat's tools print (e.g. `gh pr create`) on its workspace, like terminal output. */ +export function createStructuredAgentSessionPRLinkObserver( + sessionId: string, + target: RuntimeClientTarget +): (event: AgentSessionSubscribeEvent) => void { + const detect = createTerminalGitHubPRLinkDetector() + return (event) => { + // Why: only live batches; replayed history would re-link PRs the user may have unlinked. + if (event.type !== 'batch') { + return + } + const links = event.batch.items.flatMap((item) => + item.body.kind === 'tool-call' && item.body.output?.head + ? detect(`${item.body.output.head}\n`) + : [] + ) + if (!links.length) { + return + } + const state = useAppStore.getState() + const tab = getStructuredAgentSessionTabs(state.unifiedTabsByWorktree).find((candidate) => { + const tabTarget = + candidate.entityId === sessionId && structuredAgentSessionTargetForTab(state, candidate) + return Boolean(tabTarget && sameStructuredReadTarget(tabTarget, target)) + }) + if (!tab) { + return + } + for (const link of links) { + state.observeTerminalGitHubPullRequestLink(tab.worktreeId, link, { + kind: 'agent-session', + tabId: tab.id + }) + } + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index 6a8ab21ca9b..7cb1df87dcc 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -19,6 +19,7 @@ import { readStructuredAgentSessionOlderPage } from './structured-agent-session-older-page-read' import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' +import { createStructuredAgentSessionPRLinkObserver } from './structured-agent-session-pr-link-observer' import type { StructuredAgentSessionReadSnapshot, @@ -61,6 +62,7 @@ function createReadOwner( let captureActiveHistoryReadGuard = (): (() => boolean) => retiredHistoryRead const activations = new Set() const listeners = new Set<() => void>() + const observePRLinks = createStructuredAgentSessionPRLinkObserver(sessionId, target) const emit = (): void => { for (const listener of listeners) { @@ -196,7 +198,10 @@ function createReadOwner( } const transport = startStructuredAgentSessionReadTransport({ applyRequests: apply, - applyEvent: (event, options) => apply({ type: 'event', event, ...options }), + applyEvent: (event, options) => { + apply({ type: 'event', event, ...options }) + observePRLinks(event) + }, applyError: (message, refusal) => { // Once loaded, a failure with no host refusal attached is treated as lost contact: the // transport retries it and the chat stays as it is. A refusal is the host's answer: shown. diff --git a/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx b/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx index 6953f23ba74..89119b903cc 100644 --- a/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx @@ -1,6 +1,8 @@ import React from 'react' +import { Terminal } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { resolveNotificationTabOwner } from '@/attention/notification-subject-owner' import { terminalLayoutContainsLeaf } from '../../../../shared/workspace-session-pane-ownership' @@ -24,12 +26,32 @@ function useReferenceTabs(workspace: Worktree): Tab[] { }) } +function focusOrigin(workspace: Worktree, tab: Tab, paneKey: string | undefined): void { + const state = useAppStore.getState() + if (!state.setActiveWorktree(workspace.id, workspace.hostId)) { + return + } + state.activateTab(tab.id, { worktreeId: workspace.id }) + const pane = paneKey ? parsePaneKey(paneKey) : null + if ( + tab.contentType === 'terminal' && + pane && + pane.tabId === tab.entityId && + state.tabsByWorktree[workspace.id]?.some((entry) => entry.id === tab.entityId) && + terminalLayoutContainsLeaf(state.terminalLayoutsByTabId[tab.entityId]?.root, pane.leafId) + ) { + activateTabAndFocusPane(tab.entityId, pane.leafId) + } +} + export function WorkspaceAttachmentOrigins({ item, - workspace + workspace, + compact = false }: { item: WorkspaceAttachment workspace: Worktree + compact?: boolean }): React.JSX.Element | null { const tabs = useReferenceTabs(workspace) const origins = item.origins?.filter((origin) => origin.kind === 'observed') ?? [] @@ -37,7 +59,9 @@ export function WorkspaceAttachmentOrigins({ return null } return ( -
+
{origins.map((origin) => { const tab = tabs.find( (candidate) => @@ -51,6 +75,35 @@ export function WorkspaceAttachmentOrigins({ origin.agent || translate('workspace.links.terminal', 'Terminal') const description = translate('workspace.links.seenIn', 'Seen in {{label}}', { label }) + if (compact) { + const unavailable = translate('workspace.links.unavailableTab', 'Tab unavailable') + return ( + + + + + + {tab ? description : `${description} · ${unavailable}`} + + + ) + } return tab ? (
) : null} - {item.origins?.length ? ( - - ) : null}
) })} diff --git a/src/renderer/src/components/sidebar/WorktreeCardReferenceRow.tsx b/src/renderer/src/components/sidebar/WorktreeCardReferenceRow.tsx index 51a4d81c63b..777d490b9e4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardReferenceRow.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardReferenceRow.tsx @@ -31,6 +31,7 @@ export function WorktreeCardReferenceRow({ url, onOpen, onManage, + origins, actions }: { review: WorktreeCardPrDisplay | null @@ -43,6 +44,7 @@ export function WorktreeCardReferenceRow({ url?: string onOpen: (url: string, event: MouseEvent) => void onManage?: (event: MouseEvent) => void + origins?: ReactNode actions?: ReactNode }): React.JSX.Element { return ( @@ -127,6 +129,7 @@ export function WorktreeCardReferenceRow({ {provider === 'GitHub' ? null : `· ${provider}`} {source ? ` · ${source}` : ''} + {origins} {actions} diff --git a/src/renderer/src/lib/workspace-attachment-terminal-origin.test.ts b/src/renderer/src/lib/workspace-attachment-observed-origin.test.ts similarity index 74% rename from src/renderer/src/lib/workspace-attachment-terminal-origin.test.ts rename to src/renderer/src/lib/workspace-attachment-observed-origin.test.ts index 33a170027f7..cfc843ab742 100644 --- a/src/renderer/src/lib/workspace-attachment-terminal-origin.test.ts +++ b/src/renderer/src/lib/workspace-attachment-observed-origin.test.ts @@ -3,7 +3,10 @@ import type { AppState } from '@/store/types' import { makeTerminalTab } from '../store/slices/worktrees-slice-test-fixtures' import { singlePaneLayoutSnapshot } from '../store/slices/terminal-helpers' import { makePaneKey } from '../../../shared/stable-pane-id' -import { getWorkspaceAttachmentTerminalOrigin } from './workspace-attachment-terminal-origin' +import { + getWorkspaceAttachmentObservedOrigin, + getWorkspaceAttachmentTerminalOrigin +} from './workspace-attachment-observed-origin' const workspaceId = 'repo::/feature' const leaf = '11111111-1111-4111-8111-111111111111' @@ -91,3 +94,43 @@ describe('reference evidence terminal identity', () => { ).toBeUndefined() }) }) + +describe('reference evidence native chat identity', () => { + it('records the chat tab and its session without a terminal pane', () => { + const chat = { + ...state(), + unifiedTabsByWorktree: { + [workspaceId]: [ + { + id: 'chat-tab', + entityId: 'chat-session', + groupId: 'group', + worktreeId: workspaceId, + contentType: 'agent-session' as const, + agentSessionAgent: 'claude' as const, + label: 'Plan refactor', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + } + } + expect( + getWorkspaceAttachmentObservedOrigin( + chat, + workspaceId, + { kind: 'agent-session', tabId: 'chat-tab' }, + 'local' + ) + ).toEqual({ + kind: 'observed', + tabId: 'chat-tab', + hostId: 'local', + label: 'Plan refactor', + agent: 'claude', + sessionId: 'chat-session' + }) + }) +}) diff --git a/src/renderer/src/lib/workspace-attachment-observed-origin.ts b/src/renderer/src/lib/workspace-attachment-observed-origin.ts new file mode 100644 index 00000000000..de408c5d605 --- /dev/null +++ b/src/renderer/src/lib/workspace-attachment-observed-origin.ts @@ -0,0 +1,112 @@ +import type { AppState } from '@/store/types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { NotificationWorkspaceOwner } from '../../../shared/notification-source' +import { + resolveNotificationTabOwner, + resolveTerminalNotificationOwner +} from '@/attention/notification-subject-owner' +import type { WorkspaceAttachmentOrigin } from '../../../shared/worktree/types' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { terminalLayoutContainsLeaf } from '../../../shared/workspace-session-pane-ownership' +import type { Tab } from '../../../shared/tab-types' + +export type WorkspaceReferenceTerminalContext = { + kind?: 'terminal' + tabId: string + paneKey: string + ptyId?: string | null + executionHostId?: ExecutionHostId +} + +export type WorkspaceReferenceAgentSessionContext = { kind: 'agent-session'; tabId: string } + +export type WorkspaceReferenceObservationContext = + | WorkspaceReferenceTerminalContext + | WorkspaceReferenceAgentSessionContext + +type ObservationState = Parameters[0] & + Pick + +function findAgentSessionTab( + state: Partial>, + worktreeId: string, + tabId: string +): Tab | undefined { + return state.unifiedTabsByWorktree?.[worktreeId]?.find( + (tab) => tab.id === tabId && tab.contentType === 'agent-session' + ) +} + +export function resolveWorkspaceReferenceObservationOwner( + state: ObservationState, + worktreeId: string, + context: WorkspaceReferenceObservationContext +): NotificationWorkspaceOwner | null { + if (context.kind === 'agent-session') { + const tab = findAgentSessionTab(state, worktreeId, context.tabId) + return tab ? resolveNotificationTabOwner(state, tab) : null + } + return resolveTerminalNotificationOwner(state, worktreeId, context) +} + +export function getWorkspaceAttachmentObservedOrigin( + state: ObservationState, + worktreeId: string, + context: WorkspaceReferenceObservationContext, + hostId: ExecutionHostId | undefined +): WorkspaceAttachmentOrigin | undefined { + if (context.kind !== 'agent-session') { + return getWorkspaceAttachmentTerminalOrigin(state, worktreeId, context, hostId) + } + const tab = findAgentSessionTab(state, worktreeId, context.tabId) + if (!tab) { + return undefined + } + return { + kind: 'observed', + tabId: tab.id, + ...(hostId ? { hostId } : {}), + label: tab.customLabel || tab.label, + ...(tab.agentSessionAgent ? { agent: tab.agentSessionAgent } : {}), + sessionId: tab.entityId + } +} + +export function getWorkspaceAttachmentTerminalOrigin( + state: Pick, + worktreeId: string, + context: WorkspaceReferenceTerminalContext, + hostId: ExecutionHostId | undefined +): WorkspaceAttachmentOrigin | undefined { + const tab = state.tabsByWorktree?.[worktreeId]?.find( + (candidate) => candidate.id === context.tabId + ) + const pane = parsePaneKey(context.paneKey) + const layout = state.terminalLayoutsByTabId?.[context.tabId] + if (!tab || !pane || pane.tabId !== tab.id || !layout) { + return undefined + } + if (!terminalLayoutContainsLeaf(layout.root, pane.leafId)) { + return undefined + } + const ptyId = + layout.ptyIdsByLeafId?.[pane.leafId] ?? (layout.root?.type === 'leaf' ? tab.ptyId : null) + if (!context.ptyId || context.ptyId !== ptyId) { + return undefined + } + const entry = state.agentStatusByPaneKey?.[context.paneKey] + const matchingEntry = + (entry?.worktreeId && entry.worktreeId !== worktreeId) || + (entry?.terminalHandle && entry.terminalHandle !== ptyId) + ? undefined + : entry + return { + kind: 'observed', + tabId: tab.id, + paneKey: context.paneKey, + ...(hostId ? { hostId } : {}), + label: tab.customTitle || tab.defaultTitle || tab.title, + ...(matchingEntry?.agentType ? { agent: matchingEntry.agentType } : {}), + ...(matchingEntry?.providerSession?.id ? { sessionId: matchingEntry.providerSession.id } : {}) + } +} diff --git a/src/renderer/src/lib/workspace-attachment-terminal-origin.ts b/src/renderer/src/lib/workspace-attachment-terminal-origin.ts deleted file mode 100644 index d6c5acfa252..00000000000 --- a/src/renderer/src/lib/workspace-attachment-terminal-origin.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { AppState } from '@/store/types' -import type { ExecutionHostId } from '../../../shared/execution-host' -import type { WorkspaceAttachmentOrigin } from '../../../shared/worktree/types' -import { parsePaneKey } from '../../../shared/stable-pane-id' -import { terminalLayoutContainsLeaf } from '../../../shared/workspace-session-pane-ownership' - -export type WorkspaceReferenceTerminalContext = { - tabId: string - paneKey: string - ptyId?: string | null - executionHostId?: ExecutionHostId -} - -export function getWorkspaceAttachmentTerminalOrigin( - state: Pick, - worktreeId: string, - context: WorkspaceReferenceTerminalContext, - hostId: ExecutionHostId | undefined -): WorkspaceAttachmentOrigin | undefined { - const tab = state.tabsByWorktree?.[worktreeId]?.find( - (candidate) => candidate.id === context.tabId - ) - const pane = parsePaneKey(context.paneKey) - const layout = state.terminalLayoutsByTabId?.[context.tabId] - if (!tab || !pane || pane.tabId !== tab.id || !layout) { - return undefined - } - if (!terminalLayoutContainsLeaf(layout.root, pane.leafId)) { - return undefined - } - const ptyId = - layout.ptyIdsByLeafId?.[pane.leafId] ?? (layout.root?.type === 'leaf' ? tab.ptyId : null) - if (!context.ptyId || context.ptyId !== ptyId) { - return undefined - } - const entry = state.agentStatusByPaneKey?.[context.paneKey] - const matchingEntry = - (entry?.worktreeId && entry.worktreeId !== worktreeId) || - (entry?.terminalHandle && entry.terminalHandle !== ptyId) - ? undefined - : entry - return { - kind: 'observed', - tabId: tab.id, - paneKey: context.paneKey, - ...(hostId ? { hostId } : {}), - label: tab.customTitle || tab.defaultTitle || tab.title, - ...(matchingEntry?.agentType ? { agent: matchingEntry.agentType } : {}), - ...(matchingEntry?.providerSession?.id ? { sessionId: matchingEntry.providerSession.id } : {}) - } -} diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index accb0723f26..a6487c2a7a7 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -1,5 +1,5 @@ import type { WorkspaceAttachmentMutation } from '../../../../shared/workspace-attachment-mutation' -import type { WorkspaceReferenceTerminalContext } from '@/lib/workspace-attachment-terminal-origin' +import type { WorkspaceReferenceObservationContext } from '@/lib/workspace-attachment-observed-origin' import type { CreateWorktreeCallOptions } from './worktrees/create/worktree-create-payload' import type { WorktreeCatalogVersion } from '../../../../shared/worktree/catalog-version' import type { WorkspaceKey } from '../../../../shared/folder-workspace-types' @@ -288,7 +288,7 @@ export type WorktreeSlice = { observeTerminalGitHubPullRequestLink: ( worktreeId: string, link: TerminalGitHubPRLink, - context?: WorkspaceReferenceTerminalContext + context?: WorkspaceReferenceObservationContext ) => void bumpWorktreeActivity: (worktreeId: string) => void /** diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts b/src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts index 4178db92470..6f0d02b34be 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts @@ -8,8 +8,10 @@ import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-rev import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet } from '../listing/worktree-slice-types' import { branchName } from '@/lib/git-utils' -import { resolveTerminalNotificationOwner } from '@/attention/notification-subject-owner' -import { getWorkspaceAttachmentTerminalOrigin } from '@/lib/workspace-attachment-terminal-origin' +import { + getWorkspaceAttachmentObservedOrigin, + resolveWorkspaceReferenceObservationOwner +} from '@/lib/workspace-attachment-observed-origin' import { getWorkspaceAttachments } from '../../../../../../shared/workspace-attachments' import { getWorkspaceAttachmentKey } from '../../../../../../shared/workspace-attachment-normalization' import { @@ -46,9 +48,12 @@ export function createObserveTerminalGitHubPullRequestLink( const confirmations = new PrioritySemaphore(3) return (worktreeId, link, context) => { const state = get() - const owner = context ? resolveTerminalNotificationOwner(state, worktreeId, context) : null + const owner = context + ? resolveWorkspaceReferenceObservationOwner(state, worktreeId, context) + : null const hostId = owner?.executionHostId ?? undefined - if (context && (!hostId || (context.executionHostId && context.executionHostId !== hostId))) { + const contextHostId = context?.kind === 'agent-session' ? undefined : context?.executionHostId + if (context && (!hostId || (contextHostId && contextHostId !== hostId))) { return } const worktree = findKnownWorktreeById(state, worktreeId, hostId) @@ -75,7 +80,7 @@ export function createObserveTerminalGitHubPullRequestLink( const pushBranch = worktree.pushTarget?.branchName let confirmedPushBranch: string | undefined const origin = context - ? getWorkspaceAttachmentTerminalOrigin(state, worktreeId, context, hostId) + ? getWorkspaceAttachmentObservedOrigin(state, worktreeId, context, hostId) : undefined if (context && !origin) { return @@ -85,8 +90,8 @@ export function createObserveTerminalGitHubPullRequestLink( return true } const latest = get() - const latestOwner = resolveTerminalNotificationOwner(latest, worktreeId, context) - const latestOrigin = getWorkspaceAttachmentTerminalOrigin(latest, worktreeId, context, hostId) + const latestOwner = resolveWorkspaceReferenceObservationOwner(latest, worktreeId, context) + const latestOrigin = getWorkspaceAttachmentObservedOrigin(latest, worktreeId, context, hostId) return ( latestOwner?.executionHostId === hostId && Boolean(