From 5ef9b01477f05134023acc37214e8c94b7243954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 12 Sep 2026 13:57:28 +0200 Subject: [PATCH] feat(mobile): show session id and run target in session context sheet https://github.com/Kilo-Org/cloud/pull/6097 --- .../components/agents/instance-selector.tsx | 5 +- .../session-context-sheet.mounted.test.tsx | 304 ++++++++++++++++++ .../agents/session-context-sheet.tsx | 129 +++++++- .../agents/session-detail-content.tsx | 4 + .../components/agents/session-row-actions.ts | 10 +- .../src/lib/instance-target-label.test.ts | 96 ++++++ apps/mobile/src/lib/instance-target-label.ts | 45 +++ 7 files changed, 588 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/components/agents/session-context-sheet.mounted.test.tsx create mode 100644 apps/mobile/src/lib/instance-target-label.test.ts create mode 100644 apps/mobile/src/lib/instance-target-label.ts diff --git a/apps/mobile/src/components/agents/instance-selector.tsx b/apps/mobile/src/components/agents/instance-selector.tsx index b9d2404df4..a727027caa 100644 --- a/apps/mobile/src/components/agents/instance-selector.tsx +++ b/apps/mobile/src/components/agents/instance-selector.tsx @@ -5,6 +5,7 @@ import { Pressable } from 'react-native'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; +import { cloudAgentTargetLabel, formatInstanceTarget } from '@/lib/instance-target-label'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type InstancePickerInstance } from '@/lib/picker-bridge'; import { instancePickerSlot, UNFENCED_ROUTE_KEY } from '@/lib/route-registry'; @@ -36,12 +37,12 @@ function selectorLabel({ isLoading: boolean; }): string { if (value) { - return `${value.name} · ${value.projectName}`; + return formatInstanceTarget(value); } if (isLoading) { return i18n.t('common.loading'); } - return i18n.t('agentChat.instancePicker.cloudAgent'); + return cloudAgentTargetLabel(); } export function InstanceSelector({ diff --git a/apps/mobile/src/components/agents/session-context-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/session-context-sheet.mounted.test.tsx new file mode 100644 index 0000000000..53954829f5 --- /dev/null +++ b/apps/mobile/src/components/agents/session-context-sheet.mounted.test.tsx @@ -0,0 +1,304 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx). */ +import { type ComponentProps, createElement, type ReactElement } from 'react'; +import type * as ReactI18next from 'react-i18next'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { i18n } from '@/i18n'; +import { Text } from '@/components/ui/text'; +import { type SessionContextInfo } from '@/lib/session-context-info'; + +import { SessionContextSheet } from './session-context-sheet'; + +const holder = vi.hoisted(() => ({ + instances: [] as unknown[], + isPending: false, + copied: [] as string[], + copyResult: true as boolean | Promise, +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ data: { instances: holder.instances }, isPending: holder.isPending }), +})); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + activeSessions: { listInstances: { queryOptions: () => ({}) } }, + }), +})); +vi.mock('./session-row-actions', () => ({ + copySessionId: async (id: string) => { + await Promise.resolve(); + holder.copied.push(id); + return holder.copyResult; + }, +})); +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => i18n.t(key) }), + }; +}); +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + ScrollView: 'ScrollView', + View: 'View', +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: '#000', mutedForeground: '#999' }), +})); +vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); +vi.mock('@/components/ui/text', async () => { + const React = await import('react'); + return { Text: 'Text', TextClassContext: React.createContext(undefined) }; +}); +vi.mock('@/components/ui/icons', () => ({ ChevronDown: 'ChevronDown' })); +vi.mock('@/components/ui/directional-icons', () => ({ DirectionalChevronRight: 'ChevronRight' })); +vi.mock('@/components/agents/context-usage-ring', () => ({ + ContextUsageRing: 'ContextUsageRing', +})); +vi.mock('@/components/agents/session-page-sheet', () => ({ + SessionPageSheet: 'SessionPageSheet', +})); + +const INFO: SessionContextInfo = { + contextTokens: 1000, + providerID: 'kilo', + modelID: 'claude', + contextWindow: 10_000, + percentage: 10, +}; + +function sheetElement( + overrides: Partial> = {} +): ReactElement { + return createElement(SessionContextSheet, { + visible: true, + info: INFO, + sessionId: 'ses-123', + sessionTitle: 'Greeting', + activeSessionType: null, + ownerConnectionId: null, + modelDisplay: 'Claude', + providerDisplay: 'Kilo', + totalCostMicrodollars: null, + breakdownCostUsd: 0, + messages: [], + modelOptions: [], + onClose: vi.fn<() => void>(), + ...overrides, + }); +} + +async function mountSheet( + overrides: Partial> = {} +): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + await Promise.resolve(); + ref.current = TestRenderer.create(sheetElement(overrides)); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +async function unmount(renderer: TestRenderer.ReactTestRenderer): Promise { + await act(async () => { + await Promise.resolve(); + renderer.unmount(); + }); +} + +function textValues(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root + .findAll(node => node.type === Text) + .map(node => node.props.children) + .filter((value): value is string => typeof value === 'string'); +} + +function pressByTestID(renderer: TestRenderer.ReactTestRenderer, testID: string): void { + const target = renderer.root.findAll(node => node.props.testID === testID)[0]; + if (!target) { + throw new Error(`missing testID ${testID}`); + } + (target.props.onPress as () => void)(); +} + +beforeEach(() => { + holder.instances = []; + holder.isPending = false; + holder.copied = []; + holder.copyResult = true; +}); + +describe('SessionContextSheet session id and running on', () => { + it('shows the session title above the id row so the sheet names its session', async () => { + const renderer = await mountSheet({ sessionTitle: 'Greeting' }); + const values = textValues(renderer); + expect(values).toContain(i18n.t('agentChat.session.title')); + expect(values).toContain('Greeting'); + await unmount(renderer); + }); + + it('shows the session id and copies it from the row call to action', async () => { + const renderer = await mountSheet(); + expect(textValues(renderer)).toContain('ses-123'); + await act(async () => { + pressByTestID(renderer, 'session-context-sheet-copy-id'); + await Promise.resolve(); + }); + expect(holder.copied).toEqual(['ses-123']); + const values = textValues(renderer); + expect(values).toContain(i18n.t('agents.sessionRow.idCopied')); + // The row keeps its call-to-action name beside the outcome, so the sheet + // still names the row after the copy completes. + expect(values).toContain(i18n.t('agents.sessionRow.copyId')); + await unmount(renderer); + }); + + it('shows the could-not-copy outcome and retries from the same row', async () => { + holder.copyResult = false; + const renderer = await mountSheet(); + await act(async () => { + pressByTestID(renderer, 'session-context-sheet-copy-id'); + await Promise.resolve(); + }); + const values = textValues(renderer); + expect(values).toContain(i18n.t('agents.sessionRow.couldNotCopyId')); + expect(values).not.toContain(i18n.t('agents.sessionRow.idCopied')); + expect(values).toContain(i18n.t('agents.sessionRow.copyId')); + + holder.copyResult = true; + await act(async () => { + pressByTestID(renderer, 'session-context-sheet-copy-id'); + await Promise.resolve(); + }); + expect(holder.copied).toEqual(['ses-123', 'ses-123']); + expect(textValues(renderer)).toContain(i18n.t('agents.sessionRow.idCopied')); + expect(textValues(renderer)).not.toContain(i18n.t('agents.sessionRow.couldNotCopyId')); + await unmount(renderer); + }); + + it('resets the copy feedback to the call to action when the sheet closes', async () => { + const renderer = await mountSheet(); + await act(async () => { + pressByTestID(renderer, 'session-context-sheet-copy-id'); + await Promise.resolve(); + }); + expect(textValues(renderer)).toContain(i18n.t('agents.sessionRow.idCopied')); + await act(async () => { + renderer.update(sheetElement({ visible: false })); + await Promise.resolve(); + }); + expect(textValues(renderer)).toContain(i18n.t('agents.sessionRow.copyId')); + expect(textValues(renderer)).not.toContain(i18n.t('agents.sessionRow.idCopied')); + await unmount(renderer); + }); + + it.each([ + { success: true, resolveWhileClosed: true }, + { success: false, resolveWhileClosed: true }, + { success: true, resolveWhileClosed: false }, + { success: false, resolveWhileClosed: false }, + ])( + 'ignores a late copy result after closing (success=$success, resolveWhileClosed=$resolveWhileClosed)', + async ({ success, resolveWhileClosed }) => { + const pendingCopy = Promise.withResolvers(); + holder.copyResult = pendingCopy.promise; + const renderer = await mountSheet(); + await act(async () => { + pressByTestID(renderer, 'session-context-sheet-copy-id'); + await Promise.resolve(); + }); + expect(textValues(renderer)).toContain(i18n.t('agents.sessionRow.copyId')); + expect(textValues(renderer)).not.toContain(i18n.t('agents.sessionRow.idCopied')); + expect(textValues(renderer)).not.toContain(i18n.t('agents.sessionRow.couldNotCopyId')); + await act(async () => { + renderer.update(sheetElement({ visible: false })); + await Promise.resolve(); + }); + if (!resolveWhileClosed) { + await act(async () => { + renderer.update(sheetElement()); + await Promise.resolve(); + }); + } + await act(async () => { + pendingCopy.resolve(success); + await pendingCopy.promise; + }); + if (resolveWhileClosed) { + await act(async () => { + renderer.update(sheetElement()); + await Promise.resolve(); + }); + } + const values = textValues(renderer); + expect(values).toContain(i18n.t('agents.sessionRow.copyId')); + expect(values).not.toContain(i18n.t('agents.sessionRow.idCopied')); + expect(values).not.toContain(i18n.t('agents.sessionRow.couldNotCopyId')); + + holder.copyResult = true; + await act(async () => { + pressByTestID(renderer, 'session-context-sheet-copy-id'); + await Promise.resolve(); + }); + expect(holder.copied).toEqual(['ses-123', 'ses-123']); + expect(textValues(renderer)).toContain(i18n.t('agents.sessionRow.idCopied')); + await unmount(renderer); + } + ); + + it('shows the owning instance under the picker Run on label for a live CLI session', async () => { + holder.instances = [ + { connectionId: 'conn-1', name: 'laptop', projectName: 'kilo', kind: 'cli' }, + { connectionId: 'conn-2', name: 'desktop', projectName: 'cloud', kind: 'cli' }, + ]; + const renderer = await mountSheet({ activeSessionType: 'remote', ownerConnectionId: 'conn-2' }); + const values = textValues(renderer); + expect(values).toContain(i18n.t('agentChat.instancePicker.runOn')); + expect(values).toContain('desktop · cloud'); + await unmount(renderer); + }); + + it('shows the Cloud Agent target for a live cloud session', async () => { + const renderer = await mountSheet({ activeSessionType: 'cloud-agent' }); + const values = textValues(renderer); + expect(values).toContain(i18n.t('agentChat.instancePicker.runOn')); + expect(values).toContain(i18n.t('agentChat.instancePicker.cloudAgent')); + await unmount(renderer); + }); + + it('reserves the Run on row while the connected instances load', async () => { + holder.isPending = true; + const renderer = await mountSheet({ activeSessionType: 'remote', ownerConnectionId: 'conn-2' }); + const values = textValues(renderer); + expect(values).toContain(i18n.t('agentChat.instancePicker.runOn')); + expect(values).toContain(i18n.t('common.loading')); + await unmount(renderer); + }); + + it('hides the Run on row for a read-only session', async () => { + const renderer = await mountSheet({ activeSessionType: 'read-only' }); + expect(textValues(renderer)).not.toContain(i18n.t('agentChat.instancePicker.runOn')); + await unmount(renderer); + }); + + it('hides the Run on row when a live CLI target is not connected', async () => { + holder.instances = [ + { connectionId: 'conn-1', name: 'laptop', projectName: 'kilo', kind: 'cli' }, + ]; + const renderer = await mountSheet({ + activeSessionType: 'remote', + ownerConnectionId: 'conn-missing', + }); + expect(textValues(renderer)).not.toContain(i18n.t('agentChat.instancePicker.runOn')); + await unmount(renderer); + }); +}); diff --git a/apps/mobile/src/components/agents/session-context-sheet.tsx b/apps/mobile/src/components/agents/session-context-sheet.tsx index d5a5f51268..a020e4f3a8 100644 --- a/apps/mobile/src/components/agents/session-context-sheet.tsx +++ b/apps/mobile/src/components/agents/session-context-sheet.tsx @@ -1,20 +1,23 @@ /* eslint-disable max-lines -- The context sheet composes the usage ring, token totals, and per-model cost rows. */ -import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; import { ChevronDown } from '@/components/ui/icons'; -import { type StoredMessage } from '@kilocode/cloud-agent-sdk'; +import { type ResolvedSession, type StoredMessage } from '@kilocode/cloud-agent-sdk'; import { SheetHeader } from '@/components/sheet-header'; import { DirectionalChevronRight } from '@/components/ui/directional-icons'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; import { formatNumber, formatPercent } from '@/lib/format'; +import { resolveRunningOnLabel } from '@/lib/instance-target-label'; import { cn } from '@/lib/utils'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type SessionContextInfo } from '@/lib/session-context-info'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; +import { useTRPC } from '@/lib/trpc'; import { ContextUsageRing } from './context-usage-ring'; import { @@ -35,10 +38,15 @@ import { } from './session-cost-breakdown'; import { friendlyModelName, resolveModelProviderName } from './session-model-display'; import { SessionPageSheet } from './session-page-sheet'; +import { copySessionId } from './session-row-actions'; type SessionContextSheetProps = { visible: boolean; info: SessionContextInfo; + sessionId: string; + sessionTitle: string; + activeSessionType: ResolvedSession['type'] | null; + ownerConnectionId: string | null; modelDisplay: string; providerDisplay: string; totalCostMicrodollars: number | null; @@ -62,9 +70,27 @@ function toneTextClass(tone: ContextTone): string { return TONE_TEXT_CLASS[tone]; } +type RunningOnState = { kind: 'hidden' } | { kind: 'pending' } | { kind: 'label'; label: string }; + +type CopyFeedbackState = 'idle' | 'copied' | 'failed'; + +function copyStatusLabel(state: CopyFeedbackState, t: (key: string) => string): string | null { + if (state === 'copied') { + return t('agents.sessionRow.idCopied'); + } + if (state === 'failed') { + return t('agents.sessionRow.couldNotCopyId'); + } + return null; +} + export function SessionContextSheet({ visible, info, + sessionId, + sessionTitle, + activeSessionType, + ownerConnectionId, modelDisplay, providerDisplay, totalCostMicrodollars, @@ -75,6 +101,22 @@ export function SessionContextSheet({ }: Readonly) { const insets = useSafeAreaInsets(); const { t } = useTranslation(); + const runningOn = useRunningOnLabel(activeSessionType, ownerConnectionId, visible); + const [copyState, setCopyState] = useState('idle'); + const copyFeedbackGeneration = useRef(0); + // sonner toasts render in the app root, behind this Modal's window, so the + // copy row shows the outcome inline instead of relying on the toast. + // Closing the sheet clears feedback and invalidates pending results so a + // reopen starts from the CTA even if an earlier copy finishes late. + useEffect(() => { + if (!visible) { + setCopyState('idle'); + } + return () => { + copyFeedbackGeneration.current += 1; + }; + }, [visible]); + const copyStatus = copyStatusLabel(copyState, t); const content = getContextSheetContent(info, totalCostMicrodollars); const tone = getContextTone(info.percentage); const arcFraction = getArcFraction(info.percentage); @@ -161,6 +203,62 @@ export function SessionContextSheet({ {providerDisplay} + {/* Identity group: which session this sheet describes, its id, and + where it runs. The sheet surface covers the session page behind + it, so without the title row nothing on screen names the + session the id below belongs to. */} + + + {sessionTitle} + + + + { + void (async () => { + const generation = copyFeedbackGeneration.current; + const success = await copySessionId(sessionId); + if (generation === copyFeedbackGeneration.current) { + setCopyState(success ? 'copied' : 'failed'); + } + })(); + }} + accessibilityRole="button" + className="gap-1 active:opacity-70" + testID="session-context-sheet-copy-id" + > + {/* The row keeps its call-to-action name in every state; the copy + outcome renders beside it, so one capture of the sheet shows + both the row the scenario names and the feedback it demands. + The child texts are the accessible name in reading order. */} + + + {t('agents.sessionRow.copyId')} + + {copyStatus ? ( + + {copyStatus} + + ) : null} + + + {sessionId} + + + + {runningOn.kind !== 'hidden' ? ( + + + {runningOn.kind === 'label' ? runningOn.label : t('common.loading')} + + + ) : null} + {content.cost !== null ? ( @@ -242,6 +340,33 @@ export function SessionContextSheet({ ); } +function useRunningOnLabel( + activeSessionType: ResolvedSession['type'] | null, + ownerConnectionId: string | null, + visible: boolean +): RunningOnState { + const trpc = useTRPC(); + const isRemote = activeSessionType === 'remote'; + const { data, isPending } = useQuery( + trpc.activeSessions.listInstances.queryOptions(undefined, { + enabled: isRemote && visible, + staleTime: 30_000, + }) + ); + const label = resolveRunningOnLabel({ + activeSessionType, + ownerConnectionId, + instances: data?.instances ?? [], + }); + if (label !== null) { + return { kind: 'label', label }; + } + // A live CLI target resolves from the connected-instances list; keep the + // row's space while that first lookup is in flight so the rows below it do + // not jump when the label arrives. + return isRemote && isPending ? { kind: 'pending' } : { kind: 'hidden' }; +} + function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) { return ( diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 6578897c9d..3647fe3069 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -1434,6 +1434,10 @@ export function SessionDetailContent({ { try { const copied = await Clipboard.setStringAsync(sessionId); if (!copied) { @@ -43,8 +49,10 @@ export async function copySessionId(sessionId: string) { } void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); toast.success(i18n.t('agents.sessionRow.idCopied')); + return true; } catch { toast.error(i18n.t('agents.sessionRow.couldNotCopyId')); + return false; } } diff --git a/apps/mobile/src/lib/instance-target-label.test.ts b/apps/mobile/src/lib/instance-target-label.test.ts new file mode 100644 index 0000000000..376f00287f --- /dev/null +++ b/apps/mobile/src/lib/instance-target-label.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { i18n } from '@/i18n'; +import { type InstancePickerInstance } from '@/lib/picker-bridge'; + +import { + cloudAgentTargetLabel, + formatInstanceTarget, + resolveRunningOnLabel, +} from './instance-target-label'; + +function instance(overrides: Partial): InstancePickerInstance { + return { + connectionId: 'conn-1', + name: 'laptop', + projectName: 'kilo', + kind: 'cli', + startedAt: null, + gitBranch: null, + ...overrides, + }; +} + +describe('formatInstanceTarget', () => { + it('matches the new-session picker target label', () => { + expect(formatInstanceTarget(instance({ name: 'laptop', projectName: 'kilo' }))).toBe( + 'laptop · kilo' + ); + }); +}); + +describe('resolveRunningOnLabel', () => { + it('returns null for a read-only session', () => { + expect( + resolveRunningOnLabel({ + activeSessionType: 'read-only', + ownerConnectionId: null, + instances: [], + }) + ).toBeNull(); + }); + + it('returns null before the session type resolves', () => { + expect( + resolveRunningOnLabel({ + activeSessionType: null, + ownerConnectionId: null, + instances: [], + }) + ).toBeNull(); + }); + + it('labels the Cloud Agent target for a live cloud session', () => { + expect( + resolveRunningOnLabel({ + activeSessionType: 'cloud-agent', + ownerConnectionId: null, + instances: [], + }) + ).toBe(i18n.t('agentChat.instancePicker.cloudAgent')); + expect(cloudAgentTargetLabel()).toBe(i18n.t('agentChat.instancePicker.cloudAgent')); + }); + + it('labels the owning instance for a live CLI session', () => { + expect( + resolveRunningOnLabel({ + activeSessionType: 'remote', + ownerConnectionId: 'conn-2', + instances: [ + instance({ connectionId: 'conn-1', name: 'laptop', projectName: 'kilo' }), + instance({ connectionId: 'conn-2', name: 'desktop', projectName: 'cloud' }), + ], + }) + ).toBe('desktop · cloud'); + }); + + it('returns null when the live CLI instance is not in the connected list', () => { + expect( + resolveRunningOnLabel({ + activeSessionType: 'remote', + ownerConnectionId: 'conn-missing', + instances: [instance({ connectionId: 'conn-1' })], + }) + ).toBeNull(); + }); + + it('does not match a null owner connection to an instance', () => { + expect( + resolveRunningOnLabel({ + activeSessionType: 'remote', + ownerConnectionId: null, + instances: [instance({ connectionId: 'conn-1' })], + }) + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/instance-target-label.ts b/apps/mobile/src/lib/instance-target-label.ts new file mode 100644 index 0000000000..a54c428a06 --- /dev/null +++ b/apps/mobile/src/lib/instance-target-label.ts @@ -0,0 +1,45 @@ +import { type ResolvedSession } from '@kilocode/cloud-agent-sdk'; + +import { i18n } from '@/i18n'; +import { type InstancePickerInstance } from '@/lib/picker-bridge'; + +/** + * The target label the new-session instance picker shows for a connected CLI + * instance (`InstanceSelector`). Shared so the picker and the session context + * sheet's "Run on" row can never drift. + */ +export function formatInstanceTarget( + instance: Pick +): string { + return `${instance.name} · ${instance.projectName}`; +} + +/** The picker's label for its default Cloud Agent target. */ +export function cloudAgentTargetLabel(): string { + return i18n.t('agentChat.instancePicker.cloudAgent'); +} + +/** + * The target a live session runs on, labelled exactly as the new-session + * picker labels that target: ` · ` for a connected CLI + * instance, or the Cloud Agent label. + * + * `null` when the session is not live (`read-only` or not resolved yet) or a + * live CLI target cannot be matched to a connected instance. + */ +export function resolveRunningOnLabel(input: { + activeSessionType: ResolvedSession['type'] | null; + ownerConnectionId: string | null; + instances: readonly InstancePickerInstance[]; +}): string | null { + if (input.activeSessionType === 'cloud-agent') { + return cloudAgentTargetLabel(); + } + if (input.activeSessionType !== 'remote' || input.ownerConnectionId === null) { + return null; + } + const instance = input.instances.find( + candidate => candidate.connectionId === input.ownerConnectionId + ); + return instance ? formatInstanceTarget(instance) : null; +}