Merge remote-tracking branch 'origin/main' into brennanb2025/fix-ask-user-question-status-linger

This commit is contained in:
Brennan Benson
2026-07-16 20:01:30 -07:00
15 changed files with 319 additions and 9 deletions
@@ -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
@@ -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])
})
})
@@ -5,6 +5,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: {
groupBy: WorktreeGroupBy
repos: readonly Repo[]
worktreesByRepo: Readonly<Record<string, readonly Worktree[] | undefined>>
visibleWorktrees: readonly Worktree[]
filterRepoIds: readonly string[]
}): Set<string> {
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<string>()
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)
}
}
@@ -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',
@@ -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({
@@ -16,6 +16,7 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
activeBrowserTabIdByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
browserCertificateFailuresByPageId: {},
openFiles: [],
editorDrafts: {},
activeTabId: null,
@@ -26,6 +26,7 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
activeBrowserTabIdByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
browserCertificateFailuresByPageId: {},
openFiles: [],
editorDrafts: {},
activeTabId: null,
@@ -60,6 +61,7 @@ function makeSharedOverrides(): Partial<AppState> {
activeBrowserTabIdByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
browserCertificateFailuresByPageId: {},
openFiles: [],
editorDrafts: {},
agentStatusByPaneKey: {},
@@ -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,
+1
View File
@@ -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,
+7 -1
View File
@@ -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 (
+9 -2
View File
@@ -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)
})
})
+12 -2
View File
@@ -1,4 +1,14 @@
// Why: OpenCode abbreviates native OSC session titles as `OC | <task>` (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)
}
@@ -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 | <task>`.
* 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')
})
})
@@ -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')
})
+7 -1
View File
@@ -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 (