diff --git a/apps/aevatar-console-web/src/locales/en-US.ts b/apps/aevatar-console-web/src/locales/en-US.ts index 7d63d6260..b269e70df 100644 --- a/apps/aevatar-console-web/src/locales/en-US.ts +++ b/apps/aevatar-console-web/src/locales/en-US.ts @@ -56,7 +56,8 @@ const enUSMessages = { 'pages.chat.index.deleteChatTitle': 'Delete conversation?', 'pages.chat.index.emptyDescription': 'Describe the Team, Member, or Workflow you want to create.', - 'pages.chat.index.failedToLoadConversation': 'Conversation could not be loaded', + 'pages.chat.index.failedToLoadConversation': + 'Conversation could not be loaded', 'pages.chat.index.failedToLoadHistory': 'Chat history could not be loaded', 'pages.chat.index.historyStoredInWorkspace': 'History is saved to this workspace.', @@ -90,7 +91,38 @@ const enUSMessages = { 'pages.chat.index.status.error': 'Error', 'pages.chat.index.status.needsConfirmation': 'Needs confirmation', 'pages.chat.index.status.streaming': 'Streaming', + 'pages.chat.index.status.stopped': 'Stopped', 'pages.chat.index.status.studioReady': 'Studio ready', + 'pages.chat.index.approvalContextMissing': + 'Run context is unavailable. Reload the conversation before retrying this approval.', + 'pages.chat.index.interventionContextMissing': + 'Run context is incomplete. Open Run Detail or reload before retrying.', + 'pages.chat.runLifecycle.completed': 'Run completed', + 'pages.chat.runLifecycle.contextMissing': 'Run context unavailable', + 'pages.chat.runLifecycle.contextUnavailable': + 'This history entry has no durable Run identity. Start a new turn or open Runs to recover context.', + 'pages.chat.runLifecycle.currentStep': 'Current step: {step}', + 'pages.chat.runLifecycle.failed': 'Run failed', + 'pages.chat.runLifecycle.label': 'Run lifecycle', + 'pages.chat.runLifecycle.needsYou': 'Run needs you', + 'pages.chat.runLifecycle.openRun': 'Open Run Detail', + 'pages.chat.runLifecycle.pending': 'Run pending', + 'pages.chat.runLifecycle.permissionRequired': 'Permission required', + 'pages.chat.runLifecycle.refreshFailed': + 'Durable Run state could not be refreshed. Open Run Detail or retry the conversation to recover.', + 'pages.chat.runLifecycle.refreshFailedTitle': 'Run state unavailable', + 'pages.chat.runLifecycle.reviewAccess': 'Review access', + 'pages.chat.runLifecycle.run': 'Run {runId}', + 'pages.chat.runLifecycle.running': 'Run in progress', + 'pages.chat.runLifecycle.runningTool': 'Running tool: {tool}', + 'pages.chat.runLifecycle.state.completed': 'Completed', + 'pages.chat.runLifecycle.state.failed': 'Failed', + 'pages.chat.runLifecycle.state.needsYou': 'Needs you', + 'pages.chat.runLifecycle.state.pending': 'Pending', + 'pages.chat.runLifecycle.state.recovery': 'Recovery', + 'pages.chat.runLifecycle.state.running': 'Running', + 'pages.chat.runLifecycle.state.stopped': 'Stopped', + 'pages.chat.runLifecycle.stopped': 'Run stopped', 'pages.chat.index.time.daysAgo': '{count}d ago', 'pages.chat.index.time.hoursAgo': '{count}h ago', 'pages.chat.index.time.justNow': 'just now', @@ -171,14 +203,12 @@ const enUSMessages = { 'pages.missionwall.status.unknown': 'UNKNOWN', 'pages.missionwall.status.waiting': 'WAIT', 'pages.missionwall.state.auditLoading': 'Loading selected run', - 'pages.missionwall.state.auditPendingTitle': - 'No step flow for this run yet', + 'pages.missionwall.state.auditPendingTitle': 'No step flow for this run yet', 'pages.missionwall.state.emptyKicker': 'Waiting for runs', 'pages.missionwall.state.emptyTitle': 'No published workflows are visible', 'pages.missionwall.state.loadingKicker': 'Loading runtime', 'pages.missionwall.state.loadingTitle': 'Loading workflow runs', - 'pages.missionwall.state.publishedWorkflowTitle': - 'No visible run', + 'pages.missionwall.state.publishedWorkflowTitle': 'No visible run', 'pages.missionwall.stageSubtitle': 'Team {teamName} · {memberName}', 'pages.missionwall.stageTitle': '{workflowName} · Step Flow', 'pages.missionwall.stepStatus.active': 'ACTIVE', @@ -808,8 +838,10 @@ const enUSMessages = { 'teams.automations.authorization.error': 'Authorization could not continue', 'teams.automations.authorization.exactAccess': 'Exact access', 'teams.automations.authorization.expiry': 'Credential expiry', - 'teams.automations.authorization.noExternalGrants': 'No external NyxID service or owner LLM model grant is required.', - 'teams.automations.authorization.noOwnerLLMGrant': 'No owner LLM model grant is required for this workflow.', + 'teams.automations.authorization.noExternalGrants': + 'No external NyxID service or owner LLM model grant is required.', + 'teams.automations.authorization.noOwnerLLMGrant': + 'No owner LLM model grant is required for this workflow.', 'teams.automations.authorization.preparing': 'Preparing authorization review', 'teams.automations.authorization.review': 'Review authorization', 'teams.automations.authorization.reviewAgain': 'Review again', @@ -818,10 +850,14 @@ const enUSMessages = { 'teams.automations.authorizationStatus.active': 'Credential active', 'teams.automations.authorizationStatus.deleting': 'Deleting', 'teams.automations.authorizationStatus.failed': 'Authorization failed', - 'teams.automations.authorizationStatus.needs_authorization': 'Authorization required', - 'teams.automations.authorizationStatus.provisioning_pending': 'Preparing authorization', - 'teams.automations.authorizationStatus.replacement_pending': 'Replacing authorization', - 'teams.automations.authorizationStatus.revocation_pending': 'Revocation pending', + 'teams.automations.authorizationStatus.needs_authorization': + 'Authorization required', + 'teams.automations.authorizationStatus.provisioning_pending': + 'Preparing authorization', + 'teams.automations.authorizationStatus.replacement_pending': + 'Replacing authorization', + 'teams.automations.authorizationStatus.revocation_pending': + 'Revocation pending', 'teams.automations.columns.actions': 'Actions', 'teams.automations.columns.automation': 'Automation', 'teams.automations.columns.member': 'Member', @@ -914,8 +950,7 @@ const enUSMessages = { 'teams.automations.loading': 'Loading automations', 'teams.automations.member.publishFirst': 'Publish this member before adding recurring work.', - 'teams.automations.member.publishedServiceReady': - 'Published service ready', + 'teams.automations.member.publishedServiceReady': 'Published service ready', 'teams.automations.member.unknown': 'Unknown member', 'teams.automations.memberDescription': 'Dedicated Agent Keys are authorized per schedule and held by Aevatar.', @@ -925,7 +960,8 @@ const enUSMessages = { 'teams.automations.memberTitle': '{memberName} automations', 'teams.automations.memberUnavailable.description': 'This member is not part of the current Team. Choose another member.', - 'teams.automations.memberUnavailable.title': 'Member unavailable for automation', + 'teams.automations.memberUnavailable.title': + 'Member unavailable for automation', 'teams.automations.member.workflowOnly': 'Only workflow members can have recurring work.', 'teams.automations.schedule.next': 'Next {time}', @@ -1217,10 +1253,8 @@ const enUSMessages = { 'No safely scoped executions are available for this workflow member.', 'teamMemberWorkflowStudio.executionsPanel.fallbackName': 'Workflow execution', 'teamMemberWorkflowStudio.executionsPanel.inspect': 'Inspect', - 'teamMemberWorkflowStudio.executionsPanel.sectionAria': - 'Workflow executions', - 'teamMemberWorkflowStudio.executionsPanel.serviceMeta': - 'Service ready', + 'teamMemberWorkflowStudio.executionsPanel.sectionAria': 'Workflow executions', + 'teamMemberWorkflowStudio.executionsPanel.serviceMeta': 'Service ready', 'teamMemberWorkflowStudio.executionsPanel.title': 'Executions', 'teamMemberWorkflowStudio.executionsPanel.unknownStatus': 'unknown', 'teamMemberWorkflowStudio.graph.branchCount.one': '1 branch', @@ -1529,7 +1563,8 @@ const enUSMessages = { 'pages.scopes.invoke.selected.service': 'the selected service', 'pages.scopes.invoke.service': 'Service', 'pages.scopes.invoke.version.ready': 'Version ready', - 'pages.scopes.scopeserviceruntimeworkbench.actor.available': 'Actor available', + 'pages.scopes.scopeserviceruntimeworkbench.actor.available': + 'Actor available', 'pages.scopes.scopeserviceruntimeworkbench.binding': 'Binding', 'pages.scopes.scopeserviceruntimeworkbench.binding.accepted.for.retirement': 'Binding was accepted for retirement.', @@ -1611,8 +1646,7 @@ const enUSMessages = { 'pages.studio.studioinvokediagnosticsdrawer.endpoint.ready': 'Endpoint ready', 'pages.studio.studiomemberinvokeinspector.service.ready': 'Service ready', 'pages.studio.studiomemberinvokeinspector.version.ready': 'Version ready', - 'pages.studio.studioworkbenchsections.runtime.available': - 'Runtime available', + 'pages.studio.studioworkbenchsections.runtime.available': 'Runtime available', 'pages.studio.studioworkbenchsections.runtime.available.2': 'Runtime available', 'pages.studio.studioworkbenchsections.runtime.available.3': diff --git a/apps/aevatar-console-web/src/locales/zh-CN.ts b/apps/aevatar-console-web/src/locales/zh-CN.ts index ce32d183a..6a80288e5 100644 --- a/apps/aevatar-console-web/src/locales/zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/zh-CN.ts @@ -41,7 +41,8 @@ const zhCNMessages = { 'nav.items.topology': '拓扑', 'pages.gagents.index.platformBreadcrumb': '平台', 'pages.chat.index.chatStopped': 'Chat 已停止。', - 'pages.chat.index.composerPlaceholder': '描述你想要的 workflow,或询问当前配置...', + 'pages.chat.index.composerPlaceholder': + '描述你想要的 workflow,或询问当前配置...', 'pages.chat.index.confirmAndCreate': '确认并创建', 'pages.chat.index.confirmPrompt': '确认,请现在创建。', 'pages.chat.index.continuationContextMissing': @@ -53,7 +54,8 @@ const zhCNMessages = { '永久删除“{title}”?此会话无法恢复。', 'pages.chat.index.deleteChatFailed': '无法删除会话', 'pages.chat.index.deleteChatTitle': '删除会话?', - 'pages.chat.index.emptyDescription': '描述你想创建的 Team、Member 或 Workflow。', + 'pages.chat.index.emptyDescription': + '描述你想创建的 Team、Member 或 Workflow。', 'pages.chat.index.failedToLoadConversation': '无法加载会话', 'pages.chat.index.failedToLoadHistory': '无法加载会话历史', 'pages.chat.index.historyStoredInWorkspace': '历史记录会保存到当前工作区。', @@ -63,7 +65,8 @@ const zhCNMessages = { 'pages.chat.index.loadingConversation': '正在加载会话', 'pages.chat.index.loadingHistory': '正在加载会话历史', 'pages.chat.index.historySynchronizing': '会话历史仍在同步,请稍后重试。', - 'pages.chat.index.missingChatHistoryContext': 'Chat 已完成,但未返回会话上下文。', + 'pages.chat.index.missingChatHistoryContext': + 'Chat 已完成,但未返回会话上下文。', 'pages.chat.index.newChat': '新会话', 'pages.chat.index.newChatAction': '新建会话', 'pages.chat.index.noChatHistory': '暂无会话历史', @@ -84,7 +87,38 @@ const zhCNMessages = { 'pages.chat.index.status.error': '错误', 'pages.chat.index.status.needsConfirmation': '待确认', 'pages.chat.index.status.streaming': '生成中', + 'pages.chat.index.status.stopped': '已停止', 'pages.chat.index.status.studioReady': 'Studio 已就绪', + 'pages.chat.index.approvalContextMissing': + '运行上下文不可用。请重新加载会话后再重试审批。', + 'pages.chat.index.interventionContextMissing': + '运行上下文不完整。请打开运行详情或重新加载后再重试。', + 'pages.chat.runLifecycle.completed': '运行已完成', + 'pages.chat.runLifecycle.contextMissing': '运行上下文不可用', + 'pages.chat.runLifecycle.contextUnavailable': + '此历史记录没有持久化运行标识。请开始新一轮对话或打开运行页面恢复上下文。', + 'pages.chat.runLifecycle.currentStep': '当前步骤:{step}', + 'pages.chat.runLifecycle.failed': '运行失败', + 'pages.chat.runLifecycle.label': '运行生命周期', + 'pages.chat.runLifecycle.needsYou': '运行需要你的操作', + 'pages.chat.runLifecycle.openRun': '打开运行详情', + 'pages.chat.runLifecycle.pending': '运行等待中', + 'pages.chat.runLifecycle.permissionRequired': '需要权限', + 'pages.chat.runLifecycle.refreshFailed': + '无法刷新持久化运行状态。请打开运行详情或重试会话以恢复。', + 'pages.chat.runLifecycle.refreshFailedTitle': '运行状态不可用', + 'pages.chat.runLifecycle.reviewAccess': '检查访问权限', + 'pages.chat.runLifecycle.run': '运行 {runId}', + 'pages.chat.runLifecycle.running': '运行中', + 'pages.chat.runLifecycle.runningTool': '正在运行工具:{tool}', + 'pages.chat.runLifecycle.state.completed': '已完成', + 'pages.chat.runLifecycle.state.failed': '失败', + 'pages.chat.runLifecycle.state.needsYou': '需要操作', + 'pages.chat.runLifecycle.state.pending': '等待中', + 'pages.chat.runLifecycle.state.recovery': '恢复', + 'pages.chat.runLifecycle.state.running': '运行中', + 'pages.chat.runLifecycle.state.stopped': '已停止', + 'pages.chat.runLifecycle.stopped': '运行已停止', 'pages.chat.index.time.daysAgo': '{count} 天前', 'pages.chat.index.time.hoursAgo': '{count} 小时前', 'pages.chat.index.time.justNow': '刚刚', @@ -112,8 +146,7 @@ const zhCNMessages = { 'pages.missionwall.latencySeconds': '{latency}s', 'pages.missionwall.liveState.connected': '已连接到已发布 workflow run read models。', - 'pages.missionwall.liveState.empty': - '暂无可见的已发布 workflow runs。', + 'pages.missionwall.liveState.empty': '暂无可见的已发布 workflow runs。', 'pages.missionwall.liveState.loading': '正在加载已发布 workflow runs。', 'pages.missionwall.liveState.partialRunError': '部分已发布 service runs 无法加载。', @@ -165,8 +198,7 @@ const zhCNMessages = { 'pages.missionwall.status.unknown': '未知', 'pages.missionwall.status.waiting': '等待', 'pages.missionwall.state.auditLoading': '正在加载选中 run', - 'pages.missionwall.state.auditPendingTitle': - '这个 run 暂无步骤流', + 'pages.missionwall.state.auditPendingTitle': '这个 run 暂无步骤流', 'pages.missionwall.state.emptyKicker': '等待运行记录', 'pages.missionwall.state.emptyTitle': '暂无可见的已发布 workflow', 'pages.missionwall.state.loadingKicker': '加载运行态', @@ -767,8 +799,10 @@ const zhCNMessages = { 'teams.automations.authorization.error': '无法继续授权', 'teams.automations.authorization.exactAccess': '精确权限', 'teams.automations.authorization.expiry': '凭证有效期', - 'teams.automations.authorization.noExternalGrants': '无需外部 NyxID 服务或所有者 LLM 模型授权。', - 'teams.automations.authorization.noOwnerLLMGrant': '此工作流无需所有者 LLM 模型授权。', + 'teams.automations.authorization.noExternalGrants': + '无需外部 NyxID 服务或所有者 LLM 模型授权。', + 'teams.automations.authorization.noOwnerLLMGrant': + '此工作流无需所有者 LLM 模型授权。', 'teams.automations.authorization.preparing': '正在准备授权检查', 'teams.automations.authorization.review': '检查授权', 'teams.automations.authorization.reviewAgain': '重新检查', @@ -1149,10 +1183,8 @@ const zhCNMessages = { '当前 Workflow 成员还没有可安全归属的执行记录。', 'teamMemberWorkflowStudio.executionsPanel.fallbackName': 'Workflow 执行', 'teamMemberWorkflowStudio.executionsPanel.inspect': '查看', - 'teamMemberWorkflowStudio.executionsPanel.sectionAria': - 'Workflow 执行记录', - 'teamMemberWorkflowStudio.executionsPanel.serviceMeta': - '服务已就绪', + 'teamMemberWorkflowStudio.executionsPanel.sectionAria': 'Workflow 执行记录', + 'teamMemberWorkflowStudio.executionsPanel.serviceMeta': '服务已就绪', 'teamMemberWorkflowStudio.executionsPanel.title': '执行记录', 'teamMemberWorkflowStudio.executionsPanel.unknownStatus': '未知', 'teamMemberWorkflowStudio.graph.branchCount.one': '1 个分支', @@ -1183,8 +1215,7 @@ const zhCNMessages = { 'teamMemberWorkflowStudio.header.identityAria': 'Workflow 身份信息', 'teamMemberWorkflowStudio.header.inputSet': '已设置输入', 'teamMemberWorkflowStudio.header.invoke': '调用', - 'teamMemberWorkflowStudio.header.invoke.open': - '打开已发布成员的调用工作台。', + 'teamMemberWorkflowStudio.header.invoke.open': '打开已发布成员的调用工作台。', 'teamMemberWorkflowStudio.header.invoke.publishFirst': '先发布这个成员,再调用它。', 'teamMemberWorkflowStudio.header.invoke.saveFirst': @@ -1383,24 +1414,37 @@ const zhCNMessages = { 'pages.governance.columns.service.endpoint.target': 'Service endpoint target', 'pages.governance.columns.service.target': 'Service target', 'pages.governance.governanceinspectordrawer.version.ready': 'Version ready', - 'pages.governance.governanceworkbench.binding.accepted.for.creation': 'Binding was accepted for governance creation.', - 'pages.governance.governanceworkbench.binding.accepted.for.retirement': 'Binding was accepted for retirement.', - 'pages.governance.governanceworkbench.binding.accepted.for.update': 'Binding was accepted for update.', - 'pages.governance.governanceworkbench.endpoint.accepted.for.creation': 'Endpoint was accepted for governance creation.', - 'pages.governance.governanceworkbench.endpoint.accepted.for.exposure': 'Endpoint was accepted for {value1} exposure.', - 'pages.governance.governanceworkbench.endpoint.accepted.for.update': 'Endpoint was accepted for update.', + 'pages.governance.governanceworkbench.binding.accepted.for.creation': + 'Binding was accepted for governance creation.', + 'pages.governance.governanceworkbench.binding.accepted.for.retirement': + 'Binding was accepted for retirement.', + 'pages.governance.governanceworkbench.binding.accepted.for.update': + 'Binding was accepted for update.', + 'pages.governance.governanceworkbench.endpoint.accepted.for.creation': + 'Endpoint was accepted for governance creation.', + 'pages.governance.governanceworkbench.endpoint.accepted.for.exposure': + 'Endpoint was accepted for {value1} exposure.', + 'pages.governance.governanceworkbench.endpoint.accepted.for.update': + 'Endpoint was accepted for update.', 'pages.governance.governanceworkbench.policy': 'Policy', - 'pages.governance.governanceworkbench.policy.accepted.for.creation': 'Policy was accepted for governance creation.', - 'pages.governance.governanceworkbench.policy.accepted.for.retirement': 'Policy was accepted for retirement.', - 'pages.governance.governanceworkbench.policy.accepted.for.update': 'Policy was accepted for update.', + 'pages.governance.governanceworkbench.policy.accepted.for.creation': + 'Policy was accepted for governance creation.', + 'pages.governance.governanceworkbench.policy.accepted.for.retirement': + 'Policy was accepted for retirement.', + 'pages.governance.governanceworkbench.policy.accepted.for.update': + 'Policy was accepted for update.', 'pages.governance.governanceworkbench.service': 'Service', - 'pages.governance.governanceworkbench.service.endpoint.target': 'Service endpoint target', + 'pages.governance.governanceworkbench.service.endpoint.target': + 'Service endpoint target', 'pages.governance.governanceworkbench.service.target': 'Service target', 'pages.governance.governanceworkbench.version.pending': 'Version pending', 'pages.governance.governanceworkbench.version.ready': 'Version ready', - 'pages.governance.governanceworkbench.revision.was.published.for.governance.evaluation': 'Revision was published for governance evaluation.', - 'pages.governance.governanceworkbench.revision.is.prepared.and.waiting.for': 'Revision is prepared and waiting for promotion decisions.', - 'pages.governance.governanceworkbench.revision.missing.policies': 'Revision is missing {value1} required policies.', + 'pages.governance.governanceworkbench.revision.was.published.for.governance.evaluation': + 'Revision was published for governance evaluation.', + 'pages.governance.governanceworkbench.revision.is.prepared.and.waiting.for': + 'Revision is prepared and waiting for promotion decisions.', + 'pages.governance.governanceworkbench.revision.missing.policies': + 'Revision is missing {value1} required policies.', 'pages.runs.memberPublishedRuns.event': 'Event', 'pages.runs.memberPublishedRuns.member': 'Team member', 'pages.runs.memberPublishedRuns.step': 'Step', @@ -1412,7 +1456,8 @@ const zhCNMessages = { 'pages.runs.runsinspectorpane.current.run.2': 'Current run', 'pages.runs.runsinspectorpane.current.run.ready': 'Current run ready', 'pages.runs.runsinspectorpane.event.recorded': 'Event recorded', - 'pages.runs.runsinspectorpane.no.user.visible.payload': 'No user-visible payload fields.', + 'pages.runs.runsinspectorpane.no.user.visible.payload': + 'No user-visible payload fields.', 'pages.runs.runsinspectorpane.runtime.actor.ready': 'Runtime actor ready', 'pages.runs.runsinspectorpane.runtime.actor.ready.2': 'Runtime actor ready', 'pages.runs.runsinspectorpane.runtime.actor.ready.3': 'Runtime actor ready', @@ -1436,19 +1481,25 @@ const zhCNMessages = { 'pages.scopes.invoke.selected.service': 'the selected service', 'pages.scopes.invoke.service': 'Service', 'pages.scopes.invoke.version.ready': 'Version ready', - 'pages.scopes.scopeserviceruntimeworkbench.actor.available': 'Actor available', + 'pages.scopes.scopeserviceruntimeworkbench.actor.available': + 'Actor available', 'pages.scopes.scopeserviceruntimeworkbench.binding': 'Binding', - 'pages.scopes.scopeserviceruntimeworkbench.binding.accepted.for.retirement': 'Binding was accepted for retirement.', - 'pages.scopes.scopeserviceruntimeworkbench.binding.created': 'Binding was created.', - 'pages.scopes.scopeserviceruntimeworkbench.binding.updated': 'Binding was updated.', + 'pages.scopes.scopeserviceruntimeworkbench.binding.accepted.for.retirement': + 'Binding was accepted for retirement.', + 'pages.scopes.scopeserviceruntimeworkbench.binding.created': + 'Binding was created.', + 'pages.scopes.scopeserviceruntimeworkbench.binding.updated': + 'Binding was updated.', 'pages.scopes.scopeserviceruntimeworkbench.endpoint': 'Endpoint', 'pages.scopes.scopeserviceruntimeworkbench.endpoints': 'endpoints', 'pages.scopes.scopeserviceruntimeworkbench.policy.count': '{value1} policies', 'pages.scopes.scopeserviceruntimeworkbench.ready': 'Ready', - 'pages.scopes.scopeserviceruntimeworkbench.revision.accepted.for.retirement': 'Revision was accepted for retirement.', + 'pages.scopes.scopeserviceruntimeworkbench.revision.accepted.for.retirement': + 'Revision was accepted for retirement.', 'pages.scopes.scopeserviceruntimeworkbench.service': 'Service', 'pages.scopes.scopeserviceruntimeworkbench.service.target': 'Service target', - 'pages.scopes.scopeserviceruntimeworkbench.serving.version.ready': 'serving version ready', + 'pages.scopes.scopeserviceruntimeworkbench.serving.version.ready': + 'serving version ready', 'pages.scopes.scopeserviceruntimeworkbench.version': 'Version', 'pages.services.index.artifact.attached': 'Artifact attached', 'pages.services.index.endpoint': 'Endpoint', @@ -1469,14 +1520,17 @@ const zhCNMessages = { 'pages.studio.studiobuildpanels.current.run.ready': 'current run: ready', 'pages.studio.studiobuildpanels.events.count': 'events: {count}', 'pages.studio.studiobuildpanels.runtime.actor.ready': 'runtime actor: ready', - 'pages.studio.studiobuildpanels.save.accepted.checking.again': 'Save accepted. Waiting for catalog; checking again in {value1}s.', - 'pages.studio.studiobuildpanels.save.accepted.waiting.for.catalog': 'Save accepted. Still waiting for catalog; use Refresh catalog to check again.', + 'pages.studio.studiobuildpanels.save.accepted.checking.again': + 'Save accepted. Waiting for catalog; checking again in {value1}s.', + 'pages.studio.studiobuildpanels.save.accepted.waiting.for.catalog': + 'Save accepted. Still waiting for catalog; use Refresh catalog to check again.', 'pages.studio.studiobuildpanels.save.applied': 'Save applied.', 'pages.studio.studiobuildpanels.save.rejected': 'Save rejected.', 'pages.studio.studiobuildpanels.script': 'Script', 'pages.studio.studiobuildpanels.script.applied': 'Applied script', 'pages.studio.studiobuildpanels.script.draft': 'Script draft', - 'pages.studio.studiobuildpanels.script.promotion.version.summary': 'Script promotion version summary', + 'pages.studio.studiobuildpanels.script.promotion.version.summary': + 'Script promotion version summary', 'pages.studio.studiobuildpanels.version.ready': 'version ready', 'pages.studio.studiofilesdetailpane.chat.author.assistant': '助手', 'pages.studio.studiofilesdetailpane.chat.author.unknown': '未知作者', @@ -1510,10 +1564,14 @@ const zhCNMessages = { 'pages.studio.studiomemberinvokeinspector.service.ready': 'Service ready', 'pages.studio.studiomemberinvokeinspector.version.ready': 'Version ready', 'pages.studio.studioworkbenchsections.runtime.available': 'Runtime available', - 'pages.studio.studioworkbenchsections.runtime.available.2': 'Runtime available', - 'pages.studio.studioworkbenchsections.runtime.available.3': 'Runtime available', - 'pages.studio.studioworkbenchsections.runtime.available.4': 'Runtime available', - 'pages.studio.studioworkbenchsections.runtime.facts.available': 'Runtime facts available', + 'pages.studio.studioworkbenchsections.runtime.available.2': + 'Runtime available', + 'pages.studio.studioworkbenchsections.runtime.available.3': + 'Runtime available', + 'pages.studio.studioworkbenchsections.runtime.available.4': + 'Runtime available', + 'pages.studio.studioworkbenchsections.runtime.facts.available': + 'Runtime facts available', }; export default zhCNMessages; diff --git a/apps/aevatar-console-web/src/pages/chat/chatRunLifecycle.test.ts b/apps/aevatar-console-web/src/pages/chat/chatRunLifecycle.test.ts new file mode 100644 index 000000000..92e3b5b5e --- /dev/null +++ b/apps/aevatar-console-web/src/pages/chat/chatRunLifecycle.test.ts @@ -0,0 +1,75 @@ +import { resolveChatRunLifecycle } from './chatRunLifecycle'; +import type { + ChatMessage, + ChatSessionState, + LocalChatStatus, +} from './chatTypes'; + +const messages: ChatMessage[] = [ + { + content: 'Structured runtime output', + id: 'assistant-alpha', + role: 'assistant', + status: 'complete', + timestamp: 1, + }, +]; + +function session(runId = ''): ChatSessionState { + return { + actorId: runId ? 'actor-alpha' : '', + commandId: '', + endpointId: 'chat', + eventCount: 0, + runId, + scopeId: 'scope-a', + serviceId: 'chat', + status: runId ? 'running' : 'idle', + }; +} + +describe('chat Run lifecycle', () => { + it.each<{ + expectedState: string; + expectedTitle: string; + lifecycleRunId?: string; + sessionRunId?: string; + status: LocalChatStatus; + }>([ + { + expectedState: 'pending', + expectedTitle: 'Run pending', + status: 'streaming', + }, + { + expectedState: 'failed', + expectedTitle: 'Run failed', + sessionRunId: 'run-failed', + status: 'error', + }, + { + expectedState: 'stopped', + expectedTitle: 'Run stopped', + sessionRunId: 'run-stopped', + status: 'stopped', + }, + { + expectedState: 'completed', + expectedTitle: 'Run completed', + lifecycleRunId: 'run-completed', + status: 'completed_text', + }, + ])('maps structured $expectedState state without reading assistant prose', (example) => { + expect( + resolveChatRunLifecycle({ + messages, + runId: example.lifecycleRunId, + session: session(example.sessionRunId), + status: example.status, + }), + ).toMatchObject({ + state: example.expectedState, + title: example.expectedTitle, + }); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/chat/chatRunLifecycle.tsx b/apps/aevatar-console-web/src/pages/chat/chatRunLifecycle.tsx new file mode 100644 index 000000000..9ec3a157e --- /dev/null +++ b/apps/aevatar-console-web/src/pages/chat/chatRunLifecycle.tsx @@ -0,0 +1,335 @@ +import { Button, Space, Tag, Typography } from 'antd'; +import React from 'react'; +import { t } from '@/shared/i18n/messages'; +import type { + ChatMessage, + ChatSessionState, + LocalChatStatus, +} from './chatTypes'; + +const PERMISSION_REMEDIATION_CODES = new Set([ + 'REQUIRED_SERVICE_ACCESS_MISSING', + 'SERVICE_ACCESS_REVIEW_REQUIRED', + 'NYXID_SERVICE_ACCESS_REQUIRED', + 'NYXID_OPERATION_AUTHORIZATION_REQUIRED', + 'TEAM_AUTOMATION_REAUTHORIZATION_REQUIRED', + 'WORKFLOW_AUTHORIZATION_REQUIRED', +]); + +export type ChatRunLifecycleTone = + | 'default' + | 'processing' + | 'success' + | 'warning' + | 'error'; + +export type ChatRunLifecycleView = { + details: string[]; + permissionCode?: string; + state: + | 'pending' + | 'running' + | 'needs_you' + | 'failed' + | 'stopped' + | 'completed' + | 'recovery'; + title: string; + tone: ChatRunLifecycleTone; +}; + +function latestAssistantMessage( + messages: readonly ChatMessage[], +): ChatMessage | undefined { + return [...messages] + .reverse() + .find((message) => message.role === 'assistant'); +} + +function durableState( + completionStatus?: string, +): ChatRunLifecycleView['state'] | undefined { + switch (completionStatus?.trim().toLowerCase()) { + case 'pending': + case 'queued': + case 'accepted': + return 'pending'; + case 'running': + case 'in_progress': + case 'executing': + return 'running'; + case 'waiting_approval': + case 'waiting_input': + case 'waiting_signal': + case 'suspended': + return 'needs_you'; + case 'failed': + case 'error': + return 'failed'; + case 'stopped': + case 'cancelled': + case 'canceled': + return 'stopped'; + case 'completed': + case 'succeeded': + case 'success': + return 'completed'; + default: + return undefined; + } +} + +export function isPermissionRemediationCode(code?: string): boolean { + return Boolean(code && PERMISSION_REMEDIATION_CODES.has(code)); +} + +export function resolveChatRunLifecycle(input: { + completionStatus?: string; + failureCode?: string; + messages: readonly ChatMessage[]; + reconciliationFailed?: boolean; + runId?: string; + session: ChatSessionState; + status: LocalChatStatus; +}): ChatRunLifecycleView | null { + const latest = latestAssistantMessage(input.messages); + const runId = input.runId || input.session.runId; + const runningStep = [...(latest?.steps ?? [])] + .reverse() + .find((step) => step.status === 'running'); + const runningTool = [...(latest?.toolCalls ?? [])] + .reverse() + .find((tool) => tool.status === 'running'); + const details = [ + runId + ? t('pages.chat.runLifecycle.run', 'Run {runId}', { + runId, + }) + : '', + runningStep + ? t('pages.chat.runLifecycle.currentStep', 'Current step: {step}', { + step: runningStep.name, + }) + : '', + runningTool + ? t('pages.chat.runLifecycle.runningTool', 'Running tool: {tool}', { + tool: runningTool.name, + }) + : '', + ].filter(Boolean); + + if (isPermissionRemediationCode(input.failureCode)) { + return { + details, + permissionCode: input.failureCode, + state: 'needs_you', + title: t( + 'pages.chat.runLifecycle.permissionRequired', + 'Permission required', + ), + tone: 'warning', + }; + } + + if (latest?.pendingApproval || latest?.pendingRunIntervention) { + return { + details, + state: 'needs_you', + title: t('pages.chat.runLifecycle.needsYou', 'Run needs you'), + tone: 'warning', + }; + } + + if ( + !runId && + (input.status === 'completed_text' || + input.status === 'completed_with_studio_target') + ) { + return { + details: [ + t( + 'pages.chat.runLifecycle.contextUnavailable', + 'This history entry has no durable Run identity. Start a new turn or open Runs to recover context.', + ), + ], + state: 'recovery', + title: t( + 'pages.chat.runLifecycle.contextMissing', + 'Run context unavailable', + ), + tone: 'warning', + }; + } + + if (input.reconciliationFailed && runId) { + return { + details: [ + ...details, + t( + 'pages.chat.runLifecycle.refreshFailed', + 'Durable Run state could not be refreshed. Open Run Detail or retry the conversation to recover.', + ), + ], + state: 'recovery', + title: t( + 'pages.chat.runLifecycle.refreshFailedTitle', + 'Run state unavailable', + ), + tone: 'warning', + }; + } + + const authoritativeState = durableState(input.completionStatus); + const state = + authoritativeState ?? + (input.status === 'stopped' + ? 'stopped' + : input.status === 'error' + ? 'failed' + : input.status === 'streaming' || input.status === 'creating' + ? runId + ? 'running' + : 'pending' + : input.status === 'completed_text' || + input.status === 'completed_with_studio_target' + ? 'completed' + : undefined); + + switch (state) { + case 'pending': + return { + details, + state, + title: t('pages.chat.runLifecycle.pending', 'Run pending'), + tone: 'default', + }; + case 'running': + return { + details, + state, + title: t('pages.chat.runLifecycle.running', 'Run in progress'), + tone: 'processing', + }; + case 'needs_you': + return { + details, + state, + title: t('pages.chat.runLifecycle.needsYou', 'Run needs you'), + tone: 'warning', + }; + case 'failed': + return { + details, + state, + title: t('pages.chat.runLifecycle.failed', 'Run failed'), + tone: 'error', + }; + case 'stopped': + return { + details, + state, + title: t('pages.chat.runLifecycle.stopped', 'Run stopped'), + tone: 'default', + }; + case 'completed': + return { + details, + state, + title: t('pages.chat.runLifecycle.completed', 'Run completed'), + tone: 'success', + }; + default: + return input.messages.length > 0 + ? { + details: [ + t( + 'pages.chat.runLifecycle.contextUnavailable', + 'This history entry has no durable Run identity. Start a new turn or open Runs to recover context.', + ), + ], + state: 'recovery', + title: t( + 'pages.chat.runLifecycle.contextMissing', + 'Run context unavailable', + ), + tone: 'warning', + } + : null; + } +} + +function lifecycleStateLabel(state: ChatRunLifecycleView['state']): string { + switch (state) { + case 'pending': + return t('pages.chat.runLifecycle.state.pending', 'Pending'); + case 'running': + return t('pages.chat.runLifecycle.state.running', 'Running'); + case 'needs_you': + return t('pages.chat.runLifecycle.state.needsYou', 'Needs you'); + case 'failed': + return t('pages.chat.runLifecycle.state.failed', 'Failed'); + case 'stopped': + return t('pages.chat.runLifecycle.state.stopped', 'Stopped'); + case 'completed': + return t('pages.chat.runLifecycle.state.completed', 'Completed'); + case 'recovery': + return t('pages.chat.runLifecycle.state.recovery', 'Recovery'); + } +} + +export function ChatRunLifecycleCard({ + onOpenRun, + onReviewAccess, + view, +}: { + onOpenRun?: () => void; + onReviewAccess?: () => void; + view: ChatRunLifecycleView; +}): React.ReactElement { + return ( +
+ + + {view.title} + {lifecycleStateLabel(view.state)} + + {view.details.map((detail) => ( + + {detail} + + ))} + {view.permissionCode ? ( + {view.permissionCode} + ) : null} + {onOpenRun || onReviewAccess ? ( + + {onOpenRun ? ( + + ) : null} + {onReviewAccess ? ( + + ) : null} + + ) : null} + +
+ ); +} diff --git a/apps/aevatar-console-web/src/pages/chat/chatTypes.ts b/apps/aevatar-console-web/src/pages/chat/chatTypes.ts index 396c93926..2387fbda1 100644 --- a/apps/aevatar-console-web/src/pages/chat/chatTypes.ts +++ b/apps/aevatar-console-web/src/pages/chat/chatTypes.ts @@ -45,6 +45,7 @@ export type ChatUsageSummary = { }; export type ChatStudioTarget = { + actorId?: string; memberId?: string; runId?: string; scopeId?: string; @@ -60,6 +61,7 @@ export type LocalChatStatus = | "creating" | "completed_text" | "completed_with_studio_target" + | "stopped" | "error"; export type StepInfo = RuntimeStepInfo; diff --git a/apps/aevatar-console-web/src/pages/chat/index.test.tsx b/apps/aevatar-console-web/src/pages/chat/index.test.tsx index 764d81839..bfeb1f4fb 100644 --- a/apps/aevatar-console-web/src/pages/chat/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/chat/index.test.tsx @@ -2,6 +2,8 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import * as React from "react"; import { authFetch } from "@/shared/auth/fetch"; import { history } from "@/shared/navigation/history"; +import { nyxIdChatApi } from "@/shared/api/nyxIdChatApi"; +import { runtimeRunsApi } from "@/shared/api/runtimeRunsApi"; import { studioApi } from "@/shared/studio/api"; import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; import { chatHistoryApi } from "./chatHistoryApi"; @@ -11,6 +13,20 @@ jest.mock("@/shared/auth/fetch", () => ({ authFetch: jest.fn(), })); +jest.mock("@/shared/api/nyxIdChatApi", () => ({ + nyxIdChatApi: { + approveToolCall: jest.fn(), + }, +})); + +jest.mock("@/shared/api/runtimeRunsApi", () => ({ + runtimeRunsApi: { + getRunSummary: jest.fn(), + resume: jest.fn(), + signal: jest.fn(), + }, +})); + jest.mock("./chatHistoryApi", () => ({ ChatHistoryApiError: class MockChatHistoryApiError extends Error { code?: string; @@ -27,7 +43,9 @@ jest.mock("./chatHistoryApi", () => ({ path: string; constructor(path: string, expectation: string) { - super(`Invalid Chat History response at ${path}: expected ${expectation}.`); + super( + `Invalid Chat History response at ${path}: expected ${expectation}.` + ); this.path = path; } }, @@ -180,7 +198,8 @@ function createControlledSseResponse(): { response: Response; } { const encoder = new TextEncoder(); - let streamController: ReadableStreamDefaultController | null = null; + let streamController: ReadableStreamDefaultController | null = + null; const response = { body: new ReadableStream({ start(controller) { @@ -270,6 +289,38 @@ describe("ChatPage server-backed history", () => { (chatHistoryApi.deleteConversation as jest.Mock).mockResolvedValue( undefined ); + (runtimeRunsApi.getRunSummary as jest.Mock).mockResolvedValue({ + actorId: "actor-alpha", + completionStatus: "running", + runId: "run-alpha", + scopeId: "scope-a", + }); + (runtimeRunsApi.resume as jest.Mock).mockResolvedValue({ + accepted: true, + actorId: "actor-alpha", + commandId: "cmd-resume-alpha", + runId: "run-alpha", + stepId: "approval-alpha", + }); + (runtimeRunsApi.signal as jest.Mock).mockResolvedValue({ + accepted: true, + actorId: "actor-alpha", + commandId: "cmd-signal-alpha", + runId: "run-alpha", + signalName: "continue", + stepId: "approval-alpha", + }); + (nyxIdChatApi.approveToolCall as jest.Mock).mockResolvedValue( + createSseResponse([ + chatContextFrame(), + { + runFinished: { + result: { output: "Tool decision accepted." }, + runId: "run-alpha", + }, + }, + ]) + ); const { ChatHistoryApiError } = jest.requireMock("./chatHistoryApi"); (chatHistoryApi.recoverCreate as jest.Mock).mockRejectedValue( new ChatHistoryApiError("Recovery is not materialized.", 404) @@ -292,7 +343,10 @@ describe("ChatPage server-backed history", () => { await screen.findByRole("button", { name: "Server conversation" }) ); - expect(await screen.findByText("The support workflow is ready.")).toBeTruthy(); + expect( + await screen.findByText("The support workflow is ready.") + ).toBeTruthy(); + expect(screen.getByText("Run context unavailable")).toBeTruthy(); const messageList = document.querySelector( ".aevatar-chat-message-list" ); @@ -459,7 +513,9 @@ describe("ChatPage server-backed history", () => { ) ).toBeTruthy(); await waitFor(() => - expect(chatHistoryApi.listConversationMetas).toHaveBeenCalledWith("scope-a") + expect(chatHistoryApi.listConversationMetas).toHaveBeenCalledWith( + "scope-a" + ) ); expect(screen.getByRole("textbox")).toBeDisabled(); expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); @@ -480,30 +536,30 @@ describe("ChatPage server-backed history", () => { it("preserves open history roles, statuses, authors, and stopped errors", async () => { expect( hydrateStoredMessages([ - { - authorId: "automation-1", - authorName: "Release automation", - content: "Queued for review", - id: "turn-open:observer", - role: "observer", - status: "archived", - timestamp: 1784255700000, - }, - { - content: "Review recorded", - id: "turn-open:auditor", - role: "auditor", - status: "complete", - timestamp: 1784255701000, - }, - { - content: "", - error: "Workflow stopped before completion.", - id: "turn-open:assistant", - role: "assistant", - status: "complete", - timestamp: 1784255702000, - }, + { + authorId: "automation-1", + authorName: "Release automation", + content: "Queued for review", + id: "turn-open:observer", + role: "observer", + status: "archived", + timestamp: 1784255700000, + }, + { + content: "Review recorded", + id: "turn-open:auditor", + role: "auditor", + status: "complete", + timestamp: 1784255701000, + }, + { + content: "", + error: "Workflow stopped before completion.", + id: "turn-open:assistant", + role: "assistant", + status: "complete", + timestamp: 1784255702000, + }, ]) ).toEqual([ expect.objectContaining({ @@ -631,7 +687,9 @@ describe("ChatPage server-backed history", () => { expect(body).not.toHaveProperty("scopeId"); expect(body).not.toHaveProperty("chatHistory"); expect(window.localStorage.length).toBe(0); - expect(screen.getByRole("button", { name: "Create a support team" })).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Create a support team" }) + ).toBeTruthy(); }); it("fails a completed stream that never establishes conversation context", async () => { @@ -674,8 +732,11 @@ describe("ChatPage server-backed history", () => { const [firstBody, retryBody] = chatRequestBodies(); expect(retryBody.commandId).toBe(firstBody.commandId); - await waitFor(() => - expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(), + await waitFor( + () => + expect( + screen.getByRole("button", { name: "Send" }) + ).toBeInTheDocument(), { timeout: 5_000 } ); await sendPrompt("Changed create request"); @@ -971,7 +1032,9 @@ describe("ChatPage server-backed history", () => { await sendPrompt("Continue after refreshing history"); await waitFor(() => expect(chatRequestBodies()).toHaveLength(2)); act(() => { - stream.enqueue(chatContextFrame("conversation-a", "turn-c", "scope-a", 8)); + stream.enqueue( + chatContextFrame("conversation-a", "turn-c", "scope-a", 8) + ); stream.enqueue({ textMessageContent: { delta: "Partial continuation", @@ -1062,7 +1125,9 @@ describe("ChatPage server-backed history", () => { await waitFor(() => expect(chatRequestBodies()).toHaveLength(2), { timeout: 4_000, }); - expect(await screen.findByText("Answer committed in another tab")).toBeTruthy(); + expect( + await screen.findByText("Answer committed in another tab") + ).toBeTruthy(); expect(screen.getByText("Question accepted in another tab")).toBeTruthy(); expect(screen.getByRole("textbox")).toBeEnabled(); @@ -1124,7 +1189,9 @@ describe("ChatPage server-backed history", () => { ok: false, status: 409, statusText: "Conflict", - text: jest.fn().mockRejectedValue(new Error("Response body disconnected")), + text: jest + .fn() + .mockRejectedValue(new Error("Response body disconnected")), } as unknown as Response); renderWithQueryClient(); @@ -1309,12 +1376,12 @@ describe("ChatPage server-backed history", () => { const missingContextMessage = "The continuation may have been accepted, but its turn identity was not received. Reload this page before continuing."; - expect(await screen.findAllByText(missingContextMessage)).not.toHaveLength(0); + expect(await screen.findAllByText(missingContextMessage)).not.toHaveLength( + 0 + ); expect(screen.getByRole("textbox")).toBeDisabled(); expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); - expect( - screen.queryByRole("button", { name: /Retry saving/ }) - ).toBeNull(); + expect(screen.queryByRole("button", { name: /Retry saving/ })).toBeNull(); expect(chatHistoryApi.listConversationMetas).toHaveBeenCalledTimes(1); expect(chatHistoryApi.loadConversation).toHaveBeenCalledTimes(1); expect(chatHistoryApi.recoverCreate).not.toHaveBeenCalled(); @@ -1372,7 +1439,9 @@ describe("ChatPage server-backed history", () => { }); expect(await screen.findByText("Draft plan in progress")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Stream a draft plan" })).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Stream a draft plan" }) + ).toBeTruthy(); expect(screen.getAllByText("Streaming")).toHaveLength(2); expect(window.localStorage.length).toBe(0); @@ -1440,7 +1509,9 @@ describe("ChatPage server-backed history", () => { }, { timeout: 2_500 } ); - expect(screen.queryByRole("button", { name: "Projection-safe prompt" })).toBeNull(); + expect( + screen.queryByRole("button", { name: "Projection-safe prompt" }) + ).toBeNull(); expect(screen.getByText("Live response")).toBeTruthy(); expect(chatHistoryApi.loadConversation).toHaveBeenCalledWith( "scope-a", @@ -1525,7 +1596,9 @@ describe("ChatPage server-backed history", () => { renderWithQueryClient(); await screen.findByText("No chat history"); await sendPrompt("Stop after context"); - act(() => stream.enqueue(chatContextFrame("server-stopped", "turn-stopped"))); + act(() => + stream.enqueue(chatContextFrame("server-stopped", "turn-stopped")) + ); await screen.findByRole("button", { name: "Stop after context" }); fireEvent.click(screen.getByRole("button", { name: "Stop" })); act(() => stream.close()); @@ -1568,11 +1641,7 @@ describe("ChatPage server-backed history", () => { } ); (chatHistoryApi.loadConversation as jest.Mock).mockImplementation( - ( - _scopeId: string, - _conversationId: string, - signal?: AbortSignal - ) => { + (_scopeId: string, _conversationId: string, signal?: AbortSignal) => { reconciliationDetailSignal = signal; return new Promise(() => undefined); } @@ -1651,7 +1720,8 @@ describe("ChatPage server-backed history", () => { await sendPrompt("Late projection prompt"); expect(await screen.findByText("Optimistic response")).toBeTruthy(); await waitFor( - () => expect(chatHistoryApi.listConversationMetas).toHaveBeenCalledTimes(5), + () => + expect(chatHistoryApi.listConversationMetas).toHaveBeenCalledTimes(5), { timeout: 4_500 } ); expect(chatHistoryApi.loadConversation).not.toHaveBeenCalled(); @@ -1710,13 +1780,13 @@ describe("ChatPage server-backed history", () => { renderWithQueryClient(); - expect(await screen.findByText("Chat history could not be loaded")).toBeTruthy(); + expect( + await screen.findByText("Chat history could not be loaded") + ).toBeTruthy(); expect(screen.getByText("History service is offline")).toBeTruthy(); expect(screen.getByRole("textbox")).toBeEnabled(); - fireEvent.click( - screen.getByRole("button", { name: "Retry chat history" }) - ); + fireEvent.click(screen.getByRole("button", { name: "Retry chat history" })); expect(await screen.findByText("No chat history")).toBeTruthy(); expect(chatHistoryApi.listConversationMetas).toHaveBeenCalledTimes(2); }); @@ -1734,9 +1804,13 @@ describe("ChatPage server-backed history", () => { await screen.findByRole("button", { name: "Server conversation" }) ); - expect(await screen.findByText("Conversation could not be loaded")).toBeTruthy(); + expect( + await screen.findByText("Conversation could not be loaded") + ).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Retry" })); - expect(await screen.findByText("The support workflow is ready.")).toBeTruthy(); + expect( + await screen.findByText("The support workflow is ready.") + ).toBeTruthy(); expect(chatHistoryApi.loadConversation).toHaveBeenCalledTimes(2); }); @@ -1751,7 +1825,9 @@ describe("ChatPage server-backed history", () => { (chatHistoryApi.listConversationMetas as jest.Mock).mockResolvedValue([ serverConversation, ]); - (chatHistoryApi.loadConversation as jest.Mock).mockReturnValue(detailPromise); + (chatHistoryApi.loadConversation as jest.Mock).mockReturnValue( + detailPromise + ); renderWithQueryClient(); fireEvent.click( @@ -1890,7 +1966,9 @@ describe("ChatPage server-backed history", () => { (chatHistoryApi.listConversationMetas as jest.Mock).mockResolvedValue([ sharedConversation, ]); - (chatHistoryApi.deleteConversation as jest.Mock).mockReturnValue(deletePromise); + (chatHistoryApi.deleteConversation as jest.Mock).mockReturnValue( + deletePromise + ); const view = renderScopeSwitchableChat("scope-a"); await screen.findByRole("button", { name: "Shared conversation" }); @@ -1937,9 +2015,13 @@ describe("ChatPage server-backed history", () => { ); fireEvent.click(screen.getByRole("button", { name: "Delete" })); - expect(await screen.findByText("Conversation could not be deleted")).toBeTruthy(); + expect( + await screen.findByText("Conversation could not be deleted") + ).toBeTruthy(); expect(screen.getByText("Delete request failed")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Server conversation" })).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Server conversation" }) + ).toBeTruthy(); }); it("opens Workflow Studio only for structured target identifiers", async () => { @@ -1970,4 +2052,148 @@ describe("ChatPage server-backed history", () => { "/scopes/scope-a/teams/team-a/members/member-a/workflow?workflowId=workflow-a" ); }); + + it("renders the active run and current step from structured stream events", async () => { + const stream = createControlledSseResponse(); + (authFetch as jest.Mock).mockResolvedValue(stream.response); + + renderWithQueryClient(); + await sendPrompt("Run the release workflow"); + act(() => { + stream.enqueue(chatContextFrame()); + stream.enqueue({ + runStarted: { + actorId: "actor-alpha", + commandId: "cmd-alpha", + runId: "run-alpha", + }, + timestamp: 1, + }); + stream.enqueue({ + stepStarted: { stepName: "Validate release" }, + timestamp: 2, + }); + }); + + expect(await screen.findByText("Run in progress")).toBeTruthy(); + expect(screen.getByText("Current step: Validate release")).toBeTruthy(); + expect(screen.getByText("Run run-alpha")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Open Run Detail" })); + expect(history.push).toHaveBeenCalledWith( + "/runtime/runs?runId=run-alpha&scopeId=scope-a&actorId=actor-alpha" + ); + + act(() => stream.close()); + }); + + it("submits a structured tool approval once and disables duplicate decisions", async () => { + (authFetch as jest.Mock).mockResolvedValue( + createSseResponse([ + chatContextFrame(), + { + runStarted: { actorId: "actor-alpha", runId: "run-alpha" }, + }, + { + toolApprovalRequest: { + argumentsJson: "{}", + isDestructive: false, + requestId: "approval-alpha", + timeoutSeconds: 30, + toolCallId: "tool-alpha", + toolName: "release.validate", + }, + type: "TOOL_APPROVAL_REQUEST", + }, + ]) + ); + + renderWithQueryClient(); + await sendPrompt("Validate the release"); + const approve = await screen.findByRole("button", { name: "Approve" }); + fireEvent.click(approve); + + await waitFor(() => expect(approve).toBeDisabled()); + expect(nyxIdChatApi.approveToolCall).toHaveBeenCalledTimes(1); + expect(nyxIdChatApi.approveToolCall).toHaveBeenCalledWith( + "scope-a", + "actor-alpha", + expect.objectContaining({ + approved: true, + requestId: "approval-alpha", + }), + expect.any(AbortSignal) + ); + }); + + it("keeps a run intervention idempotent while its command is pending", async () => { + let resolveResume = (_value: Record): void => undefined; + (runtimeRunsApi.resume as jest.Mock).mockReturnValue( + new Promise((resolve) => { + resolveResume = resolve; + }) + ); + (authFetch as jest.Mock).mockResolvedValue( + createSseResponse([ + chatContextFrame(), + { + runStarted: { actorId: "actor-alpha", runId: "run-alpha" }, + }, + { + humanInputRequest: { + prompt: "Approve the release gate.", + runId: "run-alpha", + stepId: "approval-alpha", + suspensionType: "human_approval", + }, + type: "HUMAN_INPUT_REQUEST", + }, + ]) + ); + + renderWithQueryClient(); + await sendPrompt("Prepare the release"); + const approve = await screen.findByRole("button", { name: "Approve" }); + fireEvent.click(approve); + + await waitFor(() => expect(approve).toBeDisabled()); + fireEvent.click(approve); + expect(runtimeRunsApi.resume).toHaveBeenCalledTimes(1); + expect(runtimeRunsApi.resume).toHaveBeenCalledWith("scope-a", { + actorId: "actor-alpha", + approved: true, + runId: "run-alpha", + stepId: "approval-alpha", + userInput: undefined, + }); + + await act(async () => { + resolveResume({ + accepted: true, + actorId: "actor-alpha", + runId: "run-alpha", + }); + }); + }); + + it("uses structured permission codes for remediation without parsing provider text", async () => { + (authFetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 409, + statusText: "Conflict", + text: jest.fn().mockResolvedValue( + JSON.stringify({ + code: "SERVICE_ACCESS_REVIEW_REQUIRED", + message: "Opaque provider failure text", + }) + ), + } as unknown as Response); + + renderWithQueryClient(); + await sendPrompt("Run a connected-service action"); + + expect(await screen.findByText("Permission required")).toBeTruthy(); + expect(screen.getByText("SERVICE_ACCESS_REVIEW_REQUIRED")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Review access" })); + expect(history.push).toHaveBeenCalledWith("/settings?section=account"); + }); }); diff --git a/apps/aevatar-console-web/src/pages/chat/index.tsx b/apps/aevatar-console-web/src/pages/chat/index.tsx index f49ad1a5d..6a37f811e 100644 --- a/apps/aevatar-console-web/src/pages/chat/index.tsx +++ b/apps/aevatar-console-web/src/pages/chat/index.tsx @@ -28,6 +28,9 @@ import React, { useRef, useState, } from "react"; +import { nyxIdChatApi } from "@/shared/api/nyxIdChatApi"; +import { runtimeRunsApi } from "@/shared/api/runtimeRunsApi"; +import { buildRuntimeRunsHref } from "@/shared/navigation/runtimeRoutes"; import { studioApi } from "@/shared/studio/api"; import { AevatarPageShell } from "@/shared/ui/aevatarPageShells"; import { resolveStudioScopeContext } from "../scopes/components/resolvedScope"; @@ -45,6 +48,10 @@ import { } from "./chatApi"; import { ChatHistoryApiError, chatHistoryApi } from "./chatHistoryApi"; import { ChatInput, ChatMessageBubble } from "./chatPresentation"; +import { + ChatRunLifecycleCard, + resolveChatRunLifecycle, +} from "./chatRunLifecycle"; import type { ChatMessage, ChatSessionState, @@ -53,6 +60,7 @@ import type { ChatCreateRecovery, ConversationMeta, LocalChatStatus, + PendingRunInterventionInfo, RuntimeEvent, StepInfo, StoredChatMessage, @@ -71,6 +79,7 @@ type ConversationState = { createCommandId?: string; createRequestPrompt?: string; expectedTurnCount: number; + failureCode?: string; latestTurnId?: string; messages: ChatMessage[]; sessionId: string; @@ -110,6 +119,12 @@ type StudioJump = { label: string; }; +type RunInterventionAction = Parameters< + NonNullable< + React.ComponentProps["onRunInterventionAction"] + > +>[2]; + function readChatQueryValue( key: string, search = typeof window === "undefined" ? "" : window.location.search @@ -171,9 +186,26 @@ function resolveStoredConversationStatus( : "completed_text"; } -function ChatMessageEntry({ message }: { message: ChatMessage }): React.ReactElement { +function ChatMessageEntry({ + activeApprovalRequestId, + activeRunInterventionKey, + message, + onApprovalDecision, + onRunInterventionAction, +}: { + activeApprovalRequestId?: string | null; + activeRunInterventionKey?: string | null; + message: ChatMessage; + onApprovalDecision?: (requestId: string, approved: boolean) => void; + onRunInterventionAction?: ( + messageId: string, + intervention: PendingRunInterventionInfo, + action: RunInterventionAction + ) => void; +}): React.ReactElement { const authorName = message.authorName?.trim() || ""; - const isStandardRole = message.role === "user" || message.role === "assistant"; + const isStandardRole = + message.role === "user" || message.role === "assistant"; if (isStandardRole) { return ( @@ -191,7 +223,13 @@ function ChatMessageEntry({ message }: { message: ChatMessage }): React.ReactEle {authorName} ) : null} - + ); } @@ -264,6 +302,32 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function structuredErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + + const code = (error as { code?: unknown }).code; + return typeof code === "string" && code.trim() ? code.trim() : undefined; +} + +function mergeRuntimeTarget( + current: ChatStudioTarget | undefined, + scopeId: string, + accumulator: ReturnType +): ChatStudioTarget | undefined { + if (!accumulator.actorId && !accumulator.runId) { + return current; + } + + return { + ...current, + actorId: accumulator.actorId || current?.actorId, + runId: accumulator.runId || current?.runId, + scopeId: current?.scopeId || scopeId, + }; +} + function abortableDelay(delayMs: number, signal: AbortSignal): Promise { if (delayMs <= 0) { return signal.aborted @@ -349,7 +413,9 @@ function cloneStepInfo(steps?: readonly StepInfo[]): StepInfo[] { return (steps ?? []).map((step) => ({ ...step })); } -function cloneToolCallInfo(toolCalls?: readonly ToolCallInfo[]): ToolCallInfo[] { +function cloneToolCallInfo( + toolCalls?: readonly ToolCallInfo[] +): ToolCallInfo[] { return (toolCalls ?? []).map((toolCall) => ({ ...toolCall })); } @@ -381,9 +447,7 @@ function hydrateAuthoritativeMessages( return { ...authoritativeMessage, - ...(localMessage.events - ? { events: [...localMessage.events] } - : {}), + ...(localMessage.events ? { events: [...localMessage.events] } : {}), ...(localMessage.pendingApproval ? { pendingApproval: { ...localMessage.pendingApproval } } : {}), @@ -467,13 +531,18 @@ function formatStatusLabel(status: LocalChatStatus): string { case "streaming": return t("pages.chat.index.status.streaming", "Streaming"); case "needs_confirmation": - return t("pages.chat.index.status.needsConfirmation", "Needs confirmation"); + return t( + "pages.chat.index.status.needsConfirmation", + "Needs confirmation" + ); case "creating": return t("pages.chat.index.status.creating", "Creating"); case "completed_with_studio_target": return t("pages.chat.index.status.studioReady", "Studio ready"); case "completed_text": return t("pages.chat.index.status.completed", "Completed"); + case "stopped": + return t("pages.chat.index.status.stopped", "Stopped"); case "error": return t("pages.chat.index.status.error", "Error"); default: @@ -495,6 +564,8 @@ function resolveStatusTone( return "success"; case "error": return "error"; + case "stopped": + return "default"; default: return "default"; } @@ -515,7 +586,9 @@ function shouldAskForConfirmation(content: string): boolean { ); } -function resolveStudioJump(target: ChatStudioTarget | undefined): StudioJump | null { +function resolveStudioJump( + target: ChatStudioTarget | undefined +): StudioJump | null { if (!target) { return null; } @@ -618,13 +691,16 @@ const ChatPage: React.FC = () => { const reconciliationControllersRef = useRef( new Map() ); + const pendingActionKeysRef = useRef(new Set()); const scopeEpochRef = useRef(0); const scopeIdentityRef = useRef(""); const scrollAnchorRef = useRef(null); const [storedActiveConversation, setActiveConversation] = useState(null); const [conversationStateScopeId, setConversationStateScopeId] = useState(""); - const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState( + null + ); const [deletingConversation, setDeletingConversation] = useState(false); const [deleteError, setDeleteError] = useState(""); const [deletedConversationIds, setDeletedConversationIds] = useState< @@ -638,14 +714,21 @@ const ChatPage: React.FC = () => { }); const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false); const [prompt, setPrompt] = useState(""); - const [, setSession] = useState(createIdleSession()); + const [session, setSession] = useState(createIdleSession()); + const [activeApprovalRequestId, setActiveApprovalRequestId] = useState< + string | null + >(null); + const [activeRunInterventionKey, setActiveRunInterventionKey] = useState< + string | null + >(null); const authSessionQuery = useQuery({ queryKey: ["chat", "auth-session"], queryFn: () => studioApi.getAuthSession(), retry: false, }); - const routeSearch = typeof window === "undefined" ? "" : window.location.search; + const routeSearch = + typeof window === "undefined" ? "" : window.location.search; const routeScopeId = useMemo( () => readChatQueryValue("scopeId", routeSearch), [routeSearch] @@ -702,6 +785,29 @@ const ChatPage: React.FC = () => { activeConversation?.status === "streaming" || activeConversation?.status === "creating"; const studioJump = resolveStudioJump(activeConversation?.target); + const activeRunId = activeConversation?.target?.runId || session.runId; + const activeRunActorId = + activeConversation?.target?.actorId || session.actorId; + const runSummaryQuery = useQuery({ + enabled: Boolean(scopeId && activeRunId && !isStreaming), + queryFn: () => + runtimeRunsApi.getRunSummary(scopeId, activeRunId, { + actorId: activeRunActorId || undefined, + }), + queryKey: ["chat-run", scopeId, activeRunId, activeRunActorId], + retry: false, + }); + const runLifecycle = activeConversation + ? resolveChatRunLifecycle({ + completionStatus: runSummaryQuery.data?.completionStatus, + failureCode: activeConversation.failureCode, + messages: activeConversation.messages, + reconciliationFailed: runSummaryQuery.isError, + runId: activeRunId, + session, + status: activeConversation.status, + }) + : null; const visibleConversations = useMemo(() => { const activeConversationId = activeConversation?.conversationId; const liveConversations = [ @@ -723,7 +829,9 @@ const ChatPage: React.FC = () => { : [] ) ); - const serverIds = new Set(conversations.map((conversation) => conversation.id)); + const serverIds = new Set( + conversations.map((conversation) => conversation.id) + ); const serverItems = conversations.map((conversation) => { const pending = scopedPendingConversations.get(conversation.id); return { @@ -741,7 +849,9 @@ const ChatPage: React.FC = () => { return []; } - const timestamps = conversation.messages.map((message) => message.timestamp); + const timestamps = conversation.messages.map( + (message) => message.timestamp + ); return [ { createdAt: new Date(timestamps[0] ?? Date.now()).toISOString(), @@ -993,7 +1103,9 @@ const ChatPage: React.FC = () => { setSession(createIdleSession(scopeId)); } setDeleteTarget(null); - await queryClient.invalidateQueries({ queryKey: ["chat-history", scopeId] }); + await queryClient.invalidateQueries({ + queryKey: ["chat-history", scopeId], + }); } catch (error) { if (scopeEpochRef.current !== deleteScopeEpoch) { return; @@ -1148,7 +1260,8 @@ const ChatPage: React.FC = () => { }); })().finally(() => { if ( - reconciliationControllersRef.current.get(conversationId) === controller + reconciliationControllersRef.current.get(conversationId) === + controller ) { reconciliationControllersRef.current.delete(conversationId); } @@ -1233,7 +1346,7 @@ const ChatPage: React.FC = () => { ? undefined : !conversation.createRequestPrompt || conversation.createRequestPrompt === trimmedInput - ? conversation.createCommandId ?? conversation.clientId + ? (conversation.createCommandId ?? conversation.clientId) : createClientId(); const startedConversation: ConversationState = { ...conversation, @@ -1241,6 +1354,7 @@ const ChatPage: React.FC = () => { createRequestPrompt: conversation.conversationId ? undefined : trimmedInput, + failureCode: undefined, latestTurnId: undefined, messages: [...conversation.messages, userMessage, assistantMessage], status: nextStatus, @@ -1265,7 +1379,9 @@ const ChatPage: React.FC = () => { reconciliationControllersRef.current .get(conversation.conversationId) ?.abort(); - reconciliationControllersRef.current.delete(conversation.conversationId); + reconciliationControllersRef.current.delete( + conversation.conversationId + ); } const controller = new AbortController(); abortControllerRef.current = controller; @@ -1301,7 +1417,8 @@ const ChatPage: React.FC = () => { signal ); if ( - detail.stateVersion >= (conversation.stateVersion as number) && + detail.stateVersion >= + (conversation.stateVersion as number) && (!latestRefreshedDetail || detail.stateVersion > latestRefreshedDetail.stateVersion) ) { @@ -1324,13 +1441,17 @@ const ChatPage: React.FC = () => { const chatHistoryContext = extractChatHistoryContext(frame.raw); if (chatHistoryContext) { if (chatHistoryContext.scopeId !== scopeId) { - throw new Error("Chat History context does not match the active scope."); + throw new Error( + "Chat History context does not match the active scope." + ); } if ( conversation.conversationId && chatHistoryContext.conversationId !== conversation.conversationId ) { - throw new Error("Chat History returned a different conversation identity."); + throw new Error( + "Chat History returned a different conversation identity." + ); } if ( acceptedChatHistoryContext && @@ -1343,7 +1464,9 @@ const ChatPage: React.FC = () => { chatHistoryContext.stateVersion !== acceptedChatHistoryContext.stateVersion) ) { - throw new Error("Chat History context changed during the stream."); + throw new Error( + "Chat History context changed during the stream." + ); } if (!acceptedChatHistoryContext) { acceptedChatHistoryContext = chatHistoryContext; @@ -1385,11 +1508,17 @@ const ChatPage: React.FC = () => { ); const patchedConversation: ConversationState = { ...streamingConversation, + failureCode: accumulator.errorCode || undefined, messages: streamingConversation.messages.map((message) => message.id === assistantMessageId ? { ...message, ...patch } : message ), + target: mergeRuntimeTarget( + streamingConversation.target, + scopeId, + accumulator + ), }; streamingConversation = patchedConversation; activeConversationRef.current = patchedConversation; @@ -1446,12 +1575,19 @@ const ChatPage: React.FC = () => { } const artifacts = extractChatStreamArtifacts(rawFrames); - const finalAssistantStatus: ChatMessage["status"] = accumulator.errorText - ? "error" - : "complete"; - const finalTarget = artifacts.target || streamingConversation.target; + const finalAssistantStatus: ChatMessage["status"] = + accumulator.errorText ? "error" : "complete"; + const artifactTarget = artifacts.target + ? { ...streamingConversation.target, ...artifacts.target } + : streamingConversation.target; + const finalTarget = mergeRuntimeTarget( + artifactTarget, + scopeId, + accumulator + ); const finalUsage = artifacts.usage || streamingConversation.usage; - const finalContent = accumulator.finalOutput || accumulator.assistantText; + const finalContent = + accumulator.finalOutput || accumulator.assistantText; const finalStatus: LocalChatStatus = accumulator.errorText ? "error" : resolveStudioJump(finalTarget) @@ -1461,6 +1597,7 @@ const ChatPage: React.FC = () => { : "completed_text"; const finalConversation: ConversationState = { ...streamingConversation, + failureCode: accumulator.errorCode || undefined, messages: streamingConversation.messages.map((message) => message.id === assistantMessageId ? { @@ -1478,7 +1615,9 @@ const ChatPage: React.FC = () => { }; activeConversationRef.current = finalConversation; setActiveConversation((current) => - current?.clientId === conversation.clientId ? finalConversation : current + current?.clientId === conversation.clientId + ? finalConversation + : current ); setSession( buildSessionFromAccumulator( @@ -1559,6 +1698,8 @@ const ChatPage: React.FC = () => { ); const failedConversation: ConversationState = { ...streamingConversation, + failureCode: + accumulator.errorCode || structuredErrorCode(error) || undefined, ...(refreshedDetail ? { ...(receivedChatHistoryContext @@ -1573,17 +1714,24 @@ const ChatPage: React.FC = () => { messages: authoritativeMessages ? [...authoritativeMessages, ...failedAttemptMessages] : failedMessages, - status: "error", + status: + controller.signal.aborted && !accumulator.errorCode + ? "stopped" + : "error", + target: mergeRuntimeTarget( + streamingConversation.target, + scopeId, + accumulator + ), }; activeConversationRef.current = failedConversation; setActiveConversation((current) => - current?.clientId === conversation.clientId ? failedConversation : current + current?.clientId === conversation.clientId + ? failedConversation + : current ); setSession(buildSessionFromAccumulator(scopeId, accumulator, "error")); - if ( - failedConversation.conversationId && - receivedChatHistoryContext - ) { + if (failedConversation.conversationId && receivedChatHistoryContext) { reconcileConversation(failedConversation); } else if ( failedConversation.conversationId && @@ -1630,7 +1778,10 @@ const ChatPage: React.FC = () => { return; } - const recoveredConversation = bindCreateRecovery(current, recovery); + const recoveredConversation = bindCreateRecovery( + current, + recovery + ); activeConversationRef.current = recoveredConversation; setActiveConversation(recoveredConversation); reconcileConversation(recoveredConversation); @@ -1675,6 +1826,268 @@ const ChatPage: React.FC = () => { ); }, [activeConversation, runChat]); + const handleApprovalDecision = useCallback( + async (requestId: string, approved: boolean) => { + const actionKey = `tool:${requestId}`; + if (!scopeId || pendingActionKeysRef.current.has(actionKey)) { + return; + } + + const conversation = activeConversationRef.current; + const targetMessage = conversation?.messages.find( + (message) => message.pendingApproval?.requestId === requestId + ); + const actorId = conversation?.target?.actorId || session.actorId; + if (!conversation || !targetMessage || !actorId) { + setSession((current) => ({ + ...current, + error: t( + "pages.chat.index.approvalContextMissing", + "Run context is unavailable. Reload the conversation before retrying this approval." + ), + status: "error", + updatedAt: Date.now(), + })); + return; + } + + pendingActionKeysRef.current.add(actionKey); + setActiveApprovalRequestId(requestId); + const controller = new AbortController(); + const accumulator = createRuntimeEventAccumulator({ actorId }); + accumulator.assistantText = targetMessage.content; + accumulator.commandId = session.commandId; + accumulator.events = [...(targetMessage.events ?? [])]; + accumulator.runId = conversation.target?.runId || session.runId; + accumulator.steps = cloneStepInfo(targetMessage.steps); + accumulator.thinking = targetMessage.thinking ?? ""; + accumulator.toolCalls = cloneToolCallInfo(targetMessage.toolCalls); + + try { + const response = await nyxIdChatApi.approveToolCall( + scopeId, + actorId, + { + approved, + reason: approved ? undefined : "Rejected by console operator.", + requestId, + sessionId: conversation.sessionId, + }, + controller.signal + ); + + for await (const frame of readChatStreamFrames(response, { + signal: controller.signal, + })) { + if (!frame.event) { + continue; + } + applyRuntimeEvent(accumulator, frame.event); + if (isRawObserved(frame.event)) { + continue; + } + + setActiveConversation((current) => { + if (!current || current.clientId !== conversation.clientId) { + return current; + } + const next: ConversationState = { + ...current, + failureCode: accumulator.errorCode || undefined, + messages: current.messages.map((message) => + message.id === targetMessage.id + ? { + ...message, + ...buildAssistantMessagePatch( + accumulator, + accumulator.errorText ? "error" : "streaming" + ), + } + : message + ), + status: accumulator.errorText ? "error" : "creating", + target: mergeRuntimeTarget(current.target, scopeId, accumulator), + }; + activeConversationRef.current = next; + return next; + }); + setSession( + buildSessionFromAccumulator( + scopeId, + accumulator, + accumulator.errorText ? "error" : "running" + ) + ); + } + + setActiveConversation((current) => { + if (!current || current.clientId !== conversation.clientId) { + return current; + } + const next: ConversationState = { + ...current, + failureCode: accumulator.errorCode || undefined, + messages: current.messages.map((message) => + message.id === targetMessage.id + ? { + ...message, + ...buildAssistantMessagePatch( + accumulator, + accumulator.errorText ? "error" : "complete" + ), + pendingApproval: undefined, + } + : message + ), + status: accumulator.errorText ? "error" : "completed_text", + target: mergeRuntimeTarget(current.target, scopeId, accumulator), + }; + activeConversationRef.current = next; + reconcileConversation(next); + return next; + }); + } catch (error) { + setActiveConversation((current) => { + if (!current || current.clientId !== conversation.clientId) { + return current; + } + const next = { + ...current, + failureCode: structuredErrorCode(error), + status: "error" as const, + }; + activeConversationRef.current = next; + return next; + }); + setSession((current) => ({ + ...current, + error: errorMessage(error), + status: "error", + updatedAt: Date.now(), + })); + } finally { + pendingActionKeysRef.current.delete(actionKey); + setActiveApprovalRequestId((current) => + current === requestId ? null : current + ); + } + }, + [reconcileConversation, scopeId, session] + ); + + const handleRunInterventionAction = useCallback( + async ( + messageId: string, + intervention: PendingRunInterventionInfo, + action: RunInterventionAction + ) => { + const actionKey = `run:${intervention.key}`; + if (!scopeId || pendingActionKeysRef.current.has(actionKey)) { + return; + } + const actorId = intervention.actorId || session.actorId; + const runId = intervention.runId || session.runId; + if (!actorId || !runId || !intervention.stepId) { + setSession((current) => ({ + ...current, + error: t( + "pages.chat.index.interventionContextMissing", + "Run context is incomplete. Open Run Detail or reload before retrying." + ), + status: "error", + updatedAt: Date.now(), + })); + return; + } + + pendingActionKeysRef.current.add(actionKey); + setActiveRunInterventionKey(intervention.key); + try { + const result = + action.kind === "signal" + ? await runtimeRunsApi.signal(scopeId, { + actorId, + payload: action.value?.trim() || undefined, + runId, + signalName: intervention.signalName || "continue", + stepId: intervention.stepId, + }) + : await runtimeRunsApi.resume(scopeId, { + actorId, + approved: action.kind !== "reject", + runId, + stepId: intervention.stepId, + userInput: action.value?.trim() || undefined, + }); + if (!result.accepted) { + throw new Error("Runtime did not accept the intervention command."); + } + + setActiveConversation((current) => { + if (!current) { + return current; + } + const next: ConversationState = { + ...current, + failureCode: undefined, + messages: current.messages.map((message) => + message.id === messageId + ? { ...message, pendingRunIntervention: undefined } + : message + ), + status: "completed_text", + target: { + ...current.target, + actorId: result.actorId || actorId, + runId: result.runId || runId, + scopeId, + }, + }; + activeConversationRef.current = next; + return next; + }); + setSession((current) => ({ + ...current, + actorId: result.actorId || actorId, + commandId: result.commandId || current.commandId, + error: undefined, + runId: result.runId || runId, + scopeId, + status: "running", + updatedAt: Date.now(), + })); + await queryClient.invalidateQueries({ + queryKey: ["chat-run", scopeId, runId], + }); + } catch (error) { + setActiveConversation((current) => { + if (!current) { + return current; + } + const next = { + ...current, + failureCode: structuredErrorCode(error), + status: "error" as const, + }; + activeConversationRef.current = next; + return next; + }); + setSession((current) => ({ + ...current, + error: errorMessage(error), + status: "error", + updatedAt: Date.now(), + })); + } finally { + pendingActionKeysRef.current.delete(actionKey); + setActiveRunInterventionKey((current) => + current === intervention.key ? null : current + ); + } + }, + [queryClient, scopeId, session] + ); + const handleStop = useCallback(() => { abortControllerRef.current?.abort(); }, []); @@ -1687,6 +2100,24 @@ const ChatPage: React.FC = () => { history.push(studioJump.href); }, [studioJump]); + const handleOpenRun = useCallback(() => { + if (!scopeId || !activeRunId) { + return; + } + + history.push( + buildRuntimeRunsHref({ + actorId: activeRunActorId || undefined, + runId: activeRunId, + scopeId, + }) + ); + }, [activeRunActorId, activeRunId, scopeId]); + + const handleReviewAccess = useCallback(() => { + history.push("/settings?section=account"); + }, []); + const messageCount = activeConversation?.messages.length ?? 0; const historyRail = ( <> @@ -1709,7 +2140,9 @@ const ChatPage: React.FC = () => { > {t("pages.chat.index.newChatAction", "New Chat")} - + {hasFailedHistoryReconciliation ? t( "pages.chat.index.historySaveNeedsAttention", @@ -1757,7 +2190,10 @@ const ChatPage: React.FC = () => { } onClick={() => void conversationsQuery.refetch()} size="small" @@ -1807,7 +2243,9 @@ const ChatPage: React.FC = () => { aria-label={conversation.title} className="aevatar-chat-history-select" disabled={isStreaming} - onClick={() => void handleSelectConversation(conversation.id)} + onClick={() => + void handleSelectConversation(conversation.id) + } style={{ alignItems: "flex-start", background: "transparent", @@ -1866,17 +2304,19 @@ const ChatPage: React.FC = () => { "Save not confirmed" ) : conversation.historyReconciliation?.status === - "pending" - ? t( - "pages.chat.index.historySavePendingShort", - "Saving history" - ) - : conversation.liveStatus === "streaming" || - conversation.liveStatus === "creating" - ? formatStatusLabel(conversation.liveStatus) - : formatTurnCount(conversation.messageCount)} + "pending" + ? t( + "pages.chat.index.historySavePendingShort", + "Saving history" + ) + : conversation.liveStatus === "streaming" || + conversation.liveStatus === "creating" + ? formatStatusLabel(conversation.liveStatus) + : formatTurnCount(conversation.messageCount)} + + + {formatRelativeTime(conversation.updatedAt)} - {formatRelativeTime(conversation.updatedAt)} @@ -1911,9 +2351,13 @@ const ChatPage: React.FC = () => { })} > - - + <> + {runLifecycle ? ( + ) : null} - + + {activeConversation?.messages.map((message) => ( + { + void handleApprovalDecision(requestId, approved); + }} + onRunInterventionAction={( + messageId, + intervention, + action + ) => { + void handleRunInterventionAction( + messageId, + intervention, + action + ); + }} + /> + ))} + {activeConversation?.status === "needs_confirmation" ? ( +
+ + + {t( + "pages.chat.index.reviewPlan", + "Review the plan before creating resources." + )} + + + +
+ ) : null} +
+ )}
@@ -2260,17 +2743,23 @@ const ChatPage: React.FC = () => { {activeConversation?.usage?.totalTokens !== undefined ? ( {t("pages.chat.index.totalTokens", "{count} tokens", { - count: activeConversation.usage.totalTokens.toLocaleString(), + count: + activeConversation.usage.totalTokens.toLocaleString(), })} ) : null} {activeConversation?.usage?.promptTokens !== undefined || activeConversation?.usage?.completionTokens !== undefined ? ( - {t("pages.chat.index.tokenSplit", "{input} in / {output} out", { - input: activeConversation.usage?.promptTokens ?? 0, - output: activeConversation?.usage?.completionTokens ?? 0, - })} + {t( + "pages.chat.index.tokenSplit", + "{input} in / {output} out", + { + input: activeConversation.usage?.promptTokens ?? 0, + output: + activeConversation?.usage?.completionTokens ?? 0, + } + )} ) : null} {activeConversation?.usage?.model ? (