From e01f3f474c39ce1a35c83d07c2adf36a380c48d3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:38:13 -0700 Subject: [PATCH 1/2] fix(sidebar): preserve Project Group members under Hide sleeping (#9101) * fix(sidebar): preserve grouped projects under sleep filter * test(sidebar): harden #8865 project-group sleep-filter coverage Add adversarial cases for explicit project filters, multi-member groups, ungrouped neighbors, and buildRows membership/count preservation. Co-authored-by: bbingz --------- Co-authored-by: bbingz --- .../src/components/sidebar/WorktreeList.tsx | 3 +- .../empty-project-placeholder-repos.test.ts | 105 ++++++++++++++++++ .../empty-project-placeholder-repos.ts | 8 +- .../sidebar/worktree-list-groups.test.ts | 67 +++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 81671c20616..948228984d8 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -5711,9 +5711,10 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy, repos: visibleReposForRows, worktreesByRepo, + visibleWorktrees, filterRepoIds }) - }, [filterRepoIds, groupBy, visibleReposForRows, worktreesByRepo]) + }, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) // Why: buildRows only needs which creates exist and their repo. Subscribe on a diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts index aaa3be238e9..8a33151f73a 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts @@ -38,6 +38,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -51,6 +52,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: {}, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -67,6 +69,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [selectedRepo, hiddenRepo], worktreesByRepo: { [selectedRepo.id]: [], [hiddenRepo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [selectedRepo.id] }) ) @@ -79,6 +82,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'none', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) @@ -90,8 +94,109 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [worktree] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) }) + + it('keeps grouped repos visible when workspace filters hide all of their rows', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([groupedRepo.id]) + }) + + it('does not create a grouped repo placeholder when one of its workspaces is visible', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [groupedWorktree], + filterRepoIds: [] + }).size + ).toBe(0) + }) + + it('still respects explicit project filters for sleep-filtered grouped members', () => { + const selected: Repo = { ...repo, id: 'repo-selected', projectGroupId: 'group-1' } + const filteredOut: Repo = { ...repo, id: 'repo-hidden', projectGroupId: 'group-1' } + const selectedWt: Worktree = { ...worktree, id: 'wt-selected', repoId: selected.id } + const hiddenWt: Worktree = { ...worktree, id: 'wt-hidden', repoId: filteredOut.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [selected, filteredOut], + worktreesByRepo: { + [selected.id]: [selectedWt], + [filteredOut.id]: [hiddenWt] + }, + // Why: simulate Hide sleeping removing every card while the project + // filter still intentionally excludes `filteredOut`. + visibleWorktrees: [], + filterRepoIds: [selected.id] + }) + ) + ).toEqual([selected.id]) + }) + + it('placeholders only the fully-filtered members of a multi-project group', () => { + const sleeping: Repo = { ...repo, id: 'repo-sleeping', projectGroupId: 'group-1' } + const awake: Repo = { ...repo, id: 'repo-awake', projectGroupId: 'group-1' } + const sleepingWt: Worktree = { ...worktree, id: 'wt-sleeping', repoId: sleeping.id } + const awakeWt: Worktree = { ...worktree, id: 'wt-awake', repoId: awake.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [sleeping, awake], + worktreesByRepo: { + [sleeping.id]: [sleepingWt], + [awake.id]: [awakeWt] + }, + visibleWorktrees: [awakeWt], + filterRepoIds: [] + }) + ) + ).toEqual([sleeping.id]) + }) + + it('does not placeholder ungrouped neighbors of a filtered grouped member', () => { + const grouped: Repo = { ...repo, id: 'repo-grouped', projectGroupId: 'group-1' } + const ungrouped: Repo = { ...repo, id: 'repo-ungrouped' } + const groupedWt: Worktree = { ...worktree, id: 'wt-grouped', repoId: grouped.id } + const ungroupedWt: Worktree = { ...worktree, id: 'wt-ungrouped', repoId: ungrouped.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [grouped, ungrouped], + worktreesByRepo: { + [grouped.id]: [groupedWt], + [ungrouped.id]: [ungroupedWt] + }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([grouped.id]) + }) }) diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts index 26c248a2a65..57aee59c1bd 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts @@ -5,6 +5,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: { groupBy: WorktreeGroupBy repos: readonly Repo[] worktreesByRepo: Readonly> + visibleWorktrees: readonly Worktree[] filterRepoIds: readonly string[] }): Set { if (args.groupBy !== 'repo') { @@ -12,12 +13,17 @@ export function getEmptyProjectPlaceholderRepoIds(args: { } const filterSet = args.filterRepoIds.length > 0 ? new Set(args.filterRepoIds) : null + const visibleRepoIds = new Set(args.visibleWorktrees.map((worktree) => worktree.repoId)) const placeholderRepoIds = new Set() for (const repo of args.repos) { if (filterSet && !filterSet.has(repo.id)) { continue } - if ((args.worktreesByRepo[repo.id]?.length ?? 0) === 0) { + const hasNoWorktrees = (args.worktreesByRepo[repo.id]?.length ?? 0) === 0 + // Why: workspace filters hide cards, but must not rewrite the visible + // membership of a persisted Project Group. #8865 + const isFilteredProjectGroupMember = repo.projectGroupId != null && !visibleRepoIds.has(repo.id) + if (hasNoWorktrees || isFilteredProjectGroupMember) { placeholderRepoIds.add(repo.id) } } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index b967c4cd356..4b4012f8c7c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -1933,6 +1933,73 @@ describe('project groups', () => { expect(rows[0]).toMatchObject({ label: 'Platform' }) }) + it('keeps sleep-filtered Project Group members as empty project headers', () => { + // Why: #8865 — Hide sleeping removes workspace cards; membership placeholders + // must still project the grouped project header so the group count stays honest. + const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const sleepingRepo: Repo = { + ...repo, + id: 'repo-sleeping', + displayName: 'sleeping-project', + projectGroupId: group.id + } + const awakeRepo: Repo = { + ...repo, + id: 'repo-awake', + displayName: 'awake-project', + projectGroupId: group.id + } + const awakeWorktree: Worktree = { + ...worktree, + id: 'wt-awake', + repoId: awakeRepo.id, + path: '/tmp/awake' + } + + const rows = buildRows( + 'repo', + [awakeWorktree], + new Map([ + [sleepingRepo.id, sleepingRepo], + [awakeRepo.id, awakeRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[awakeWorktree.id, awakeWorktree]]), + false, + undefined, + [group], + new Set([sleepingRepo.id]) + ) + + expect(rows[0]).toMatchObject({ + type: 'header', + key: 'project-group:group-1', + count: 2 + }) + // Why: empty/placeholder projects sort after projects with visible activity. + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-1', + `repo:${awakeRepo.id}`, + `repo:${sleepingRepo.id}` + ]) + }) + it('renders ungrouped repos as top-level repo rows when Project Groups exist', () => { const group: ProjectGroup = { id: 'group-1', From 5ee90c8d59f5839b5a05fe5e42db32c40b936273 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:53:18 -0700 Subject: [PATCH 2/2] fix(agents): recognize OpenCode native OC | tab titles (#9102) * fix(agents): recognize OpenCode native OC | tab titles OpenCode's native OSC titles use `OC | ` without an `opencode` token, so title classifiers left tabs as Claude/unknown. Map the native marker (optional mux prefix) to OpenCode identity in both title classifiers, exclude it from isClaudeAgent, and cover lookalikes plus stale Claude launch reclaim. Builds on and supersedes #8590 (credit @gatsby74). Fixes #8478. * fix(agents): drop renderer import from #8478 shared repro tsconfig.node includes src/shared tests; importing agent-status pulled renderer modules outside the node project and failed typecheck. Assert opencode identity via shared title classifiers only (OpenCode TUI sets "OpenCode" and `OC | ${title}`). * fix(runtime): fill browser cert failure map in mobile snapshot fixtures Main's #9104 reads browserCertificateFailuresByPageId in buildMobileBrowserTab but left partial AppState test helpers without the field, breaking PR Checks merge commits. Default the map in fixtures and use optional chaining so partial state cannot throw. --- src/renderer/src/lib/use-tab-agent.test.ts | 14 ++++++ ...sync-runtime-graph-terminal-layout.test.ts | 1 + .../src/runtime/sync-runtime-graph.test.ts | 2 + .../src/runtime/sync-runtime-graph.ts | 2 +- src/shared/agent-detection.ts | 1 + src/shared/agent-title-identity.ts | 8 ++- src/shared/opencode-terminal-title.test.ts | 11 ++++- src/shared/opencode-terminal-title.ts | 14 +++++- ...ro-8478-opencode-native-title-icon.test.ts | 49 +++++++++++++++++++ src/shared/terminal-title-agent-type.test.ts | 35 +++++++++++++ src/shared/terminal-title-agent-type.ts | 8 ++- 11 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 src/shared/repro-8478-opencode-native-title-icon.test.ts diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 9152159f14b..d2283db7dea 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -258,6 +258,20 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('claude') }) + // Why: #8478 — OpenCode native `OC | …` titles must reclaim a stale Claude + // launch identity so the tab icon is OpenCode, not Claude. + it('uses OpenCode native session titles to replace stale Claude launch identity', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'OC | Understand about the plugin', + hookAgent: null, + launchAgent: 'claude' + }) + ).toBe('opencode') + }) + it('does not let an explicit title override launch identity before any activity is observed', () => { expect( resolveTabAgentFromSignals({ diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts index 6095f4e1840..385a7764fd1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts @@ -16,6 +16,7 @@ function makeState(overrides: Partial = {}): AppState { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, activeTabId: null, diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index 2de176df7b9..d1082d89cab 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -26,6 +26,7 @@ function makeState(overrides: Partial = {}): AppState { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, activeTabId: null, @@ -60,6 +61,7 @@ function makeSharedOverrides(): Partial { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, agentStatusByPaneKey: {}, diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index e735e130004..fb32a847941 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1496,7 +1496,7 @@ function buildMobileBrowserTab( // back through ?? would resurrect a stale workspace-level error. loadError: activePage ? activePage.loadError : workspace.loadError, certificateFailure: activePage - ? (state.browserCertificateFailuresByPageId[activePage.id] ?? null) + ? (state.browserCertificateFailuresByPageId?.[activePage.id] ?? null) : null, color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true, diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 891f37093db..4fbfff86f24 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -18,6 +18,7 @@ export { STRONG_IDLE_KEYWORDS_RE, STRONG_WORKING_KEYWORDS_RE } from './agent-title-core' +export { isOpenCodeNativeTitle, isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' export { getAgentLabel, isClaudeAgent } from './agent-title-identity' export { clearWorkingIndicators, diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 9f7746b01bc..af86de1667d 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -10,6 +10,7 @@ import { isPiAgentTitle, titleHasAgentName } from './agent-title-core' +import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' /** @@ -17,7 +18,7 @@ import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-ti * Used to scope prompt-cache-timer behavior to Claude sessions only. */ export function isClaudeAgent(title: string): boolean { - if (!title || isClaudeManagementTitle(title)) { + if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } const lower = title.toLowerCase() @@ -46,6 +47,11 @@ export function getAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } + // Why: the native marker owns the whole title; its session text may name or + // include status glyphs from other agents without changing OpenCode identity. + if (isOpenCodeNativeTitle(title)) { + return 'OpenCode' + } // Why: Claude task titles can mention another CLI; the prefix is the identity // signal, not arbitrary task text. if ( diff --git a/src/shared/opencode-terminal-title.test.ts b/src/shared/opencode-terminal-title.test.ts index fc1b7806e35..f1cb825e8cd 100644 --- a/src/shared/opencode-terminal-title.test.ts +++ b/src/shared/opencode-terminal-title.test.ts @@ -1,16 +1,23 @@ import { describe, expect, it } from 'vitest' -import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' +import { isMeaningfulOpenCodeTerminalTitle, isOpenCodeNativeTitle } from './opencode-terminal-title' describe('OpenCode terminal titles', () => { it('recognizes native session titles', () => { expect(isMeaningfulOpenCodeTerminalTitle('OC | Native Stable Session')).toBe(true) expect(isMeaningfulOpenCodeTerminalTitle(' OC|Session ')).toBe(true) + expect(isOpenCodeNativeTitle('OC | Understand about the plugin')).toBe(true) + expect(isOpenCodeNativeTitle('tmux | OC | ses_123')).toBe(true) }) - it('rejects generic or incomplete OpenCode titles', () => { + it('rejects generic, incomplete, embedded, and lookalike titles', () => { expect(isMeaningfulOpenCodeTerminalTitle('OpenCode')).toBe(false) expect(isMeaningfulOpenCodeTerminalTitle('OpenCode ready')).toBe(false) expect(isMeaningfulOpenCodeTerminalTitle('OC |')).toBe(false) expect(isMeaningfulOpenCodeTerminalTitle(undefined)).toBe(false) + // Why: lowercase is not OpenCode's native marker; avoid "oc |" cwd/task noise. + expect(isOpenCodeNativeTitle('oc | Understand about the plugin')).toBe(false) + // Why: mid-title OC must not steal another agent's braille/task frame. + expect(isOpenCodeNativeTitle('⠋ Fix foo | OC | bar')).toBe(false) + expect(isOpenCodeNativeTitle('my session | OC | task')).toBe(false) }) }) diff --git a/src/shared/opencode-terminal-title.ts b/src/shared/opencode-terminal-title.ts index 64312b94be2..01d3d3819ae 100644 --- a/src/shared/opencode-terminal-title.ts +++ b/src/shared/opencode-terminal-title.ts @@ -1,4 +1,14 @@ +// Why: OpenCode abbreviates native OSC session titles as `OC | ` (no +// agent-name token). Optional single-token multiplexer prefix covers SSH/tmux +// frames like `tmux | OC | …`. Case-sensitive `OC` avoids ordinary lowercase +// "oc" lookalikes; require non-whitespace after the marker so bare `OC |` is not +// identity. Used for both display-title preservation and tab-agent identity. +const OPENCODE_NATIVE_TITLE_RE = /^(?:[^|\s]+ \| )?OC\s*\|\s*\S/u + +export function isOpenCodeNativeTitle(title: string | null | undefined): boolean { + return OPENCODE_NATIVE_TITLE_RE.test(title?.trim() ?? '') +} + export function isMeaningfulOpenCodeTerminalTitle(title: string | null | undefined): boolean { - // Why: bare OpenCode labels are status/identity; `OC | …` carries native session identity. - return /^OC\s*\|\s*\S/u.test(title?.trim() ?? '') + return isOpenCodeNativeTitle(title) } diff --git a/src/shared/repro-8478-opencode-native-title-icon.test.ts b/src/shared/repro-8478-opencode-native-title-icon.test.ts new file mode 100644 index 00000000000..554ac0c5ddb --- /dev/null +++ b/src/shared/repro-8478-opencode-native-title-icon.test.ts @@ -0,0 +1,49 @@ +/** + * Issue #8478 — OpenCode logo / icon not coming up well (Claude glyph on + * OpenCode tabs). + * + * Root cause (pre-fix): OpenCode's native OSC tab title format is `OC | `. + * Title classifiers only recognized OpenCode when the token "opencode" appeared. + * Native `OC | …` titles fell through, so AgentIcon rendered Claude/"?". + * + * OpenCode source (packages/tui/src/app.tsx) sets titles as: + * - "OpenCode" on home / default session titles + * - `OC | ${title}` for named sessions (title truncated at 40 chars) + * - `OC | ${pluginId}` on plugin routes + * + * Related: #8940 (OpenCode activity frames mislabeled Claude Code — separate + * braille-without-token path; not fully solved here). + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/shared/repro-8478-opencode-native-title-icon.test.ts + */ +import { describe, expect, it } from 'vitest' +import { getAgentLabel, isClaudeAgent } from './agent-detection' +import { + resolveExplicitTerminalTitleAgentType, + resolveTerminalTitleAgentType +} from './terminal-title-agent-type' + +describe('#8478 OpenCode native OC | titles map to OpenCode icon', () => { + it('recognizes OpenCode native "OC | …" title format as opencode identity', () => { + const native = 'OC | Understand about the plugin' + expect(getAgentLabel(native)).toBe('OpenCode') + expect(resolveTerminalTitleAgentType(native)).toBe('opencode') + expect(resolveExplicitTerminalTitleAgentType(native)).toBe('opencode') + expect(isClaudeAgent(native)).toBe(false) + }) + + // Why: bare "OpenCode" is what the TUI sets on home/default sessions; keep it + // classified so the icon path stays OpenCode without the OC | abbreviation. + it('keeps bare OpenCode home titles and Claude-style frames classified correctly', () => { + expect(getAgentLabel('OpenCode')).toBe('OpenCode') + expect(resolveTerminalTitleAgentType('OpenCode')).toBe('opencode') + expect(getAgentLabel('OpenCode ready')).toBe('OpenCode') + expect(resolveTerminalTitleAgentType('OpenCode ready')).toBe('opencode') + // Same family as #8940: braille/task frames without "opencode" still become Claude + // when they lack the native OC marker — documented, out of scope for this fix. + expect(isClaudeAgent('⠋ implementing the feature')).toBe(true) + expect(getAgentLabel('⠋ implementing the feature')).toBe('Claude Code') + }) +}) diff --git a/src/shared/terminal-title-agent-type.test.ts b/src/shared/terminal-title-agent-type.test.ts index d28dd43f503..d0fda7dcc45 100644 --- a/src/shared/terminal-title-agent-type.test.ts +++ b/src/shared/terminal-title-agent-type.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { getAgentLabel as getSharedAgentLabel } from './agent-title-identity' +import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { isClaudeAgent, isGrokRotatingWorkingTitle, @@ -51,6 +53,39 @@ describe('resolveExplicitTerminalTitleAgentType', () => { expect(resolveExplicitTerminalTitleAgentType('* Review Codex behavior')).toBeNull() }) + it('resolves OpenCode native abbreviated session titles before task-text identities', () => { + expect(resolveExplicitTerminalTitleAgentType('OC | Understand about the plugin')).toBe( + 'opencode' + ) + expect(resolveExplicitTerminalTitleAgentType('OC | Compare Codex and Claude')).toBe('opencode') + // Why: Gemini glyphs inside OpenCode session text must not rebrand the tab. + expect(resolveExplicitTerminalTitleAgentType('OC | ✦ Gemini CLI')).toBe('opencode') + expect(getSharedAgentLabel('OC | Compare Codex and Claude')).toBe('OpenCode') + expect(getSharedAgentLabel('OC | ✦ Gemini CLI')).toBe('OpenCode') + expect(resolveExplicitTerminalTitleAgentType('tmux | OC | ses_123')).toBe('opencode') + expect(resolveExplicitTerminalTitleAgentType('OC|compact-session')).toBe('opencode') + expect(resolveExplicitTerminalTitleAgentType('oc | Understand about the plugin')).toBeNull() + }) + + it('does not find an OpenCode marker inside another agent task title', () => { + expect(isOpenCodeNativeTitle('⠋ Fix foo | OC | bar')).toBe(false) + expect(resolveExplicitTerminalTitleAgentType('⠋ Fix foo | OC | bar')).toBeNull() + }) + + // Why: adversarial coverage — native OC must not steal Claude/Codex/Cursor/ + // Gemini/Pi identity, and those agents must keep resolving when titled normally. + it('keeps other agents classified correctly alongside OpenCode native titles', () => { + expect(resolveExplicitTerminalTitleAgentType('✳ Claude Code')).toBe('claude') + expect(resolveExplicitTerminalTitleAgentType('⠋ Codex')).toBe('codex') + expect(resolveExplicitTerminalTitleAgentType('✦ Gemini CLI')).toBe('gemini') + expect(resolveExplicitTerminalTitleAgentType('Cursor Agent')).toBe('cursor') + expect(resolveExplicitTerminalTitleAgentType('Pi ready')).toBe('pi') + expect(resolveExplicitTerminalTitleAgentType('OpenCode ready')).toBe('opencode') + expect(resolveTerminalTitleAgentType('OC | ⠋ implementing the feature')).toBe('opencode') + expect(isClaudeAgent('OC | ⠋ implementing the feature')).toBe(false) + expect(isClaudeAgent('OC | Understand about the plugin')).toBe(false) + }) + it('still resolves Claude when the title explicitly names Claude', () => { expect(resolveExplicitTerminalTitleAgentType('. Claude Code compare Opencode')).toBe('claude') }) diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index ae7c97c09e7..fa92bb198c0 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -5,6 +5,7 @@ import { titleHasAgentName } from './agent-name-token-match' import { isCursorAgentTitle } from './agent-title-core' +import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel, isLegacyPiCompatibleTitle @@ -80,7 +81,7 @@ export function isPiAgentTitle(title: string): boolean { * agents have different (or no) caching semantics. */ export function isClaudeAgent(title: string): boolean { - if (!title || isClaudeManagementTitle(title)) { + if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } const lower = title.toLowerCase() @@ -124,6 +125,11 @@ export function getAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } + // Why: the native marker owns the whole title; its session text may name or + // include status glyphs from other agents without changing OpenCode identity. + if (isOpenCodeNativeTitle(title)) { + return 'OpenCode' + } // Why: Claude Code title text is often the task title. If that task mentions // another CLI, the Claude-specific prefix is the identity signal, not the words. if (