mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Redesign Agents sidebar cards for cross-worktree scanning (#8420)
* Redesign Agents sidebar cards for cross-worktree scanning. Lead with project and workspace identity, keep stable task titles across follow-up turns, and stop user replies from replacing agent status previews. * fix(activity): honor generated-title setting and follow the active turn Activity sidebar task-title/preview fixes surfaced in review of the cross-worktree redesign: - Apply generatedTitlesEnabled instead of ignoring it, so a disabled tabAutoGenerateTitle no longer resurfaces auto-titles (mirrors resolveTerminalTabTitle's gate). - Guard orchestration labels with orchestrationLabelsMatchLiveDispatch so sticky completed metadata can't pin the title over new work. - Prefer a substantive live prompt over history, and pick history by most recent startedAt (not longest), so the row title follows the active turn. - Clear a stale status preview on a new substantive turn; only bridge a previous preview across a terse follow-up. - Drop the duplicated workspace name in thread search text; index the branch instead. Fixes two previously-red buildActivityEvents tests and adds coverage for the generated-title gate and orchestration staleness. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
@@ -67,6 +67,11 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
|
||||
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getActivityThreadTaskTitle,
|
||||
getActivityThreadWorkspaceTitle,
|
||||
resolveActivityThreadStatusPreview
|
||||
} from '@/lib/activity-thread-display'
|
||||
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
|
||||
|
||||
type ThreadReadFilter = 'all' | 'unread'
|
||||
@@ -438,36 +443,28 @@ function agentMeta(event: ActivityEvent): string {
|
||||
return event.state === 'waiting' ? `${agent} waiting` : `${agent} blocked`
|
||||
}
|
||||
|
||||
// Why (label hierarchy): mirror DashboardAgentRow — the agent's last prompt
|
||||
// IS what the agent is working on and is the primary signal users want at a
|
||||
// glance. A user-renamed customTitle still wins (explicit rename intent), but
|
||||
// the OSC-set live title ("Claude Code", "Codex", …) must NOT shadow the
|
||||
// prompt: agent CLIs set that title eagerly, so preferring it would pin every
|
||||
// row to the agent name and hide the actual turn. Fall back to a non-default
|
||||
// liveTitle only when there is no prompt at all.
|
||||
function paneTitleForEntry(entry: AgentStatusEntry, tab: TerminalTab): string {
|
||||
const customTitle = tab.customTitle?.trim()
|
||||
if (customTitle) {
|
||||
return customTitle
|
||||
}
|
||||
const prompt = getAgentRowPrimaryText(entry)
|
||||
if (prompt) {
|
||||
return prompt
|
||||
}
|
||||
const liveTitle = tab.title?.trim()
|
||||
const defaultTitle = tab.defaultTitle?.trim()
|
||||
if (liveTitle && liveTitle !== defaultTitle) {
|
||||
return liveTitle
|
||||
}
|
||||
return defaultTitle || liveTitle || 'Terminal'
|
||||
// Why (label hierarchy): Activity rows need a stable task identity across
|
||||
// follow-up turns. The live hook prompt tracks the current turn ("yes",
|
||||
// "ok proceed") and must not replace the task title when scanning many agents
|
||||
// across worktrees.
|
||||
function paneTitleForEntry(
|
||||
entry: AgentStatusEntry,
|
||||
tab: TerminalTab,
|
||||
generatedTitlesEnabled: boolean
|
||||
): string {
|
||||
return getActivityThreadTaskTitle({ entry, tab, generatedTitlesEnabled })
|
||||
}
|
||||
|
||||
function paneTitleForEvent(event: ActivityEvent): string {
|
||||
return paneTitleForEntry(event.entry, event.tab)
|
||||
function paneTitleForEvent(event: ActivityEvent, generatedTitlesEnabled: boolean): string {
|
||||
return paneTitleForEntry(event.entry, event.tab, generatedTitlesEnabled)
|
||||
}
|
||||
|
||||
function responsePreviewForEntry(entry: AgentStatusEntry): string {
|
||||
return entry.lastAssistantMessage?.trim() ?? ''
|
||||
function statusPreviewForEntry(
|
||||
entry: AgentStatusEntry,
|
||||
agentState?: AgentStatusState | null,
|
||||
previousPreview?: string
|
||||
): string {
|
||||
return resolveActivityThreadStatusPreview(entry, agentState, previousPreview)
|
||||
}
|
||||
|
||||
function isActivityEventState(state: AgentStatusState): state is ActivityEventState {
|
||||
@@ -772,7 +769,9 @@ export function buildActivityEvents(args: {
|
||||
export function buildAgentPaneThreads(args: {
|
||||
events: ActivityEvent[]
|
||||
liveAgentByPaneKey: Record<string, ActivityLiveAgentSnapshot>
|
||||
generatedTitlesEnabled?: boolean
|
||||
}): AgentPaneThread[] {
|
||||
const generatedTitlesEnabled = args.generatedTitlesEnabled === true
|
||||
const byPaneKey = new Map<string, AgentPaneThread>()
|
||||
for (const event of args.events) {
|
||||
const paneKey = event.entry.paneKey
|
||||
@@ -780,14 +779,14 @@ export function buildAgentPaneThreads(args: {
|
||||
if (!existing) {
|
||||
byPaneKey.set(paneKey, {
|
||||
paneKey,
|
||||
paneTitle: paneTitleForEvent(event),
|
||||
paneTitle: paneTitleForEvent(event, generatedTitlesEnabled),
|
||||
worktree: event.worktree,
|
||||
repo: event.repo,
|
||||
tab: event.tab,
|
||||
agentType: event.agentType,
|
||||
currentAgentState: null,
|
||||
currentAgentEntry: null,
|
||||
responsePreview: responsePreviewForEntry(event.entry),
|
||||
responsePreview: statusPreviewForEntry(event.entry, event.state),
|
||||
latestTimestamp: event.timestamp,
|
||||
latestEvent: event,
|
||||
events: [event],
|
||||
@@ -802,10 +801,14 @@ export function buildAgentPaneThreads(args: {
|
||||
existing.migrationUnsupportedPtyId ?? event.migrationUnsupportedPtyId
|
||||
if (!existing.latestEvent || event.timestamp > existing.latestEvent.timestamp) {
|
||||
existing.latestEvent = event
|
||||
existing.paneTitle = paneTitleForEvent(event)
|
||||
existing.paneTitle = paneTitleForEvent(event, generatedTitlesEnabled)
|
||||
existing.agentType = event.agentType
|
||||
existing.tab = event.tab
|
||||
existing.responsePreview = responsePreviewForEntry(event.entry)
|
||||
existing.responsePreview = statusPreviewForEntry(
|
||||
event.entry,
|
||||
event.state,
|
||||
existing.responsePreview
|
||||
)
|
||||
existing.latestTimestamp = event.timestamp
|
||||
}
|
||||
}
|
||||
@@ -815,14 +818,14 @@ export function buildAgentPaneThreads(args: {
|
||||
if (!existing) {
|
||||
byPaneKey.set(paneKey, {
|
||||
paneKey,
|
||||
paneTitle: paneTitleForEntry(liveAgent.entry, liveAgent.tab),
|
||||
paneTitle: paneTitleForEntry(liveAgent.entry, liveAgent.tab, generatedTitlesEnabled),
|
||||
worktree: liveAgent.worktree,
|
||||
repo: liveAgent.repo,
|
||||
tab: liveAgent.tab,
|
||||
agentType: liveAgent.agentType,
|
||||
currentAgentState: liveAgent.state,
|
||||
currentAgentEntry: liveAgent.entry,
|
||||
responsePreview: responsePreviewForEntry(liveAgent.entry),
|
||||
responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.state),
|
||||
latestTimestamp: liveAgent.timestamp,
|
||||
latestEvent: null,
|
||||
events: [],
|
||||
@@ -833,14 +836,18 @@ export function buildAgentPaneThreads(args: {
|
||||
// Why: live metadata is the current thread identity. Historical events stay
|
||||
// in the event list, but the row title/time/target must follow the active
|
||||
// turn so a running agent never shows the previous prompt as primary.
|
||||
existing.paneTitle = paneTitleForEntry(liveAgent.entry, liveAgent.tab)
|
||||
existing.paneTitle = paneTitleForEntry(liveAgent.entry, liveAgent.tab, generatedTitlesEnabled)
|
||||
existing.worktree = liveAgent.worktree
|
||||
existing.repo = liveAgent.repo
|
||||
existing.tab = liveAgent.tab
|
||||
existing.agentType = liveAgent.agentType
|
||||
existing.currentAgentState = liveAgent.state
|
||||
existing.currentAgentEntry = liveAgent.entry
|
||||
existing.responsePreview = responsePreviewForEntry(liveAgent.entry)
|
||||
existing.responsePreview = statusPreviewForEntry(
|
||||
liveAgent.entry,
|
||||
liveAgent.state,
|
||||
existing.responsePreview
|
||||
)
|
||||
existing.latestTimestamp = liveAgent.timestamp
|
||||
}
|
||||
|
||||
@@ -928,6 +935,23 @@ export function ActivityThreadOptionsMenu({
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityProjectLabel({ repo }: { repo: Repo | null }): React.JSX.Element {
|
||||
const label =
|
||||
repo?.displayName?.trim() ||
|
||||
translate('auto.components.activity.ActivityPrototypePage.5651b216c6', 'Unknown project')
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{repo ? <RepoBadgeMark color={repo.badgeColor} /> : null}
|
||||
<span
|
||||
className="min-w-0 truncate text-[11px] font-semibold uppercase tracking-[0.04em] text-muted-foreground"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | null {
|
||||
if (!repo) {
|
||||
return null
|
||||
@@ -1054,7 +1078,7 @@ function threadSearchText(thread: AgentPaneThread): string {
|
||||
const latestEventText = latest
|
||||
? `${agentTitle(latest)} ${agentSummary(latest)} ${agentMeta(latest)}`
|
||||
: ''
|
||||
return `${thread.paneTitle} ${thread.worktree.displayName} ${thread.repo?.displayName ?? ''} ${formatAgentTypeLabel(thread.agentType)} ${stateLabel} ${currentPrompt} ${rawCurrentPrompt} ${currentSummary} ${thread.responsePreview} ${latestEventText}`.toLowerCase()
|
||||
return `${thread.paneTitle} ${getActivityThreadWorkspaceTitle(thread.worktree)} ${thread.worktree.branch ?? ''} ${thread.repo?.displayName ?? ''} ${formatAgentTypeLabel(thread.agentType)} ${stateLabel} ${currentPrompt} ${rawCurrentPrompt} ${currentSummary} ${thread.responsePreview} ${latestEventText}`.toLowerCase()
|
||||
}
|
||||
|
||||
export const ACTIVITY_SEARCH_QUERY_MAX_BYTES = 2 * 1024
|
||||
@@ -1219,6 +1243,14 @@ function ThreadRow({
|
||||
const renderedResponsePreview = activityThreadResponseRenderPreview({
|
||||
responsePreview: thread.responsePreview
|
||||
})
|
||||
const workspaceTitle = getActivityThreadWorkspaceTitle(thread.worktree)
|
||||
const taskTitle = thread.paneTitle
|
||||
const agentLabel = formatAgentTypeLabel(thread.agentType)
|
||||
const showStatusPreview =
|
||||
!compactMode &&
|
||||
renderedResponsePreview.length > 0 &&
|
||||
renderedResponsePreview !== taskTitle &&
|
||||
renderedResponsePreview !== workspaceTitle
|
||||
return (
|
||||
<div
|
||||
data-current={selected ? 'true' : undefined}
|
||||
@@ -1259,10 +1291,6 @@ function ThreadRow({
|
||||
{thread.unread ? (
|
||||
<span className="absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-r-full bg-primary" />
|
||||
) : null}
|
||||
{/* Why (right cluster aligned to title, not centered between rows):
|
||||
parking the timestamp on the title row leaves the secondary row
|
||||
full-width for the repo badge + branch name, which used to get
|
||||
truncated when the right cluster ate horizontal space. */}
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className="inline-flex shrink-0 items-start gap-1">
|
||||
<ThreadAgentStateIndicator thread={thread} />
|
||||
@@ -1271,128 +1299,126 @@ function ThreadRow({
|
||||
</span>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 text-xs leading-snug',
|
||||
compactMode ? 'block truncate' : 'line-clamp-3 break-words',
|
||||
thread.unread ? 'font-semibold text-foreground' : 'font-medium text-foreground'
|
||||
)}
|
||||
title={compactMode ? thread.paneTitle : undefined}
|
||||
>
|
||||
{thread.paneTitle}
|
||||
</span>
|
||||
{!compactMode && renderedResponsePreview ? (
|
||||
<CommentMarkdown
|
||||
content={renderedResponsePreview}
|
||||
className={cn(
|
||||
// Why: mirror the in-workspace agent card's compact response
|
||||
// preview while keeping Activity rows to one scannable line;
|
||||
// the content is capped before markdown parsing to keep large
|
||||
// assistant summaries cheap in long Activity lists.
|
||||
'mt-1 h-[1lh] min-w-0 overflow-hidden truncate whitespace-nowrap text-[11px] font-normal leading-snug text-muted-foreground/80',
|
||||
'[&_*]:inline [&_*]:!m-0 [&_*]:!p-0 [&_*]:!whitespace-nowrap [&_br]:hidden [&_ol]:list-none [&_ul]:list-none'
|
||||
)}
|
||||
title={thread.responsePreview}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 pt-px">
|
||||
{/* Why (bell matches WorktreeCard pattern): unread → amber filled
|
||||
bell as a static, non-interactive cue (selecting the thread
|
||||
auto-marks it read, so a Mark-read button would be redundant);
|
||||
read → outline Bell that fades in on row hover and acts as
|
||||
Mark-unread. Bare button (no shadcn outline) so it reads as
|
||||
an inline cue rather than a discrete control square. */}
|
||||
<span className="inline-flex size-4 shrink-0 items-center justify-center">
|
||||
{thread.unread ? (
|
||||
<FilledBellIcon
|
||||
className="size-[13px] shrink-0 text-amber-500 drop-shadow-sm"
|
||||
aria-label={translate(
|
||||
'auto.components.activity.ActivityPrototypePage.beb2c19173',
|
||||
'Unread'
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<ActivityProjectLabel repo={thread.repo} />
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 text-[13px] leading-snug',
|
||||
compactMode ? 'truncate' : 'line-clamp-2 break-words',
|
||||
thread.unread ? 'font-semibold text-foreground' : 'font-medium text-foreground'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onMarkUnread()
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
title={workspaceTitle}
|
||||
>
|
||||
{workspaceTitle}
|
||||
</div>
|
||||
{taskTitle !== workspaceTitle ? (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 text-[12px] leading-snug text-muted-foreground',
|
||||
compactMode ? 'truncate' : 'line-clamp-2 break-words'
|
||||
)}
|
||||
title={taskTitle}
|
||||
>
|
||||
{taskTitle}
|
||||
</div>
|
||||
) : null}
|
||||
{showStatusPreview ? (
|
||||
<CommentMarkdown
|
||||
content={renderedResponsePreview}
|
||||
className={cn(
|
||||
'h-[1lh] min-w-0 overflow-hidden truncate whitespace-nowrap text-[11px] font-normal leading-snug text-muted-foreground/80',
|
||||
'[&_*]:inline [&_*]:!m-0 [&_*]:!p-0 [&_*]:!whitespace-nowrap [&_br]:hidden [&_ol]:list-none [&_ul]:list-none'
|
||||
)}
|
||||
title={thread.responsePreview}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 items-center gap-1.5 pt-0.5">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/80">{agentLabel}</span>
|
||||
{canJump ? (
|
||||
<span
|
||||
className={cn(
|
||||
'group/unread flex size-4 shrink-0 cursor-pointer items-center justify-center rounded transition-all',
|
||||
'hover:bg-accent/80 active:scale-95',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.activity.ActivityPrototypePage.59b131fbd9',
|
||||
'Mark thread unread'
|
||||
'ml-auto inline-flex shrink-0 items-center transition-opacity',
|
||||
'can-hover:pointer-events-none can-hover:invisible can-hover:opacity-0',
|
||||
'group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Bell className="size-3 text-muted-foreground/40 can-hover:opacity-0 transition-opacity group-hover:opacity-100 group-hover/unread:opacity-100" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{translate(
|
||||
'auto.components.activity.ActivityPrototypePage.59b131fbd9',
|
||||
'Mark thread unread'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
<EventTime timestamp={thread.latestTimestamp} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5 pl-[42px]">
|
||||
<EventRepoBadge repo={thread.repo} />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
|
||||
{thread.worktree.displayName}
|
||||
</span>
|
||||
{/* Why (Jump-to-workspace lives on the secondary row): the bell slot
|
||||
on the title row already holds the unread/Mark-unread state, so
|
||||
the navigation action gets its own slot down here aligned with
|
||||
the worktree name. On hover-capable pointers, the hidden state
|
||||
keeps the worktree-name's flex-1 width stable across hover. */}
|
||||
{canJump ? (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto inline-flex shrink-0 items-center transition-opacity',
|
||||
'can-hover:pointer-events-none can-hover:invisible can-hover:opacity-0',
|
||||
'group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.activity.ActivityPrototypePage.4616ea39fd',
|
||||
'Jump to workspace'
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onJump()
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{translate(
|
||||
'auto.components.activity.ActivityPrototypePage.4616ea39fd',
|
||||
'Jump to workspace'
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.activity.ActivityPrototypePage.4616ea39fd',
|
||||
'Jump to workspace'
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onJump()
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{translate(
|
||||
'auto.components.activity.ActivityPrototypePage.4616ea39fd',
|
||||
'Jump to workspace'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 pt-px">
|
||||
<span className="inline-flex size-4 shrink-0 items-center justify-center">
|
||||
{thread.unread ? (
|
||||
<FilledBellIcon
|
||||
className="size-[13px] shrink-0 text-amber-500 drop-shadow-sm"
|
||||
aria-label={translate(
|
||||
'auto.components.activity.ActivityPrototypePage.beb2c19173',
|
||||
'Unread'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onMarkUnread()
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
className={cn(
|
||||
'group/unread flex size-4 shrink-0 cursor-pointer items-center justify-center rounded transition-all',
|
||||
'hover:bg-accent/80 active:scale-95',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.activity.ActivityPrototypePage.59b131fbd9',
|
||||
'Mark thread unread'
|
||||
)}
|
||||
>
|
||||
<Bell className="size-3 text-muted-foreground/40 can-hover:opacity-0 transition-opacity group-hover:opacity-100 group-hover/unread:opacity-100" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{translate(
|
||||
'auto.components.activity.ActivityPrototypePage.59b131fbd9',
|
||||
'Mark thread unread'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<EventTime timestamp={thread.latestTimestamp} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1439,7 +1465,8 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
repoMap: getRepoMapFromState(s),
|
||||
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
|
||||
acknowledgeAgents: s.acknowledgeAgents,
|
||||
unacknowledgeAgents: s.unacknowledgeAgents
|
||||
unacknowledgeAgents: s.unacknowledgeAgents,
|
||||
generatedTitlesEnabled: s.settings?.tabAutoGenerateTitle === true
|
||||
}))
|
||||
)
|
||||
// Why: agentStatusEpoch is included in the dependency array (but not in the
|
||||
@@ -1468,8 +1495,13 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
)
|
||||
|
||||
const allThreads = useMemo(
|
||||
() => buildAgentPaneThreads({ events: allEvents, liveAgentByPaneKey }),
|
||||
[allEvents, liveAgentByPaneKey]
|
||||
() =>
|
||||
buildAgentPaneThreads({
|
||||
events: allEvents,
|
||||
liveAgentByPaneKey,
|
||||
generatedTitlesEnabled: storeData.generatedTitlesEnabled
|
||||
}),
|
||||
[allEvents, liveAgentByPaneKey, storeData.generatedTitlesEnabled]
|
||||
)
|
||||
const selectedPaneKeyIsLive =
|
||||
selectedPaneKey === null || allThreads.some((thread) => thread.paneKey === selectedPaneKey)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getActivityThreadStatusPreview,
|
||||
getActivityThreadTaskTitle,
|
||||
getActivityThreadWorkspaceTitle,
|
||||
isTerseAgentFollowUpPrompt,
|
||||
resolveActivityThreadStatusPreview
|
||||
} from './activity-thread-display'
|
||||
|
||||
describe('isTerseAgentFollowUpPrompt', () => {
|
||||
it('flags common short follow-ups', () => {
|
||||
expect(isTerseAgentFollowUpPrompt('yes')).toBe(true)
|
||||
expect(isTerseAgentFollowUpPrompt('ok proceed')).toBe(true)
|
||||
expect(isTerseAgentFollowUpPrompt('Looks good.')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps substantive prompts', () => {
|
||||
expect(isTerseAgentFollowUpPrompt('Compare gpt5 claude prompting')).toBe(false)
|
||||
expect(isTerseAgentFollowUpPrompt('Skill creator codex port')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActivityThreadWorkspaceTitle', () => {
|
||||
it('prefers the stored display name', () => {
|
||||
expect(
|
||||
getActivityThreadWorkspaceTitle({
|
||||
displayName: 'Compound engineering plugin',
|
||||
branch: 'main'
|
||||
})
|
||||
).toBe('Compound engineering plugin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActivityThreadTaskTitle', () => {
|
||||
const tab = {
|
||||
customTitle: null,
|
||||
generatedTitle: 'Refactor auth middleware',
|
||||
title: 'Claude',
|
||||
defaultTitle: 'Claude'
|
||||
}
|
||||
|
||||
it('prefers custom title, then sticky orchestration labels', () => {
|
||||
expect(
|
||||
getActivityThreadTaskTitle({
|
||||
entry: {
|
||||
prompt: 'yes',
|
||||
stateHistory: [],
|
||||
orchestration: {
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
displayName: 'Fix checkout race'
|
||||
}
|
||||
},
|
||||
tab: { ...tab, customTitle: 'My rename' },
|
||||
generatedTitlesEnabled: true
|
||||
})
|
||||
).toBe('My rename')
|
||||
|
||||
expect(
|
||||
getActivityThreadTaskTitle({
|
||||
entry: { prompt: 'yes', stateHistory: [] },
|
||||
tab,
|
||||
generatedTitlesEnabled: true
|
||||
})
|
||||
).toBe('Refactor auth middleware')
|
||||
})
|
||||
|
||||
it('ignores terse live prompts and uses generated title or history', () => {
|
||||
expect(
|
||||
getActivityThreadTaskTitle({
|
||||
entry: {
|
||||
prompt: 'yes',
|
||||
stateHistory: [{ state: 'working', prompt: 'Skill creator codex port', startedAt: 1 }]
|
||||
},
|
||||
tab: { ...tab, generatedTitle: undefined },
|
||||
generatedTitlesEnabled: true
|
||||
})
|
||||
).toBe('Skill creator codex port')
|
||||
})
|
||||
|
||||
it('picks the most recent substantive prompt from history, not the longest', () => {
|
||||
expect(
|
||||
getActivityThreadTaskTitle({
|
||||
entry: {
|
||||
prompt: 'yes',
|
||||
stateHistory: [
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'Refactor the entire authentication middleware layer',
|
||||
startedAt: 1
|
||||
},
|
||||
{ state: 'working', prompt: 'Fix logout', startedAt: 2 }
|
||||
]
|
||||
},
|
||||
tab: { ...tab, generatedTitle: undefined },
|
||||
generatedTitlesEnabled: false
|
||||
})
|
||||
).toBe('Fix logout')
|
||||
})
|
||||
|
||||
it('ignores the generated title when generated titles are disabled', () => {
|
||||
expect(
|
||||
getActivityThreadTaskTitle({
|
||||
entry: {
|
||||
prompt: 'yes',
|
||||
stateHistory: [{ state: 'working', prompt: 'Wire up the export button', startedAt: 1 }]
|
||||
},
|
||||
tab,
|
||||
generatedTitlesEnabled: false
|
||||
})
|
||||
).toBe('Wire up the export button')
|
||||
})
|
||||
|
||||
it('keeps orchestration labels across terse follow-ups but yields to new work', () => {
|
||||
const orchestration = { taskId: 'task-1', dispatchId: 'ctx-1', displayName: 'Fix checkout race' }
|
||||
// Terse follow-up → still the same orchestration task.
|
||||
expect(
|
||||
getActivityThreadTaskTitle({
|
||||
entry: { prompt: 'yes', stateHistory: [], orchestration },
|
||||
tab: { ...tab, generatedTitle: undefined },
|
||||
generatedTitlesEnabled: false
|
||||
})
|
||||
).toBe('Fix checkout race')
|
||||
// Substantive non-dispatch prompt → pane moved on; stale label must not pin.
|
||||
expect(
|
||||
getActivityThreadTaskTitle({
|
||||
entry: { prompt: 'Investigate the flaky login test', stateHistory: [], orchestration },
|
||||
tab: { ...tab, generatedTitle: undefined },
|
||||
generatedTitlesEnabled: false
|
||||
})
|
||||
).toBe('Investigate the flaky login test')
|
||||
})
|
||||
|
||||
it('parses dispatch task bodies from history when the live prompt is a follow-up', () => {
|
||||
expect(
|
||||
getActivityThreadTaskTitle({
|
||||
entry: {
|
||||
prompt: 'ok',
|
||||
stateHistory: [
|
||||
{
|
||||
state: 'done',
|
||||
prompt: `You are working inside Orca, a multi-agent IDE. Your task ID is: task-1
|
||||
|
||||
=== TASK ===
|
||||
Compare gpt5 claude prompting`,
|
||||
startedAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
tab: { ...tab, generatedTitle: undefined },
|
||||
generatedTitlesEnabled: false
|
||||
})
|
||||
).toBe('Compare gpt5 claude prompting')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActivityThreadStatusPreview', () => {
|
||||
it('shows tool activity while working and assistant replies otherwise', () => {
|
||||
expect(
|
||||
getActivityThreadStatusPreview({
|
||||
state: 'working',
|
||||
toolName: 'Bash',
|
||||
toolInput: 'pnpm test',
|
||||
prompt: 'Run tests'
|
||||
})
|
||||
).toBe('Bash: pnpm test')
|
||||
|
||||
expect(
|
||||
getActivityThreadStatusPreview(
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'yes',
|
||||
lastAssistantMessage: 'Implemented the skill creator port.'
|
||||
},
|
||||
'done'
|
||||
)
|
||||
).toBe('Implemented the skill creator port.')
|
||||
})
|
||||
|
||||
it('rejects hook previews that echo the live user prompt', () => {
|
||||
expect(
|
||||
getActivityThreadStatusPreview({
|
||||
state: 'working',
|
||||
prompt: 'yes',
|
||||
lastAssistantMessage: 'yes'
|
||||
})
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('surfaces interrupted sessions explicitly', () => {
|
||||
expect(
|
||||
getActivityThreadStatusPreview({
|
||||
state: 'done',
|
||||
interrupted: true,
|
||||
prompt: 'Ship it'
|
||||
})
|
||||
).toBe('Interrupted by user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveActivityThreadStatusPreview', () => {
|
||||
it('keeps the previous assistant preview when a new ping mislabels the user prompt', () => {
|
||||
expect(
|
||||
resolveActivityThreadStatusPreview(
|
||||
{
|
||||
state: 'working',
|
||||
prompt: 'yes',
|
||||
lastAssistantMessage: 'yes'
|
||||
},
|
||||
'working',
|
||||
'Implemented the skill creator port.'
|
||||
)
|
||||
).toBe('Implemented the skill creator port.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
AgentStateHistoryEntry,
|
||||
AgentStatusEntry,
|
||||
AgentStatusState
|
||||
} from '../../../shared/agent-status-types'
|
||||
import type { TerminalTab, Worktree } from '../../../shared/types'
|
||||
import {
|
||||
getAgentRowPrimaryText,
|
||||
isOrcaDispatchPrompt,
|
||||
orchestrationLabelsMatchLiveDispatch
|
||||
} from './agent-row-primary-text'
|
||||
|
||||
// Why: follow-up replies ("yes", "ok proceed") are valid hook prompts but are
|
||||
// terrible scan labels for a cross-worktree agent list — treat them as non-titles.
|
||||
const TERSE_FOLLOW_UP_PATTERN =
|
||||
/^(yes|no|ok|yep|nope|sure|thanks|thank you|please|proceed|continue|go ahead|lgtm|done|looks good|ok proceed)\.?$/i
|
||||
|
||||
export function isTerseAgentFollowUpPrompt(prompt: string): boolean {
|
||||
const trimmed = prompt.trim()
|
||||
if (!trimmed) {
|
||||
return true
|
||||
}
|
||||
if (trimmed.length > 24) {
|
||||
return false
|
||||
}
|
||||
return TERSE_FOLLOW_UP_PATTERN.test(trimmed)
|
||||
}
|
||||
|
||||
function taskTitleFromPrompt(prompt: string): string | null {
|
||||
if (isOrcaDispatchPrompt(prompt)) {
|
||||
const preview = getAgentRowPrimaryText({ prompt })
|
||||
return preview || null
|
||||
}
|
||||
const trimmed = prompt.trim()
|
||||
if (!trimmed || isTerseAgentFollowUpPrompt(trimmed)) {
|
||||
return null
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function bestTaskPromptFromHistory(history: readonly AgentStateHistoryEntry[]): string | null {
|
||||
// Why: the most recent substantive turn is the current task — older prompts
|
||||
// (even longer ones) must not shadow newer work. Compare startedAt rather
|
||||
// than array position so out-of-order history still resolves the latest turn.
|
||||
let best: string | null = null
|
||||
let bestStartedAt = Number.NEGATIVE_INFINITY
|
||||
for (const historyEntry of history) {
|
||||
const candidate = taskTitleFromPrompt(historyEntry.prompt)
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
if (historyEntry.startedAt >= bestStartedAt) {
|
||||
best = candidate
|
||||
bestStartedAt = historyEntry.startedAt
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Why: orchestration labels are the stable identity across follow-up turns, but
|
||||
// sticky metadata can outlive the task. Trust the label only when it still
|
||||
// describes the live work: a dispatch turn must share the task id (mirrors
|
||||
// getAgentRowPrimaryText), and a substantive non-dispatch prompt means the pane
|
||||
// moved on to new work — a terse follow-up ("yes") is still the same task.
|
||||
function orchestrationLabelForEntry(
|
||||
entry: Pick<AgentStatusEntry, 'orchestration' | 'prompt'>
|
||||
): string | null {
|
||||
const label =
|
||||
entry.orchestration?.displayName?.trim() || entry.orchestration?.taskTitle?.trim() || ''
|
||||
if (!label) {
|
||||
return null
|
||||
}
|
||||
if (isOrcaDispatchPrompt(entry.prompt)) {
|
||||
return orchestrationLabelsMatchLiveDispatch(entry) ? label : null
|
||||
}
|
||||
if (taskTitleFromPrompt(entry.prompt)) {
|
||||
return null
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
/** Friendly workspace label — matches the sidebar worktree card's primary name. */
|
||||
export function getActivityThreadWorkspaceTitle(
|
||||
worktree: Pick<Worktree, 'displayName' | 'branch'>
|
||||
): string {
|
||||
const displayName = worktree.displayName?.trim()
|
||||
const branch = worktree.branch?.trim()
|
||||
if (displayName) {
|
||||
return displayName
|
||||
}
|
||||
return branch || 'Workspace'
|
||||
}
|
||||
|
||||
/** Stable task identity for Activity sidebar rows — not the latest follow-up turn. */
|
||||
export function getActivityThreadTaskTitle(args: {
|
||||
entry: Pick<AgentStatusEntry, 'orchestration' | 'prompt' | 'stateHistory'>
|
||||
tab: Pick<TerminalTab, 'customTitle' | 'generatedTitle' | 'title' | 'defaultTitle'>
|
||||
generatedTitlesEnabled: boolean
|
||||
}): string {
|
||||
const customTitle = args.tab.customTitle?.trim()
|
||||
if (customTitle) {
|
||||
return customTitle
|
||||
}
|
||||
|
||||
const orchestrationLabel = orchestrationLabelForEntry(args.entry)
|
||||
if (orchestrationLabel) {
|
||||
return orchestrationLabel
|
||||
}
|
||||
|
||||
// Why: respect the user's tabAutoGenerateTitle setting — a disabled generated
|
||||
// title must not resurface here (mirrors resolveTerminalTabTitle's gate).
|
||||
const generatedTitle = args.generatedTitlesEnabled ? args.tab.generatedTitle?.trim() : ''
|
||||
if (generatedTitle) {
|
||||
return generatedTitle
|
||||
}
|
||||
|
||||
// Why: a substantive live prompt is genuine new work and must win — the row
|
||||
// title follows the active turn (see buildAgentPaneThreads). Only a terse
|
||||
// follow-up ("yes") falls through to the prior task recorded in history.
|
||||
const liveTitle = taskTitleFromPrompt(args.entry.prompt)
|
||||
if (liveTitle) {
|
||||
return liveTitle
|
||||
}
|
||||
|
||||
const historical = bestTaskPromptFromHistory(args.entry.stateHistory)
|
||||
if (historical) {
|
||||
return historical
|
||||
}
|
||||
|
||||
const liveTabTitle = args.tab.title?.trim()
|
||||
const defaultTabTitle = args.tab.defaultTitle?.trim()
|
||||
if (liveTabTitle && liveTabTitle !== defaultTabTitle) {
|
||||
return liveTabTitle
|
||||
}
|
||||
return defaultTabTitle || liveTabTitle || 'Terminal'
|
||||
}
|
||||
|
||||
function isMislabeledUserPrompt(text: string, entry: Pick<AgentStatusEntry, 'prompt'>): boolean {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
return true
|
||||
}
|
||||
if (isTerseAgentFollowUpPrompt(trimmed)) {
|
||||
return true
|
||||
}
|
||||
// Why: some hooks echo the live user prompt into assistant preview fields
|
||||
// between turns; never surface that as the agent's latest reply.
|
||||
if (trimmed === entry.prompt.trim()) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Latest agent activity line — tool step while working, assistant reply otherwise. */
|
||||
export function getActivityThreadStatusPreview(
|
||||
entry: Pick<
|
||||
AgentStatusEntry,
|
||||
'state' | 'toolName' | 'toolInput' | 'lastAssistantMessage' | 'interrupted' | 'prompt'
|
||||
>,
|
||||
agentState?: AgentStatusState | null
|
||||
): string {
|
||||
if (entry.interrupted === true) {
|
||||
return 'Interrupted by user'
|
||||
}
|
||||
const state = agentState ?? entry.state
|
||||
if (state === 'working') {
|
||||
const toolName = entry.toolName?.trim() ?? ''
|
||||
const toolInput = entry.toolInput?.trim() ?? ''
|
||||
if (toolName && toolInput) {
|
||||
return `${toolName}: ${toolInput}`
|
||||
}
|
||||
if (toolName) {
|
||||
return toolName
|
||||
}
|
||||
}
|
||||
const assistant = entry.lastAssistantMessage?.trim() ?? ''
|
||||
if (assistant && !isMislabeledUserPrompt(assistant, entry)) {
|
||||
return assistant
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Keep the last good assistant preview when a new hook ping clears or mislabels it. */
|
||||
export function resolveActivityThreadStatusPreview(
|
||||
entry: Pick<
|
||||
AgentStatusEntry,
|
||||
'state' | 'toolName' | 'toolInput' | 'lastAssistantMessage' | 'interrupted' | 'prompt'
|
||||
>,
|
||||
agentState: AgentStatusState | null | undefined,
|
||||
previousPreview?: string
|
||||
): string {
|
||||
const next = getActivityThreadStatusPreview(entry, agentState)
|
||||
if (next) {
|
||||
return next
|
||||
}
|
||||
// Why: only bridge a transient empty/mislabeled ping within the SAME turn. A
|
||||
// substantive live prompt marks a new turn, so the prior turn's reply must not
|
||||
// linger as the current status (a fresh working turn shows no stale preview).
|
||||
if (!isTerseAgentFollowUpPrompt(entry.prompt)) {
|
||||
return ''
|
||||
}
|
||||
const previous = previousPreview?.trim() ?? ''
|
||||
if (previous && !isMislabeledUserPrompt(previous, entry)) {
|
||||
return previous
|
||||
}
|
||||
return ''
|
||||
}
|
||||
Reference in New Issue
Block a user