mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(native-chat): a chat is named from its first message at once, so a failed name generation no longer leaves 'Codex Chat' (#30392)
* fix(native-chat): name a chat from its first message at once; the generated name replaces it A native chat's one stored name was written only by a best-effort, one-shot generation, so any failure left every surface on the 'Codex Chat' placeholder. The first prompt now names the chat when its first turn starts, and the generated name replaces it only while the field still holds that seed. * fix(native-chat): the chat names setting says a chat shows its first message until it is named
This commit is contained in:
@@ -17,18 +17,18 @@ A `<webview>` guest is an inner WebContents in the window's frame tree. Chromium
|
||||
that tree's focus, not by which debugger the command arrived on. A hidden-window probe (host
|
||||
`<textarea>` focused, guest page logging its own events) measured:
|
||||
|
||||
| Input | Where it lands | Host focus |
|
||||
| --- | --- | --- |
|
||||
| CDP `Input.dispatchKeyEvent` / `Input.insertText` on the guest's main session | **host textarea** | kept |
|
||||
| same, with `Emulation.setFocusEmulationEnabled` | **host textarea** | kept |
|
||||
| `webContents.sendInputEvent` key / `char` on the guest | guest | kept |
|
||||
| CDP key / `insertText` on a cross-site iframe's own (auto-attached) session | that iframe | kept |
|
||||
| `webContents.insertText` with focus in a cross-site iframe | **guest renderer crashed** | — |
|
||||
| `sendInputEvent` key with focus in a same-site iframe | that iframe | kept |
|
||||
| any mouse press (CDP mouse, touch, tap gesture, `sendInputEvent`) | guest | **moved to the webview** |
|
||||
| `inert` / `tabindex=-1` on the `<webview>` during the press | guest | **moved** |
|
||||
| press + synchronous host restore on the `<webview>` `focus` event | guest | restored, next user key lands in the host |
|
||||
| the restore above with focus emulation enabled first | guest | **not restored** (the host `focus` event never fires) |
|
||||
| Input | Where it lands | Host focus |
|
||||
| ----------------------------------------------------------------------------- | -------------------------- | ----------------------------------------------------- |
|
||||
| CDP `Input.dispatchKeyEvent` / `Input.insertText` on the guest's main session | **host textarea** | kept |
|
||||
| same, with `Emulation.setFocusEmulationEnabled` | **host textarea** | kept |
|
||||
| `webContents.sendInputEvent` key / `char` on the guest | guest | kept |
|
||||
| CDP key / `insertText` on a cross-site iframe's own (auto-attached) session | that iframe | kept |
|
||||
| `webContents.insertText` with focus in a cross-site iframe | **guest renderer crashed** | — |
|
||||
| `sendInputEvent` key with focus in a same-site iframe | that iframe | kept |
|
||||
| any mouse press (CDP mouse, touch, tap gesture, `sendInputEvent`) | guest | **moved to the webview** |
|
||||
| `inert` / `tabindex=-1` on the `<webview>` during the press | guest | **moved** |
|
||||
| press + synchronous host restore on the `<webview>` `focus` event | guest | restored, next user key lands in the host |
|
||||
| the restore above with focus emulation enabled first | guest | **not restored** (the host `focus` event never fires) |
|
||||
|
||||
With focus emulation on, the press still moves focus and the restore on the `focus` event never
|
||||
runs, but restoring when the press ends works and the page sees no blur, so a menu or picker the
|
||||
@@ -95,12 +95,12 @@ two gaps.
|
||||
|
||||
## Tracker
|
||||
|
||||
| Step | PR | State |
|
||||
| --- | --- | --- |
|
||||
| CDP focus side effects removed (`bringToFront`, `activateTarget`) + this doc | stack 1/3 | in review |
|
||||
| Keys and text delivered to the guest directly | stack 2/3 | in review |
|
||||
| Presses and CDP-only input borrow focus and give it back; focus emulation | stack 3/3 | in review |
|
||||
| Regression spec: user typing in a terminal while an agent clicks, types and presses keys | stack 2/3, 3/3 | in review |
|
||||
| Visible-window spec (`@headful`, isolated display only): native focus stays on Orca's window | stack 3/3 | in review |
|
||||
| Main-session keys and text for a focused cross-site iframe go to its own session, no focus borrow | follow-up PR | in review |
|
||||
| Agent presses are refused while the user's IME composition is open (#28700) | follow-up PR | in review |
|
||||
| Step | PR | State |
|
||||
| ------------------------------------------------------------------------------------------------- | -------------- | --------- |
|
||||
| CDP focus side effects removed (`bringToFront`, `activateTarget`) + this doc | stack 1/3 | in review |
|
||||
| Keys and text delivered to the guest directly | stack 2/3 | in review |
|
||||
| Presses and CDP-only input borrow focus and give it back; focus emulation | stack 3/3 | in review |
|
||||
| Regression spec: user typing in a terminal while an agent clicks, types and presses keys | stack 2/3, 3/3 | in review |
|
||||
| Visible-window spec (`@headful`, isolated display only): native focus stays on Orca's window | stack 3/3 | in review |
|
||||
| Main-session keys and text for a focused cross-site iframe go to its own session, no focus borrow | follow-up PR | in review |
|
||||
| Agent presses are refused while the user's IME composition is open (#28700) | follow-up PR | in review |
|
||||
|
||||
@@ -83,20 +83,30 @@ function rig(provider: 'claude' | 'codex') {
|
||||
}
|
||||
|
||||
describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provider) => {
|
||||
it('writes only the generated name without awaiting generation', async () => {
|
||||
it('names the chat from its first prompt at once, then takes the generated name', async () => {
|
||||
const state = rig(provider)
|
||||
const generation = deferred<string | null>()
|
||||
state.deps.generate = vi.fn(() => generation.promise)
|
||||
expect(state.handle(state.summary, { replay: false })).toBeUndefined()
|
||||
await settle()
|
||||
expect(state.read()?.conversationName).toBeUndefined()
|
||||
expect(state.setConversationName).not.toHaveBeenCalled()
|
||||
expect(state.deps.onNamed).not.toHaveBeenCalled()
|
||||
expect(state.read()?.conversationName).toBe('Please repair the login flow')
|
||||
expect(state.deps.onNamed).toHaveBeenCalledTimes(1)
|
||||
expect(state.deps.readFirstPrompt).toHaveBeenCalledWith(state.summary.sessionId, 100)
|
||||
generation.resolve('auth/login')
|
||||
await settle()
|
||||
expect(state.read()?.conversationName).toBe('auth/login')
|
||||
expect(state.deps.onNamed).toHaveBeenCalledTimes(1)
|
||||
expect(state.deps.onNamed).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clips a long first prompt into a short name', async () => {
|
||||
const state = rig(provider)
|
||||
const generation = deferred<string | null>()
|
||||
state.deps.generate = vi.fn(() => generation.promise)
|
||||
state.deps.readFirstPrompt = async () =>
|
||||
'use /agent-slack to search the thread and bring indexed search to cmd + j'
|
||||
state.handle(state.summary, { replay: false })
|
||||
await settle()
|
||||
expect(state.read()?.conversationName).toBe('use /agent-slack to search the thread and bring…')
|
||||
})
|
||||
|
||||
it.each([null, 'idle', 'attention'] as const)('does no work for status %s', async (status) => {
|
||||
@@ -172,7 +182,7 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid
|
||||
})
|
||||
|
||||
it.each(['rejected', 'declined', 'empty'] as const)(
|
||||
'leaves %s generation unnamed without retrying',
|
||||
'keeps the first-prompt name when generation is %s, without retrying',
|
||||
async (outcome) => {
|
||||
const state = rig(provider)
|
||||
state.deps.generate = vi.fn(async () => {
|
||||
@@ -186,9 +196,9 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid
|
||||
state.handle({ ...state.summary, status: 'idle' }, { replay: false })
|
||||
state.handle({ ...state.summary, latestPrompt: 'Second request' }, { replay: false })
|
||||
await settle()
|
||||
expect(state.read()?.conversationName).toBeUndefined()
|
||||
expect(state.read()?.conversationName).toBe('Please repair the login flow')
|
||||
expect(state.deps.generate).toHaveBeenCalledTimes(1)
|
||||
expect(state.setConversationName).not.toHaveBeenCalled()
|
||||
expect(state.setConversationName).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -206,7 +216,7 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid
|
||||
generation.resolve('Late agent name')
|
||||
await settle()
|
||||
expect(state.read()?.conversationName).toBe('User name')
|
||||
expect(state.deps.onNamed).not.toHaveBeenCalled()
|
||||
expect(state.deps.onNamed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('protects a name changed while the first prompt is read', async () => {
|
||||
@@ -254,7 +264,7 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid
|
||||
state.deps.generate = vi.fn(() => generation.promise)
|
||||
state.handle(state.summary, { replay: false })
|
||||
await settle()
|
||||
expect(state.read()?.conversationName).toBeUndefined()
|
||||
expect(state.read()?.conversationName).toBe(prompt)
|
||||
expect(state.deps.generate).toHaveBeenCalledWith(expect.anything(), prompt)
|
||||
generation.resolve('auth/login')
|
||||
await settle()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { StructuredAgentSessionStatusObserverOptions } from './agent-session-wire/structured-agent-session-status-observation'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
|
||||
import { clampConversationNameFirstPrompt } from '../../shared/conversation-name-generation'
|
||||
import {
|
||||
clampConversationNameFirstPrompt,
|
||||
conversationNameSeed
|
||||
} from '../../shared/conversation-name-generation'
|
||||
import { hasStructuredChatPromptText } from '../../shared/structured-agent-session-first-prompt'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
||||
@@ -43,35 +46,45 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps
|
||||
}
|
||||
}
|
||||
|
||||
async function generateName(
|
||||
store: NonNullable<ReturnType<StructuredChatNamingDeps['getStore']>>,
|
||||
record: AgentSessionRecord
|
||||
): Promise<string | null> {
|
||||
const firstPrompt = clampConversationNameFirstPrompt(
|
||||
await deps.readFirstPrompt(record.sessionId, hostStartedAt)
|
||||
)
|
||||
if (
|
||||
!hasStructuredChatPromptText(firstPrompt) ||
|
||||
store.getRecord(record.sessionId)?.conversationName !== undefined ||
|
||||
deps.getSettings().nativeChatAutoName === false
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return deps.generate(record, firstPrompt)
|
||||
}
|
||||
|
||||
async function name(summary: AgentSessionStatusSummary) {
|
||||
const store = deps.getStore()
|
||||
const record = store?.getRecord(summary.sessionId)
|
||||
if (!store || !record || record.conversationName !== undefined) {
|
||||
const namingOff = () => deps.getSettings().nativeChatAutoName === false
|
||||
if (!store || !record || record.conversationName !== undefined || namingOff()) {
|
||||
return
|
||||
}
|
||||
const workerTask = deps.workerTaskTitle(record)
|
||||
const chatName = workerTask ? workerTask.title : await generateName(store, record)
|
||||
if (!chatName || deps.getSettings().nativeChatAutoName === false) {
|
||||
if (workerTask) {
|
||||
const named =
|
||||
workerTask.title &&
|
||||
(await store.compareAndSetConversationName(summary.sessionId, workerTask.title, null))
|
||||
if (named) {
|
||||
refresh(named)
|
||||
}
|
||||
return
|
||||
}
|
||||
const named = await store.compareAndSetConversationName(summary.sessionId, chatName, null)
|
||||
const firstPrompt = clampConversationNameFirstPrompt(
|
||||
await deps.readFirstPrompt(record.sessionId, hostStartedAt)
|
||||
)
|
||||
const seed = hasStructuredChatPromptText(firstPrompt) ? conversationNameSeed(firstPrompt) : null
|
||||
if (!seed || namingOff()) {
|
||||
return
|
||||
}
|
||||
// The prompt names the chat at once; generation only upgrades it, so its failure keeps a name.
|
||||
const seeded = await store.compareAndSetConversationName(summary.sessionId, seed, null)
|
||||
if (!seeded?.conversationName) {
|
||||
return
|
||||
}
|
||||
refresh(seeded)
|
||||
const chatName = await deps.generate(seeded, firstPrompt)
|
||||
if (!chatName || namingOff()) {
|
||||
return
|
||||
}
|
||||
const named = await store.compareAndSetConversationName(
|
||||
summary.sessionId,
|
||||
chatName,
|
||||
seeded.conversationName
|
||||
)
|
||||
if (named) {
|
||||
refresh(named)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ export function structuredChatNamingDeps(
|
||||
}
|
||||
const target = await resolveGenerationTarget(workspace.path, settings.params.agentId, runtime)
|
||||
if (!target) {
|
||||
logger.warn('No chat name agent can run in this workspace', {
|
||||
scope: 'conversation-name',
|
||||
sessionId: record.sessionId
|
||||
})
|
||||
return null
|
||||
}
|
||||
const result = await generateConversationNameFromContext(
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getChatNamingSearchEntry = createLocalizedCatalog((): SettingsSearc
|
||||
title: translate('settings.chat.names.title', 'Chat names'),
|
||||
description: translate(
|
||||
'settings.chat.names.description',
|
||||
'Use an agent to name new chats from their first message. Without a generated name, chats stay Claude Chat or Codex Chat.'
|
||||
'Use an agent to name new chats from their first message. Until a name is generated, a chat shows its first message.'
|
||||
),
|
||||
keywords: [
|
||||
translate('settings.chat.names.enable', 'Name chats automatically'),
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
"description": "Choose how chats look and behave.",
|
||||
"names": {
|
||||
"title": "Chat names",
|
||||
"description": "Use an agent to name new chats from their first message. Without a generated name, chats stay Claude Chat or Codex Chat.",
|
||||
"description": "Use an agent to name new chats from their first message. Until a name is generated, a chat shows its first message.",
|
||||
"enable": "Name chats automatically",
|
||||
"recipe": "Chat name",
|
||||
"recipeDescription": "Generate a short name from the first message in a new chat.",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"description": "Elige cómo se ven y se comportan los chats.",
|
||||
"names": {
|
||||
"title": "Nombres de chats",
|
||||
"description": "Usa un agente para nombrar los chats nuevos a partir de su primer mensaje. Si no se genera un nombre, se mantienen como Claude Chat o Codex Chat.",
|
||||
"description": "Usa un agente para nombrar los chats nuevos a partir de su primer mensaje. Hasta que se genere un nombre, el chat muestra su primer mensaje.",
|
||||
"enable": "Nombrar chats automáticamente",
|
||||
"recipe": "Nombre del chat",
|
||||
"recipeDescription": "Genera un nombre breve a partir del primer mensaje de un chat nuevo.",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
"description": "Choisissez l’apparence et le comportement des chats.",
|
||||
"names": {
|
||||
"title": "Noms des chats",
|
||||
"description": "Utilisez un agent pour nommer les nouveaux chats à partir de leur premier message. Sans nom généré, ils restent Claude Chat ou Codex Chat.",
|
||||
"description": "Utilisez un agent pour nommer les nouveaux chats à partir de leur premier message. En attendant un nom généré, le chat affiche son premier message.",
|
||||
"enable": "Nommer automatiquement les chats",
|
||||
"recipe": "Nom du chat",
|
||||
"recipeDescription": "Génère un nom court à partir du premier message d’un nouveau chat.",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"description": "チャットの外観と動作を選びます。",
|
||||
"names": {
|
||||
"title": "チャット名",
|
||||
"description": "選択したツールが最初のメッセージから新規チャットの名前を生成します。名前が生成されない場合は、Claude Chat または Codex Chat のままです。",
|
||||
"description": "選択したツールが最初のメッセージから新規チャットの名前を生成します。名前が生成されるまでは、最初のメッセージが表示されます。",
|
||||
"enable": "チャットに自動で名前を付ける",
|
||||
"recipe": "チャット名",
|
||||
"recipeDescription": "新規チャットの最初のメッセージから短い名前を生成します。",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"description": "채팅의 모양과 동작 방식을 선택하세요.",
|
||||
"names": {
|
||||
"title": "채팅 이름",
|
||||
"description": "에이전트가 첫 메시지를 바탕으로 새 채팅의 이름을 생성합니다. 이름이 생성되지 않으면 Claude Chat 또는 Codex Chat으로 유지됩니다.",
|
||||
"description": "에이전트가 첫 메시지를 바탕으로 새 채팅의 이름을 생성합니다. 이름이 생성되기 전까지는 첫 메시지가 표시됩니다.",
|
||||
"enable": "채팅 이름 자동 지정",
|
||||
"recipe": "채팅 이름",
|
||||
"recipeDescription": "새 채팅의 첫 메시지에서 짧은 이름을 생성합니다.",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"description": "选择聊天的外观和行为方式。",
|
||||
"names": {
|
||||
"title": "聊天名称",
|
||||
"description": "使用智能体根据首条消息为新聊天生成名称。未生成名称时,聊天仍显示为 Claude Chat 或 Codex Chat。",
|
||||
"description": "使用智能体根据首条消息为新聊天生成名称。生成名称前,聊天显示其首条消息。",
|
||||
"enable": "自动为聊天命名",
|
||||
"recipe": "聊天名称",
|
||||
"recipeDescription": "根据新聊天的首条消息生成简短名称。",
|
||||
|
||||
@@ -57,14 +57,27 @@ export function sanitizeGeneratedConversationName(raw: string): string | null {
|
||||
name = unwrapped
|
||||
}
|
||||
name = normalizeAgentSessionConversationName(name) ?? ''
|
||||
if (!name) {
|
||||
return name ? normalizeAgentSessionConversationName(clipConversationName(name)) : null
|
||||
}
|
||||
|
||||
/** The chat's name until a generated one replaces it, so a failed generation never leaves the
|
||||
* placeholder label. */
|
||||
export function conversationNameSeed(firstPrompt: string): string | null {
|
||||
const prompt = normalizeAgentSessionConversationName(firstPrompt)
|
||||
if (!prompt) {
|
||||
return null
|
||||
}
|
||||
if (name.length > MAX_GENERATED_NAME_LENGTH) {
|
||||
const clipped = sliceAtCodeUnitLimit(name, MAX_GENERATED_NAME_LENGTH)
|
||||
const boundary = clipped.lastIndexOf(' ')
|
||||
name =
|
||||
boundary >= Math.floor(MAX_GENERATED_NAME_LENGTH / 2) ? clipped.slice(0, boundary) : clipped
|
||||
const clipped = clipConversationName(prompt)
|
||||
return normalizeAgentSessionConversationName(clipped === prompt ? prompt : `${clipped}…`)
|
||||
}
|
||||
|
||||
function clipConversationName(name: string): string {
|
||||
if (name.length <= MAX_GENERATED_NAME_LENGTH) {
|
||||
return name
|
||||
}
|
||||
return normalizeAgentSessionConversationName(name)
|
||||
const clipped = sliceAtCodeUnitLimit(name, MAX_GENERATED_NAME_LENGTH)
|
||||
const boundary = clipped.lastIndexOf(' ')
|
||||
return boundary >= Math.floor(MAX_GENERATED_NAME_LENGTH / 2)
|
||||
? clipped.slice(0, boundary)
|
||||
: clipped
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user