mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
* feat(agent-rows): opt-in conversation-name labels for agent rows Sidebar worktree-card and dashboard agent rows always show the last message sent to the agent, so rows relabel on every turn and a 'continue' prompt becomes the row's name. Add an opt-in Agents setting that labels rows with the conversation name instead, resolved with the tab bar's precedence: manual rename, quick-command label, OpenCode session title, generated title (behind its existing setting), then the agent-set live title. Live titles count only when they carry a real name - status decoration is stripped, and pure status, identity-echo, spinner+cwd, and placeholder titles fall back to the last-message label. Subagent child rows keep their own descriptions. Locale note: sync:localization-catalog also restored parity for keys already missing on main (add-host, sleep-worktree copy). * fix(agent-rows): read the live tab so late renames and titles surface Row data patches live entries in place and keeps the tab snapshot from row creation, so a rename or agent-set title landing after the row was built never reached the conversation-name resolver. Select the current tab from the store in the hook; retained rows without a live tab keep the snapshot fallback. * fix(agent-rows): reject status labels and bound lookup work Reject native provider/status titles and Windows/UNC cwd frames so conversation-name mode falls back to the user's last message instead of relabeling rows with identity or path text. Keep default-off and subagent rows off the tab map, and share a WeakMap-backed tab index across mounted rows to avoid repeated linear scans on store writes. * feat(agent-rows): make conversation names the default Always prefer a usable conversation name for sidebar and dashboard agent rows, falling back to the last message when the resolver rejects a title. Remove the preference, settings UI, search copy, and catalog entries. Keep subagent labels unchanged and reject generic Terminal N placeholders so partial tab snapshots cannot hide meaningful prompts. * chore(i18n): sync catalog after main merge * fix(agent-rows): preserve same-tab child labels
144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
// Resolves the stable "conversation name" an agent row can show instead of the
|
|
// live last-message preview. Sources, in the same precedence the tab bar uses
|
|
// (tab-title-resolution.ts): manual rename → quick-command label → OpenCode's
|
|
// semantic session title → Orca's generated title → the agent-set live title.
|
|
// Live titles are accepted only when they carry a real name — pure status,
|
|
// identity-echo, and spinner/cwd titles yield null so callers keep the
|
|
// last-message label.
|
|
import type { AgentType } from './agent-status-types'
|
|
import { isClaudeManagementTitle } from './agent-title-core'
|
|
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
|
|
import { formatAgentTypeLabel } from './agent-type-label'
|
|
import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
|
|
import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title'
|
|
import type { TerminalTab } from './types'
|
|
|
|
export type ConversationNameTab = Pick<
|
|
TerminalTab,
|
|
'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'defaultTitle'
|
|
>
|
|
|
|
// Why: synthetic status titles ("Codex ready", "Cursor - action required") are
|
|
// state, not names. Precomputed once; the profile table is a module constant.
|
|
const SYNTHETIC_STATUS_TITLES_LOWER: ReadonlySet<string> = new Set(
|
|
Object.values(SYNTHETIC_AGENT_TITLE_PROFILES).flatMap((profile) => [
|
|
profile.workingLabel.toLowerCase(),
|
|
profile.permissionLabel.toLowerCase(),
|
|
profile.idleLabel.toLowerCase()
|
|
])
|
|
)
|
|
|
|
// Why: retained rows without a live tab synthesize `title: 'Agent'`
|
|
// (worktree-agent-row-fallback-tab.ts); it is a placeholder, not a name.
|
|
const FALLBACK_TAB_TITLE_LOWER = 'agent'
|
|
|
|
const AGENT_IDENTITY_ALIASES_LOWER: Readonly<Record<string, readonly string[]>> = {
|
|
claude: ['claude code'],
|
|
gemini: ['gemini cli']
|
|
}
|
|
|
|
const STATUS_WITH_CONTEXT_RE = /^(?:ready|idle|done)(?:\s+\([^)]*\))?$/i
|
|
const DEFAULT_TERMINAL_TITLE_RE = /^terminal \d+$/i
|
|
|
|
function isIdentityStatusTitle(titleLower: string, identityLower: string): boolean {
|
|
return (
|
|
titleLower === identityLower ||
|
|
titleLower === `${identityLower} ready` ||
|
|
titleLower === `${identityLower} idle` ||
|
|
titleLower === `${identityLower} done` ||
|
|
titleLower === `${identityLower} working` ||
|
|
titleLower === `${identityLower} thinking` ||
|
|
titleLower === `${identityLower} running` ||
|
|
titleLower === `${identityLower} - action required`
|
|
)
|
|
}
|
|
|
|
function isAgentIdentityStatusTitle(
|
|
titleLower: string,
|
|
agentType: AgentType | null | undefined,
|
|
agentTypeLabelLower: string
|
|
): boolean {
|
|
if (isIdentityStatusTitle(titleLower, agentTypeLabelLower)) {
|
|
return true
|
|
}
|
|
return (
|
|
AGENT_IDENTITY_ALIASES_LOWER[agentType ?? '']?.some((identity) =>
|
|
isIdentityStatusTitle(titleLower, identity)
|
|
) ?? false
|
|
)
|
|
}
|
|
|
|
function isCwdLikeTitle(title: string): boolean {
|
|
// Hook-less agents over SSH surface spinner+cwd titles (#8711); once the
|
|
// spinner is stripped, what remains is a path, not a conversation name.
|
|
if (/^(?:~|[\\/]|[A-Za-z]:[\\/])/.test(title)) {
|
|
return true
|
|
}
|
|
// A single path-ish token ("orca/workspaces") is still a cwd, not a name.
|
|
return !/\s/.test(title) && /[\\/]/.test(title)
|
|
}
|
|
|
|
function conversationNameFromLiveTitle(
|
|
liveTitle: string,
|
|
agentType: AgentType | null | undefined,
|
|
agentTypeLabelLower: string,
|
|
defaultTitle: string | undefined
|
|
): string | null {
|
|
const stripped = stripLeadingAgentTitleDecorationOrEmpty(liveTitle.trim()).trim()
|
|
if (!stripped) {
|
|
return null
|
|
}
|
|
const lower = stripped.toLowerCase()
|
|
if (
|
|
SYNTHETIC_STATUS_TITLES_LOWER.has(lower) ||
|
|
lower === FALLBACK_TAB_TITLE_LOWER ||
|
|
isAgentIdentityStatusTitle(lower, agentType, agentTypeLabelLower) ||
|
|
STATUS_WITH_CONTEXT_RE.test(stripped) ||
|
|
DEFAULT_TERMINAL_TITLE_RE.test(stripped) ||
|
|
isClaudeManagementTitle(stripped) ||
|
|
isCwdLikeTitle(stripped)
|
|
) {
|
|
return null
|
|
}
|
|
if (defaultTitle && stripped === defaultTitle.trim()) {
|
|
return null
|
|
}
|
|
return stripped
|
|
}
|
|
|
|
/**
|
|
* The conversation name for an agent row, or null when no usable name exists
|
|
* and the caller should keep its last-message label.
|
|
*/
|
|
export function getAgentRowConversationName(
|
|
tab: ConversationNameTab,
|
|
agentType: AgentType | null | undefined,
|
|
generatedTitlesEnabled: boolean
|
|
): string | null {
|
|
const customTitle = tab.customTitle?.trim()
|
|
if (customTitle) {
|
|
return customTitle
|
|
}
|
|
const quickCommandLabel = tab.quickCommandLabel?.trim()
|
|
if (quickCommandLabel) {
|
|
return quickCommandLabel
|
|
}
|
|
const liveTitle = tab.title?.trim() ?? ''
|
|
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
|
|
return liveTitle
|
|
}
|
|
const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
|
|
if (generatedTitle) {
|
|
return generatedTitle
|
|
}
|
|
if (!liveTitle) {
|
|
return null
|
|
}
|
|
return conversationNameFromLiveTitle(
|
|
liveTitle,
|
|
agentType,
|
|
formatAgentTypeLabel(agentType).toLowerCase(),
|
|
tab.defaultTitle
|
|
)
|
|
}
|