Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Spaces: allow empty Spaces (no last-node warning/auto-close), add pane context menu action to create an empty Space, and allow archiving a Space without saving its history. (#171)

### 🐞 Fixed
- Agent: derive directly launched Agent titles from their first bound session and keep short-turn completion notifications aligned with the resolved title. (#309)
- Sidebar: distinguish expandable Spaces from empty Spaces in the collapsed rail, keeping disclosure arrows only for Spaces with agents and using a subtle leaf marker for empty Spaces. (#308)
- Worktrees: prevent background Git status polling from acquiring index locks or leaving stale locks when commands time out. (#305)
- Worktree create: keep branch dropdowns above the Space worktree dialog so branches remain visible and selectable. (#304)
Expand Down
6 changes: 6 additions & 0 deletions scripts/test-agent-session-stub.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
runCodexClickRedrawAfterClickScenario,
runCodexStandbyNoNewlineScenario,
runCodexStandbyOnlyScenario,
runCodexTitleFromFirstInputScenario,
runJsonlStdinSubmitDelayedTurnScenario,
runJsonlStdinSubmitDrivenTurnScenario,
} from './test-agent-session-stub/codex.mjs'
Expand Down Expand Up @@ -81,6 +82,11 @@ async function main() {
return
}

if (provider === 'codex' && scenario === 'codex-title-from-first-input') {
await runCodexTitleFromFirstInputScenario(cwd)
return
}

if (
(provider === 'codex' || provider === 'claude-code') &&
scenario === 'jsonl-stdin-submit-delayed-turn'
Expand Down
83 changes: 83 additions & 0 deletions scripts/test-agent-session-stub/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,87 @@ export async function runCodexClickRedrawAfterClickScenario(cwd) {
await runRawClickRedrawAfterClickScenario()
}

function normalizeSubmittedPrompt(rawValue) {
let normalized = ''

for (let index = 0; index < rawValue.length; index += 1) {
const characterCode = rawValue.charCodeAt(index)
if (characterCode === 27 && rawValue[index + 1] === '[') {
index += 2
while (index < rawValue.length) {
const sequenceCode = rawValue.charCodeAt(index)
if (sequenceCode >= 64 && sequenceCode <= 126) {
break
}
index += 1
}
continue
}

if (characterCode < 32 || characterCode === 127) {
continue
}

normalized += rawValue[index]
}

return normalized.trim()
}

export async function runCodexTitleFromFirstInputScenario(cwd) {
const sessionFilePath = await createCodexSessionFile(cwd)
let pendingInput = ''
let capturedFirstPrompt = false

process.stdin.on('data', chunk => {
if (capturedFirstPrompt) {
return
}

pendingInput += Buffer.from(chunk).toString('utf8')
const submitIndex = pendingInput.search(/[\r\n]/)
if (submitIndex === -1) {
return
}

const prompt = normalizeSubmittedPrompt(pendingInput.slice(0, submitIndex))
pendingInput = pendingInput.slice(submitIndex + 1)
if (prompt.length === 0) {
return
}

capturedFirstPrompt = true
void (async () => {
await appendCodexRecord(sessionFilePath, {
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: prompt }],
},
})
await appendCodexRecord(sessionFilePath, {
type: 'event_msg',
payload: {
type: 'task_started',
turn_id: 'opencove-test-title-turn-1',
model_context_window: 128_000,
collaboration_mode_kind: 'default',
},
})
await sleep(50)
await appendCodexRecord(sessionFilePath, {
type: 'event_msg',
payload: {
type: 'task_complete',
turn_id: 'opencove-test-title-turn-1',
last_agent_message: 'Done.',
},
})
})()
})

await sleep(IDLE_SCENARIO_LIFETIME_MS)
}

export { runJsonlStdinSubmitDelayedTurnScenario, runJsonlStdinSubmitDrivenTurnScenario }
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useRef } from 'react'
import { useAppStore } from '../store/useAppStore'
import { waitForAgentStandbyNotificationTitle } from '../utils/agentStandbyNotificationTitleGate'
import {
type AgentStandbyNotificationPayload,
resolveAgentNodeForSessionId,
} from './useAgentStandbyNotificationWatcher'

export function useAgentStandbyNotificationTitleGate({
enabled,
onReady,
}: {
enabled: boolean
onReady: (payload: AgentStandbyNotificationPayload) => void
}): {
deferUntilTitleReady: (payload: AgentStandbyNotificationPayload) => void
cancelPendingTitle: (sessionId: string) => void
} {
const pendingBySessionIdRef = useRef<Map<string, AbortController>>(new Map())
const readyHandlerRef = useRef(onReady)

useEffect(() => {
readyHandlerRef.current = onReady
}, [onReady])

const deferUntilTitleReady = useCallback(
(payload: AgentStandbyNotificationPayload) => {
if (!enabled) {
return
}

if (!payload.awaitingSessionTitle) {
readyHandlerRef.current(payload)
return
}

if (pendingBySessionIdRef.current.has(payload.sessionId)) {
return
}

const abortController = new AbortController()
pendingBySessionIdRef.current.set(payload.sessionId, abortController)

void waitForAgentStandbyNotificationTitle({
initial: payload,
resolveLatest: () => resolveAgentNodeForSessionId(payload.sessionId),
subscribe: listener => useAppStore.subscribe(listener),
signal: abortController.signal,
}).then(latest => {
if (pendingBySessionIdRef.current.get(payload.sessionId) !== abortController) {
return
}

pendingBySessionIdRef.current.delete(payload.sessionId)
if (latest) {
readyHandlerRef.current(latest)
}
})
},
[enabled],
)

const cancelPendingTitle = useCallback((sessionId: string) => {
pendingBySessionIdRef.current.get(sessionId)?.abort()
pendingBySessionIdRef.current.delete(sessionId)
}, [])

useEffect(() => {
const pending = pendingBySessionIdRef.current
return () => {
pending.forEach(abortController => abortController.abort())
pending.clear()
}
}, [])

return { deferUntilTitleReady, cancelPendingTitle }
}
12 changes: 10 additions & 2 deletions src/app/renderer/shell/hooks/useAgentStandbyNotificationWatcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import type { TerminalSessionState, TerminalSessionStateEvent } from '@shared/contracts/dto'
import type { AgentRuntimeStatus } from '@contexts/agent/domain/types'
import { isAgentNodeAwaitingSessionTitle } from '@contexts/workspace/presentation/renderer/utils/agentSessionTitleSync'
import { useAppStore } from '../store/useAppStore'
import { getPtyEventHub } from '../utils/ptyEventHub'

Expand All @@ -13,12 +14,14 @@ function normalizeSessionId(rawValue: unknown): string | null {
return trimmed.length > 0 ? trimmed : null
}

function resolveAgentNodeForSessionId(sessionId: string): {
export function resolveAgentNodeForSessionId(sessionId: string): {
sessionId: string
workspaceId: string
workspaceName: string
workspacePath: string
nodeId: string
title: string
awaitingSessionTitle: boolean
runtimeStatus: AgentRuntimeStatus | null
executionDirectory: string
taskId: string | null
Expand All @@ -32,15 +35,18 @@ function resolveAgentNodeForSessionId(sessionId: string): {
}

const taskId = node.data.agent?.taskId ?? null
const agent = node.data.agent
const resolvedExecutionDirectory =
node.data.executionDirectory ?? node.data.agent?.executionDirectory ?? workspace.path
node.data.executionDirectory ?? agent?.executionDirectory ?? workspace.path

return {
sessionId,
workspaceId: workspace.id,
workspaceName: workspace.name,
workspacePath: workspace.path,
nodeId: node.id,
title: node.data.title,
awaitingSessionTitle: isAgentNodeAwaitingSessionTitle(node),
runtimeStatus: node.data.status,
executionDirectory: resolvedExecutionDirectory,
taskId,
Expand All @@ -58,6 +64,7 @@ export type AgentStandbyNotificationPayload = {
workspacePath: string
nodeId: string
title: string
awaitingSessionTitle: boolean
executionDirectory: string
taskId: string | null
}
Expand Down Expand Up @@ -127,6 +134,7 @@ export function useAgentStandbyNotificationWatcher({
workspacePath: resolved.workspacePath,
nodeId: resolved.nodeId,
title: resolved.title,
awaitingSessionTitle: resolved.awaitingSessionTitle,
executionDirectory: resolved.executionDirectory,
taskId: resolved.taskId,
})
Expand Down
46 changes: 20 additions & 26 deletions src/app/renderer/shell/hooks/useAgentStandbyNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type AgentStandbyNotificationPayload,
useAgentStandbyNotificationWatcher,
} from './useAgentStandbyNotificationWatcher'
import { useAgentStandbyNotificationTitleGate } from './useAgentStandbyNotificationTitleGate'

function normalizeComparablePath(pathValue: string, platform?: string): string {
const normalized = pathValue
Expand Down Expand Up @@ -151,10 +152,7 @@ export function useAgentStandbyNotifications({
dismiss: (id: string) => void
} {
const { t } = useTranslation()
const platform =
typeof window !== 'undefined' && window.opencoveApi?.meta?.platform
? window.opencoveApi.meta.platform
: undefined
const platform = window.opencoveApi?.meta?.platform
const workspaces = useAppStore(state => state.workspaces)
const areSystemNotificationsEnabled = useAppStore(
state => state.agentSettings.systemNotificationsEnabled,
Expand Down Expand Up @@ -183,7 +181,6 @@ export function useAgentStandbyNotifications({
const prInFlightByKeyRef = useRef<Map<string, Promise<GitHubPullRequestSummary | null>>>(
new Map(),
)

const workspacesById = useMemo(() => {
return new Map(workspaces.map(workspace => [workspace.id, workspace]))
}, [workspaces])
Expand Down Expand Up @@ -271,7 +268,7 @@ export function useAgentStandbyNotifications({
[platform],
)

const handleAgentEnteredStandby = useCallback(
const publishAgentEnteredStandby = useCallback(
(payload: AgentStandbyNotificationPayload) => {
if (!isStandbyBannerEnabled && !areSystemNotificationsEnabled) {
return
Expand Down Expand Up @@ -321,31 +318,28 @@ export function useAgentStandbyNotifications({
return previous
}

if (!shouldResolveBranch && !shouldResolvePullRequest) {
const updated = [next, ...previous]
return updated.length > maxVisible ? updated.slice(0, maxVisible) : updated
}

const updated = [next, ...previous]
return updated.length > maxVisible ? updated.slice(0, maxVisible) : updated
})
},
[
areSystemNotificationsEnabled,
isStandbyBannerEnabled,
maxVisible,
shouldResolveBranch,
shouldResolvePullRequest,
t,
workspacesById,
],
[areSystemNotificationsEnabled, isStandbyBannerEnabled, maxVisible, t, workspacesById],
)

const handleAgentEnteredWorking = useCallback((sessionId: string) => {
setNotifications(previous =>
previous.filter(notification => notification.sessionId !== sessionId),
)
}, [])
const { deferUntilTitleReady: handleAgentEnteredStandby, cancelPendingTitle } =
useAgentStandbyNotificationTitleGate({
enabled: isStandbyBannerEnabled || areSystemNotificationsEnabled,
onReady: publishAgentEnteredStandby,
})

const handleAgentEnteredWorking = useCallback(
(sessionId: string) => {
cancelPendingTitle(sessionId)
setNotifications(previous =>
previous.filter(notification => notification.sessionId !== sessionId),
)
},
[cancelPendingTitle],
)

useEffect(() => {
if (isStandbyBannerEnabled) {
Expand Down Expand Up @@ -490,7 +484,7 @@ export function useAgentStandbyNotifications({
])

useAgentStandbyNotificationWatcher({
enabled: isStandbyBannerEnabled,
enabled: isStandbyBannerEnabled || areSystemNotificationsEnabled,
onAgentEnteredStandby: handleAgentEnteredStandby,
onAgentEnteredWorking: handleAgentEnteredWorking,
})
Expand Down
Loading
Loading