diff --git a/web/src/components/ChatMarkdown.tsx b/web/src/components/ChatMarkdown.tsx
index 52a2e67ce..e47152ae2 100644
--- a/web/src/components/ChatMarkdown.tsx
+++ b/web/src/components/ChatMarkdown.tsx
@@ -22,10 +22,21 @@ interface Props {
/** Returns true if the path has a file extension (not a directory). */
function hasFileExtension(path: string): boolean {
- const basename = path.split('/').pop() ?? '';
+ const basename = path.split(/[/\\]/).pop() ?? '';
return /\.\w{1,10}$/.test(basename);
}
+function isLikelyDomainPath(value: string): boolean {
+ return /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/|$)/i.test(value);
+}
+
+function trimDetectedUrl(url: string): string {
+ const hardStop = url.search(/[(【《「『,。;:!?⬇]/u);
+ let next = hardStop >= 0 ? url.slice(0, hardStop) : url;
+ while (next.length > 1 && /[.,;:!?)}\]>)】》」』,。;:!?⬇]$/u.test(next)) next = next.slice(0, -1);
+ return next;
+}
+
// ── Token rendering ─────────────────────────────────────────────────────────
function isLocalPath(href: string): boolean {
@@ -73,6 +84,10 @@ function renderToken(
case 'paragraph': {
const t = token as Tokens.Paragraph;
+ const plainEscapedParagraph = !inLink && Array.isArray(t.tokens) && t.tokens.every((child) => child.type === 'text' || child.type === 'escape');
+ if (plainEscapedParagraph) {
+ return
;
}
@@ -152,18 +167,21 @@ function renderToken(
);
}
+ const sanitizedHref = trimDetectedUrl(t.href);
+ const inlineText = typeof (t as { text?: unknown }).text === 'string' ? String((t as { text?: unknown }).text) : '';
+ const isAutoLinkLike = !inlineText || inlineText === t.href;
return (
);
}
@@ -249,8 +267,8 @@ function renderToken(
// ── URL/Path detection (inline within text tokens) ──────────────────────────
-const URL_REGEX_INLINE = /https?:\/\/[^\s<>"\])}]+/g;
-const PATH_REGEX_INLINE = /(\.{1,2}\/[\w\p{L}.\-~/]+|\/[\w\p{L}.\-~][\w\p{L}.\-~/]*|(?"\])})】》」』,。;:!?(【《「『]+/g;
+const PATH_REGEX_INLINE = /(\\\\[\w.$ -]+\\[\w.$ \\-]+|[A-Za-z]:\\(?:[\w.$ -]+\\)*[\w.$ -]+|\.{1,2}\/[\w\p{L}.\-~/]+|\/[\w\p{L}.\-~][\w\p{L}.\-~/]*|(? last) chunks.push({ type: 'text', value: text.slice(last, m.index), start: last });
- let url = m[0];
- while (url.length > 1 && /[.,;:!?)}\]>]$/.test(url)) url = url.slice(0, -1);
+ let url = trimDetectedUrl(m[0]);
chunks.push({ type: 'url', value: url, start: m.index });
last = m.index + url.length;
URL_REGEX_INLINE.lastIndex = last;
@@ -298,6 +315,7 @@ function splitPathsAndUrlsInternal(
while ((pm = PATH_REGEX_INLINE.exec(chunk.value)) !== null) {
const path = pm[1];
if (path.length < 3) continue;
+ if (isLikelyDomainPath(path)) continue;
if (pm.index > pathLast) parts.push(
diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx
index a68046d64..32c7c5199 100644
--- a/web/src/components/ChatView.tsx
+++ b/web/src/components/ChatView.tsx
@@ -63,6 +63,22 @@ interface AssistantBlockProps {
onDownload?: (path: string) => void;
}
+function hasFileExtension(path: string): boolean {
+ const basename = path.split(/[/\\]/).pop() ?? '';
+ return /\.\w{1,10}$/.test(basename);
+}
+
+function isLikelyDomainPath(value: string): boolean {
+ return /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/|$)/i.test(value);
+}
+
+function trimDetectedUrl(url: string): string {
+ const hardStop = url.search(/[(【《「『,。;:!?⬇]/u);
+ let next = hardStop >= 0 ? url.slice(0, hardStop) : url;
+ while (next.length > 1 && /[.,;:!?)}\]>)】》」』,。;:!?⬇]$/u.test(next)) next = next.slice(0, -1);
+ return next;
+}
+
const TOOL_INPUT_SUMMARY_KEYS = [
'query',
'command',
@@ -151,6 +167,7 @@ function buildViewItems(events: TimelineEvent[]): ViewItem[] {
// - agent.status, usage.update: stats, not chat content
// - mode.state: shown elsewhere (tabs/header)
// - command.ack, terminal.snapshot: internal plumbing
+ // - session.state running/idle: live status belongs in footer/header, not chat history
const visible = events.filter(
(e) =>
!e.hidden &&
@@ -159,6 +176,7 @@ function buildViewItems(events: TimelineEvent[]): ViewItem[] {
e.type !== 'mode.state' &&
e.type !== 'command.ack' &&
e.type !== 'terminal.snapshot' &&
+ !(e.type === 'session.state' && (e.payload.state === 'running' || e.payload.state === 'idle')) &&
e.type !== 'assistant.thinking',
);
@@ -808,9 +826,9 @@ export function ChatView({ events, loading, refreshing: _refreshing, loadingOlde
onDownload={downloadHandler}
/>
) : item.type === 'tool-group' ? (
-
+
) : (
-
+
);
})}
{!loading && }
@@ -1012,7 +1030,17 @@ function ToolBlockFold({ children }: { children: preact.ComponentChildren }) {
}
/** Collapsible group of consecutive tool events. Shows first and last, folds middle. */
-function ToolCallGroup({ events, onPathClick }: { events: TimelineEvent[]; onPathClick?: (p: string) => void }) {
+function ToolCallGroup({
+ events,
+ onPathClick,
+ onDownload,
+ serverId,
+}: {
+ events: TimelineEvent[];
+ onPathClick?: (p: string) => void;
+ onDownload?: (path: string) => void;
+ serverId?: string;
+}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const first = events[0];
@@ -1021,18 +1049,18 @@ function ToolCallGroup({ events, onPathClick }: { events: TimelineEvent[]; onPat
return (
-
+
);
@@ -1143,11 +1183,11 @@ const ChatEvent = memo(function ChatEvent({ event, nextTs, onPathClick, serverId
{'>'}
{String(event.payload.tool ?? 'tool')}
- {toolInput && {' '}{splitPathsAndUrls(toolInput, onPathClick)}}
+ {toolInput && {' '}{splitPathsAndUrls(toolInput, onPathClick, undefined, onDownload)}}
{toolOutput && (
- {splitPathsAndUrls(toolOutput, onPathClick)}
+ {splitPathsAndUrls(toolOutput, onPathClick, undefined, onDownload)}
)}
{(callDetail || resultDetail) && (
@@ -1173,9 +1213,9 @@ const ChatEvent = memo(function ChatEvent({ event, nextTs, onPathClick, serverId
{/* Usage footer — shared component */}
- {(lastUsage || activeThinkingTs || statusText || sessionInfo?.planLabel || sessionInfo?.quotaLabel || sessionInfo?.quotaUsageLabel) && (
+ {(lastUsage || activeThinkingTs || statusText || liveSessionState === 'running' || liveSessionState === 'idle' || sessionInfo?.planLabel || sessionInfo?.quotaLabel || sessionInfo?.quotaUsageLabel || sessionInfo?.quotaMeta) && (
)}
diff --git a/web/src/components/UsageFooter.tsx b/web/src/components/UsageFooter.tsx
index 5e869bbd8..a9bb9a3ae 100644
--- a/web/src/components/UsageFooter.tsx
+++ b/web/src/components/UsageFooter.tsx
@@ -26,6 +26,8 @@ interface Props {
activeThinkingTs?: number | null;
/** Status text from agent (e.g. "Reading file..."). */
statusText?: string | null;
+ /** Whether the current live tail is an active tool call. */
+ activeToolCall?: boolean;
/** Current timestamp for thinking timer (updated every second). */
now?: number;
}
@@ -35,10 +37,11 @@ const fmt = (n: number) =>
: n >= 1000 ? `${(n / 1000).toFixed(0)}k`
: String(n);
-export function UsageFooter({ usage, sessionName, sessionState, agentType, modelOverride, planLabel, quotaLabel, quotaUsageLabel, quotaMeta, showCost, activeThinkingTs, statusText, now }: Props) {
+export function UsageFooter({ usage, sessionName, sessionState, agentType, modelOverride, planLabel, quotaLabel, quotaUsageLabel, quotaMeta, showCost, activeThinkingTs, statusText, activeToolCall, now }: Props) {
const { t } = useTranslation();
const isCodexFamily = agentType === 'codex' || agentType === 'codex-sdk';
- const showRunningStatus = sessionState === 'running' && !!(activeThinkingTs || statusText);
+ const hasActiveLiveWork = !!activeToolCall || !!activeThinkingTs;
+ const showLiveStatus = sessionState === 'running' || sessionState === 'idle' || hasActiveLiveWork;
const [quotaNow, setQuotaNow] = useState(() => Date.now());
const displayModel = modelOverride ?? usage.model;
@@ -95,6 +98,21 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model
const monthlyCost = sessionCost > 0 ? getMonthlyCost() : 0;
const modelLabel = shortModelLabel(displayModel);
const inlineQuotaText = displayQuotaLabel;
+ const liveStatusMode = hasActiveLiveWork
+ ? (activeToolCall ? 'tool' : 'thinking')
+ : sessionState === 'running'
+ ? 'running'
+ : sessionState === 'idle' ? 'idle' : null;
+ const liveStatusText = useMemo(() => {
+ if (hasActiveLiveWork || sessionState === 'running') {
+ if (activeToolCall) return statusText || 'Tool running...';
+ if (activeThinkingTs) return t('chat.thinking_running', { sec: Math.max(0, Math.round(((now ?? Date.now()) - activeThinkingTs) / 1000)) });
+ return 'Agent working...';
+ }
+ if (sessionState === 'idle') return 'Agent idle — waiting for input';
+ return null;
+ }, [activeThinkingTs, activeToolCall, hasActiveLiveWork, now, sessionState, statusText, t]);
+ const showInlineStatusText = liveStatusMode === 'running' || liveStatusMode === 'thinking' || liveStatusMode === 'tool';
const codexQuotaLines = (agentType === 'codex' || agentType === 'codex-sdk')
? (displayQuotaLabel ?? '').split(' · ').filter(Boolean)
: [];
@@ -115,12 +133,14 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model
)}
- {showRunningStatus && (
-
- ···
- {' '}{activeThinkingTs
- ? t('chat.thinking_running', { sec: Math.max(0, Math.round(((now ?? Date.now()) - activeThinkingTs) / 1000)) })
- : statusText}
+ {showLiveStatus && liveStatusText && liveStatusMode && (
+
+ 🤖
+ {liveStatusMode === 'running' && ⚙️}
+ {liveStatusMode === 'thinking' && 💭}
+ {liveStatusMode === 'tool' && 🔍}
+ {liveStatusMode === 'idle' && 💤}
+ {showInlineStatusText && {liveStatusText}}
)}
diff --git a/web/src/components/pinnedPanelTypes.tsx b/web/src/components/pinnedPanelTypes.tsx
index 8c3ac8baf..63caf526d 100644
--- a/web/src/components/pinnedPanelTypes.tsx
+++ b/web/src/components/pinnedPanelTypes.tsx
@@ -14,7 +14,8 @@ import { useMemo } from 'preact/hooks';
import { useTranslation } from 'react-i18next';
import { UsageFooter } from './UsageFooter.js';
import { extractLatestUsage } from '../usage-data.js';
-import { getActiveThinkingTs, getActiveStatusText } from '../thinking-utils.js';
+import { getActiveThinkingTs, getActiveStatusText, getTailSessionState, hasActiveToolCall } from '../thinking-utils.js';
+import { useNowTicker } from '../hooks/useNowTicker.js';
import type { PinnedPanel } from '../app.js';
import type { PanelRenderContext } from './PinnedPanelRegistry.js';
@@ -37,6 +38,12 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende
const lastUsage = useMemo(() => extractLatestUsage(events), [events]);
const activeThinkingTs = useMemo(() => getActiveThinkingTs(events), [events]);
const statusText = useMemo(() => getActiveStatusText(events), [events]);
+ const activeToolCall = useMemo(() => hasActiveToolCall(events), [events]);
+ const liveSessionState = useMemo(
+ () => getTailSessionState(events) ?? liveSub?.state ?? null,
+ [events, liveSub?.state],
+ );
+ const thinkingNow = useNowTicker(!!activeThinkingTs);
if (!liveSub) {
return ;
@@ -45,11 +52,9 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende
const isShell = liveSub.type === 'shell' || liveSub.type === 'script';
const mode = pinnedViewMode ?? (isShell ? 'terminal' : 'chat');
const modelDisplay = liveSub.modelDisplay ?? (liveSub.type === 'qwen' ? liveSub.qwenModel : undefined);
- const compactQuotaText = liveSub.type === 'codex'
+ const compactQuotaText = liveSub.type === 'codex' || liveSub.type === 'codex-sdk'
? ''
- : liveSub.type === 'codex-sdk'
- ? (liveSub.quotaLabel ?? '')
- : [liveSub.quotaLabel, liveSub.quotaUsageLabel].filter(Boolean).join(' · ');
+ : [liveSub.quotaLabel, liveSub.quotaUsageLabel].filter(Boolean).join(' · ');
return (
<>
@@ -61,18 +66,18 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende
loading={false}
refreshing={refreshing}
sessionId={sessionName}
- sessionState={liveSub.state}
+ sessionState={liveSessionState ?? undefined}
ws={ctx.ws}
workdir={liveSub.cwd ?? null}
serverId={ctx.serverId}
onQuote={ctx.onQuote}
/>
)}
- {(lastUsage || activeThinkingTs || statusText || liveSub.planLabel || liveSub.quotaLabel || liveSub.quotaUsageLabel) && (
+ {(lastUsage || activeThinkingTs || statusText || liveSessionState === 'running' || liveSessionState === 'idle' || liveSub.planLabel || liveSub.quotaLabel || liveSub.quotaUsageLabel || liveSub.quotaMeta) && (
)}
{(compactQuotaText || liveSub.planLabel) && (
diff --git a/web/src/hooks/useSubSessions.ts b/web/src/hooks/useSubSessions.ts
index 9475ed1a3..4b97c09d1 100644
--- a/web/src/hooks/useSubSessions.ts
+++ b/web/src/hooks/useSubSessions.ts
@@ -11,7 +11,7 @@ import {
} from '../api.js';
import type { WsClient } from '../ws-client.js';
import { isRunningTimelineEvent } from '../timeline-running.js';
-import { extractTransportPendingMessages } from '../transport-queue.js';
+import { extractTransportPendingMessages, mergeTransportPendingMessagesForRunningState } from '../transport-queue.js';
export interface SubSession extends SubSessionData {
sessionName: string;
@@ -252,12 +252,19 @@ export function useSubSessions(
return;
}
if (state === 'running' && hasPendingMessagesField) {
- const pendingMessages = extractTransportPendingMessages(msg.event.payload.pendingMessages);
setSubSessions((prev) => {
const idx = prev.findIndex((s) => s.sessionName === sessionName);
if (idx === -1) return prev;
const next = [...prev];
- next[idx] = { ...next[idx], state: 'running', transportPendingMessages: pendingMessages };
+ next[idx] = {
+ ...next[idx],
+ state: 'running',
+ transportPendingMessages: mergeTransportPendingMessagesForRunningState(
+ next[idx].transportPendingMessages,
+ msg.event.payload.pendingMessages,
+ true,
+ ),
+ };
return next;
});
return;
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 5943ad740..24bc2e366 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -182,7 +182,8 @@
"toast": {
"finished": "finished",
"upgrade_blocked_title": "Upgrade blocked",
- "upgrade_blocked_p2p_active": "P2P is still running. Stop it before upgrading the daemon."
+ "upgrade_blocked_p2p_active": "P2P is still running. Stop it before upgrading the daemon.",
+ "upgrade_blocked_transport_busy": "A transport session is still in a turn. Wait until it goes idle before upgrading the daemon."
},
"discussion": {
"role_critic": "Critic",
@@ -476,12 +477,12 @@
"propose_action": "Propose",
"propose_from_discussion_action": "From Recent Discussion",
"propose_from_description_action": "From Description Below",
- "audit_implementation_prompt": "Perform a strict implementation audit for {{reference}} against its OpenSpec artifacts. Identify every mismatch, omission, regression risk, edge-case gap, and missing test; then fix the code, tests, and any required supporting changes. Do not stop at a report. Deliver the implementation in full spec compliance.",
- "audit_spec_prompt": "Perform a strict specification audit for {{reference}}. Strengthen scope, requirements, acceptance criteria, edge cases, failure modes, dependencies, and testability; remove ambiguity and internal inconsistency; and update the spec until it is implementation-ready. Do not stop at review notes.",
- "implement_prompt": "Drive the implementation of {{reference}} aggressively. Break the work into concrete sub-tasks, dispatch sub-agents with clear ownership, integrate their output, resolve gaps against the spec, add or update tests, and verify the finished result. You own orchestration, technical decisions, and final acceptance.",
- "achieve_prompt": "Take {{reference}} to done using the full OpenSpec workflow. Inspect all remaining tasks and artifacts, complete the required implementation and spec work, close outstanding gaps, sync affected specs if needed, and archive the change once it meets completion criteria. Do not stop at status reporting.",
- "propose_from_discussion_prompt": "Generate an OpenSpec proposal from the recent discussion. Extract the goal, scope, key requirements, and acceptance criteria, and list unclear points as follow-ups. Start with a change proposal draft that is ready to land.",
- "propose_from_description_prompt": "Generate an OpenSpec proposal from the description below. Organize the goal, scope, key requirements, and acceptance criteria, and list missing details as follow-ups. Start with a change proposal draft that is ready to land."
+ "audit_implementation_prompt": "Perform a strict implementation audit for {{reference}} against its OpenSpec artifacts. Identify every mismatch, omission, regression risk, edge-case gap, and missing test; then fix the code, tests, and any required supporting changes. If the change artifacts need to move with the implementation, update the relevant OpenSpec files under {{reference}} in the same task. Do not stop at a report. Deliver the implementation in full spec compliance.",
+ "audit_spec_prompt": "Perform a strict specification audit for {{reference}}. Strengthen scope, requirements, acceptance criteria, edge cases, failure modes, dependencies, and testability; remove ambiguity and internal inconsistency; then directly update the change artifacts under {{reference}} (proposal, design, specs, tasks) until they are implementation-ready. Do not stop at review notes.",
+ "implement_prompt": "Drive the implementation of {{reference}} aggressively. Break the work into concrete sub-tasks, dispatch sub-agents with clear ownership, integrate their output, resolve gaps against the spec, add or update tests, and verify the finished result. Keep the OpenSpec artifacts under {{reference}} aligned with the implementation as you go instead of leaving follow-up notes. You own orchestration, technical decisions, and final acceptance.",
+ "achieve_prompt": "Take {{reference}} to done using the full OpenSpec workflow. Inspect all remaining tasks and artifacts, complete the required implementation and spec work, directly update proposal/design/specs/tasks under {{reference}} where needed, close outstanding gaps, sync affected specs if needed, and archive the change once it meets completion criteria. Do not stop at status reporting.",
+ "propose_from_discussion_prompt": "Generate an OpenSpec change from the recent discussion. Extract the goal, scope, key requirements, and acceptance criteria, list unclear points as follow-ups, and write the actual change artifacts under openspec/changes/ (proposal, design, specs, tasks) instead of stopping at a draft note.",
+ "propose_from_description_prompt": "Generate an OpenSpec change from the description below. Organize the goal, scope, key requirements, and acceptance criteria, list missing details as follow-ups, and write the actual change artifacts under openspec/changes/ (proposal, design, specs, tasks) instead of stopping at a draft note."
},
"upload": {
"upload_file": "Upload file",
diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json
index 52e5ab6b2..7a767f035 100644
--- a/web/src/i18n/locales/es.json
+++ b/web/src/i18n/locales/es.json
@@ -476,12 +476,12 @@
"propose_action": "Proponer",
"propose_from_discussion_action": "Desde la discusión reciente",
"propose_from_description_action": "Desde la descripción de abajo",
- "audit_implementation_prompt": "Realiza una auditoría estricta de la implementación de {{reference}} contra sus artefactos de OpenSpec. Identifica cada discrepancia, omisión, riesgo de regresión, hueco en casos límite y prueba faltante; después corrige el código, las pruebas y cualquier cambio de soporte necesario. No te detengas en un informe: deja la implementación totalmente alineada con la especificación.",
- "audit_spec_prompt": "Realiza una auditoría estricta de la especificación de {{reference}}. Refuerza alcance, requisitos, criterios de aceptación, casos límite, modos de fallo, dependencias y capacidad de prueba; elimina ambigüedades e inconsistencias internas; y actualiza la especificación hasta que quede lista para implementar. No te limites a observaciones de revisión.",
- "implement_prompt": "Impulsa con firmeza la implementación de {{reference}}. Divide el trabajo en subtareas concretas, asigna subagentes con propiedad clara, integra sus resultados, cierra las brechas respecto a la especificación, añade o actualiza pruebas y verifica el resultado final. Tú eres responsable de la orquestación, las decisiones técnicas y la aceptación final.",
- "achieve_prompt": "Lleva {{reference}} hasta completarlo usando el flujo completo de OpenSpec. Revisa todas las tareas y artefactos pendientes, completa el trabajo necesario de implementación y especificación, cierra los huecos abiertos, sincroniza las especificaciones afectadas si hace falta y archiva el cambio cuando cumpla los criterios de finalización. No te quedes en el reporte de estado.",
- "propose_from_discussion_prompt": "Genera una propuesta de OpenSpec a partir de la discusión reciente. Extrae el objetivo, el alcance, los requisitos clave y los criterios de aceptación, y deja los puntos poco claros como pendientes. Empieza con un borrador de change proposal listo para guardar.",
- "propose_from_description_prompt": "Genera una propuesta de OpenSpec a partir de la descripción de abajo. Organiza el objetivo, el alcance, los requisitos clave y los criterios de aceptación, y deja los detalles faltantes como pendientes. Empieza con un borrador de change proposal listo para guardar."
+ "audit_implementation_prompt": "Realiza una auditoría estricta de la implementación de {{reference}} contra sus artefactos de OpenSpec. Identifica cada discrepancia, omisión, riesgo de regresión, hueco en casos límite y prueba faltante; después corrige el código, las pruebas y cualquier cambio de soporte necesario. Si la implementación exige mover también la especificación, actualiza en la misma tarea los archivos de OpenSpec bajo {{reference}}. No te detengas en un informe: deja la implementación totalmente alineada con la especificación.",
+ "audit_spec_prompt": "Realiza una auditoría estricta de la especificación de {{reference}}. Refuerza alcance, requisitos, criterios de aceptación, casos límite, modos de fallo, dependencias y capacidad de prueba; elimina ambigüedades e inconsistencias internas; y actualiza directamente proposal, design, specs y tasks bajo {{reference}} hasta que quede lista para implementar. No te limites a observaciones de revisión.",
+ "implement_prompt": "Impulsa con firmeza la implementación de {{reference}}. Divide el trabajo en subtareas concretas, asigna subagentes con propiedad clara, integra sus resultados, cierra las brechas respecto a la especificación, añade o actualiza pruebas y verifica el resultado final. Mantén alineados durante el trabajo los artefactos de OpenSpec bajo {{reference}} en lugar de dejar notas para después. Tú eres responsable de la orquestación, las decisiones técnicas y la aceptación final.",
+ "achieve_prompt": "Lleva {{reference}} hasta completarlo usando el flujo completo de OpenSpec. Revisa todas las tareas y artefactos pendientes, completa el trabajo necesario de implementación y especificación, actualiza directamente proposal, design, specs y tasks bajo {{reference}} cuando haga falta, cierra los huecos abiertos, sincroniza las especificaciones afectadas si hace falta y archiva el cambio cuando cumpla los criterios de finalización. No te quedes en el reporte de estado.",
+ "propose_from_discussion_prompt": "Genera un cambio de OpenSpec a partir de la discusión reciente. Extrae el objetivo, el alcance, los requisitos clave y los criterios de aceptación, deja los puntos poco claros como pendientes y escribe los artefactos reales bajo openspec/changes/ (proposal, design, specs y tasks) en lugar de quedarte en una nota borrador.",
+ "propose_from_description_prompt": "Genera un cambio de OpenSpec a partir de la descripción de abajo. Organiza el objetivo, el alcance, los requisitos clave y los criterios de aceptación, deja los detalles faltantes como pendientes y escribe los artefactos reales bajo openspec/changes/ (proposal, design, specs y tasks) en lugar de quedarte en una nota borrador."
},
"upload": {
"upload_file": "Subir archivo",
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index 64b3847c8..c0bf48dc0 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -476,12 +476,12 @@
"propose_action": "提案",
"propose_from_discussion_action": "直近の議論から生成",
"propose_from_description_action": "下の説明から生成",
- "audit_implementation_prompt": "{{reference}} について、OpenSpec の成果物に照らした厳格な実装監査を実施してください。不一致、抜け漏れ、回帰リスク、境界ケースの欠落、未整備のテストをすべて洗い出し、そのうえでコード、テスト、必要な関連修正まで直してください。レポートだけで終わらせず、実装を仕様完全準拠まで持っていってください。",
- "audit_spec_prompt": "{{reference}} について、厳格な仕様監査を実施してください。スコープ、要件、受け入れ条件、境界ケース、失敗モード、依存関係、テスト容易性を強化し、曖昧さと内部矛盾を除去して、実装可能な品質まで仕様を更新してください。レビューコメントだけで終わらせないでください。",
- "implement_prompt": "{{reference}} の実装を強力に前進させてください。作業を具体的なサブタスクに分解し、サブエージェントへ明確な責務で割り当て、成果を統合し、仕様との差分を解消し、テストを追加または更新し、最終結果を検証してください。あなたがオーケストレーション、技術判断、最終受け入れを担ってください。",
- "achieve_prompt": "完全な OpenSpec ワークフローで {{reference}} を done まで持っていってください。残っているタスクと成果物をすべて確認し、必要な実装と仕様の作業を完了し、未解決事項を閉じ、必要なら影響するメイン仕様を同期し、完了条件を満たしたら変更をアーカイブしてください。進捗報告だけで止まらないでください。",
- "propose_from_discussion_prompt": "直近の議論から OpenSpec proposal を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不明点は確認事項として明示してください。まずはそのまま保存できる change proposal の草案を出してください。",
- "propose_from_description_prompt": "下の説明から OpenSpec proposal を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不足情報は確認事項として明示してください。まずはそのまま保存できる change proposal の草案を出してください。"
+ "audit_implementation_prompt": "{{reference}} について、OpenSpec の成果物に照らした厳格な実装監査を実施してください。不一致、抜け漏れ、回帰リスク、境界ケースの欠落、未整備のテストをすべて洗い出し、そのうえでコード、テスト、必要な関連修正まで直してください。実装に合わせて仕様成果物も動かす必要がある場合は、同じ作業の中で {{reference}} 配下の OpenSpec ファイルも直接更新してください。レポートだけで終わらせず、実装を仕様完全準拠まで持っていってください。",
+ "audit_spec_prompt": "{{reference}} について、厳格な仕様監査を実施してください。スコープ、要件、受け入れ条件、境界ケース、失敗モード、依存関係、テスト容易性を強化し、曖昧さと内部矛盾を除去したうえで、{{reference}} 配下の proposal・design・specs・tasks を直接更新し、実装可能な品質まで仕様を仕上げてください。レビューコメントだけで終わらせないでください。",
+ "implement_prompt": "{{reference}} の実装を強力に前進させてください。作業を具体的なサブタスクに分解し、サブエージェントへ明確な責務で割り当て、成果を統合し、仕様との差分を解消し、テストを追加または更新し、最終結果を検証してください。作業中は {{reference}} 配下の OpenSpec 成果物も同期させ、後続メモに先送りしないでください。あなたがオーケストレーション、技術判断、最終受け入れを担ってください。",
+ "achieve_prompt": "完全な OpenSpec ワークフローで {{reference}} を done まで持っていってください。残っているタスクと成果物をすべて確認し、必要な実装と仕様の作業を完了し、必要に応じて {{reference}} 配下の proposal・design・specs・tasks を直接更新し、未解決事項を閉じ、必要なら影響するメイン仕様を同期し、完了条件を満たしたら変更をアーカイブしてください。進捗報告だけで止まらないでください。",
+ "propose_from_discussion_prompt": "直近の議論から OpenSpec 変更を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不明点は確認事項として明示したうえで、draft note で止めずに openspec/changes/ 配下へ proposal・design・specs・tasks を実際に書き出してください。",
+ "propose_from_description_prompt": "下の説明から OpenSpec 変更を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不足情報は確認事項として明示したうえで、draft note で止めずに openspec/changes/ 配下へ proposal・design・specs・tasks を実際に書き出してください。"
},
"upload": {
"upload_file": "ファイルをアップロード",
diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json
index c2bf23f67..cde74b344 100644
--- a/web/src/i18n/locales/ko.json
+++ b/web/src/i18n/locales/ko.json
@@ -476,12 +476,12 @@
"propose_action": "제안",
"propose_from_discussion_action": "최근 논의에서 생성",
"propose_from_description_action": "아래 설명에서 생성",
- "audit_implementation_prompt": "{{reference}}에 대해 OpenSpec 산출물을 기준으로 엄격한 구현 감사를 수행하세요. 모든 불일치, 누락, 회귀 위험, 경계 사례 공백, 누락된 테스트를 찾아낸 뒤 코드, 테스트, 필요한 보조 변경까지 직접 수정하세요. 보고서로 끝내지 말고 구현을 명세와 완전히 일치시키세요.",
- "audit_spec_prompt": "{{reference}}에 대해 엄격한 명세 감사를 수행하세요. 범위, 요구사항, 승인 기준, 경계 사례, 실패 모드, 의존성, 테스트 가능성을 강화하고, 모호함과 내부 불일치를 제거해 바로 구현 가능한 수준까지 명세를 개선하세요. 검토 의견만 남기지 말고 명세 자체를 고치세요.",
- "implement_prompt": "{{reference}} 구현을 강하게 밀어붙이세요. 작업을 구체적 하위 작업으로 분해하고, 하위 에이전트에 명확한 소유권을 배정하고, 결과를 통합하고, 명세 대비 부족분을 메우고, 테스트를 추가 또는 갱신하고, 최종 결과를 검증하세요. 당신이 오케스트레이션, 기술 판단, 최종 검수를 책임지세요.",
- "achieve_prompt": "전체 OpenSpec 워크플로로 {{reference}}를 완료 상태까지 밀어붙이세요. 남은 작업과 산출물을 모두 점검하고, 필요한 구현과 명세 작업을 끝내고, 미해결 항목을 닫고, 필요하면 영향받는 메인 명세를 동기화한 뒤, 완료 기준을 충족하면 변경을 보관하세요. 상태 보고에서 멈추지 마세요.",
- "propose_from_discussion_prompt": "최근 논의를 바탕으로 OpenSpec proposal을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 불명확한 부분은 확인 필요 항목으로 남기세요. 먼저 바로 저장할 수 있는 change proposal 초안을 작성하세요.",
- "propose_from_description_prompt": "아래 설명을 바탕으로 OpenSpec proposal을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 빠진 정보는 확인 필요 항목으로 남기세요. 먼저 바로 저장할 수 있는 change proposal 초안을 작성하세요."
+ "audit_implementation_prompt": "{{reference}}에 대해 OpenSpec 산출물을 기준으로 엄격한 구현 감사를 수행하세요. 모든 불일치, 누락, 회귀 위험, 경계 사례 공백, 누락된 테스트를 찾아낸 뒤 코드, 테스트, 필요한 보조 변경까지 직접 수정하세요. 구현에 맞춰 명세 산출물도 움직여야 한다면 같은 작업 안에서 {{reference}} 아래 OpenSpec 파일까지 직접 갱신하세요. 보고서로 끝내지 말고 구현을 명세와 완전히 일치시키세요.",
+ "audit_spec_prompt": "{{reference}}에 대해 엄격한 명세 감사를 수행하세요. 범위, 요구사항, 승인 기준, 경계 사례, 실패 모드, 의존성, 테스트 가능성을 강화하고, 모호함과 내부 불일치를 제거한 뒤 {{reference}} 아래 proposal, design, specs, tasks를 직접 갱신해 바로 구현 가능한 수준까지 명세를 개선하세요. 검토 의견만 남기지 말고 명세 자체를 고치세요.",
+ "implement_prompt": "{{reference}} 구현을 강하게 밀어붙이세요. 작업을 구체적 하위 작업으로 분해하고, 하위 에이전트에 명확한 소유권을 배정하고, 결과를 통합하고, 명세 대비 부족분을 메우고, 테스트를 추가 또는 갱신하고, 최종 결과를 검증하세요. 작업 중에는 {{reference}} 아래 OpenSpec 산출물도 함께 맞춰 두고 후속 메모로 미루지 마세요. 당신이 오케스트레이션, 기술 판단, 최종 검수를 책임지세요.",
+ "achieve_prompt": "전체 OpenSpec 워크플로로 {{reference}}를 완료 상태까지 밀어붙이세요. 남은 작업과 산출물을 모두 점검하고, 필요한 구현과 명세 작업을 끝내고, 필요하면 {{reference}} 아래 proposal, design, specs, tasks를 직접 갱신하고, 미해결 항목을 닫고, 영향받는 메인 명세를 동기화한 뒤, 완료 기준을 충족하면 변경을 보관하세요. 상태 보고에서 멈추지 마세요.",
+ "propose_from_discussion_prompt": "최근 논의를 바탕으로 OpenSpec 변경을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 불명확한 부분은 확인 필요 항목으로 남긴 뒤, 초안 메모에서 멈추지 말고 openspec/changes/ 아래에 proposal, design, specs, tasks를 실제로 작성하세요.",
+ "propose_from_description_prompt": "아래 설명을 바탕으로 OpenSpec 변경을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 빠진 정보는 확인 필요 항목으로 남긴 뒤, 초안 메모에서 멈추지 말고 openspec/changes/ 아래에 proposal, design, specs, tasks를 실제로 작성하세요."
},
"upload": {
"upload_file": "파일 업로드",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index f2b48d19e..7921d0fca 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -476,12 +476,12 @@
"propose_action": "Предложить",
"propose_from_discussion_action": "Из недавнего обсуждения",
"propose_from_description_action": "Из описания ниже",
- "audit_implementation_prompt": "Проведи строгий аудит реализации {{reference}} по его артефактам OpenSpec. Выяви каждое расхождение, упущение, риск регрессии, пробел в пограничных сценариях и недостающий тест; затем исправь код, тесты и все необходимые сопутствующие изменения. Не останавливайся на отчете: доведи реализацию до полного соответствия спецификации.",
- "audit_spec_prompt": "Проведи строгий аудит спецификации {{reference}}. Усиль границы задачи, требования, критерии приемки, пограничные случаи, режимы отказа, зависимости и проверяемость; устрани неоднозначность и внутренние противоречия; и обнови спецификацию до состояния, готового к реализации. Не ограничивайся замечаниями ревью.",
- "implement_prompt": "Жестко доведи реализацию {{reference}}. Разбей работу на конкретные подзадачи, раздай подагентам четкие зоны ответственности, интегрируй их результат, закрой разрывы относительно спецификации, добавь или обнови тесты и проверь финальный результат. На тебе оркестрация, технические решения и итоговая приемка.",
- "achieve_prompt": "Доведи {{reference}} до состояния done по полному процессу OpenSpec. Проверь все оставшиеся задачи и артефакты, выполни необходимую работу по реализации и спецификации, закрой незавершенные пункты, при необходимости синхронизируй затронутые основные спецификации и архивируй изменение, когда оно будет соответствовать критериям завершения. Не останавливайся на отчете о статусе.",
- "propose_from_discussion_prompt": "Сгенерируй OpenSpec proposal на основе недавнего обсуждения. Выдели цель, scope, ключевые требования и критерии приемки, а неясные места перечисли как вопросы на уточнение. Начни с черновика change proposal, готового к сохранению.",
- "propose_from_description_prompt": "Сгенерируй OpenSpec proposal на основе описания ниже. Сформируй цель, scope, ключевые требования и критерии приемки, а недостающие детали перечисли как вопросы на уточнение. Начни с черновика change proposal, готового к сохранению."
+ "audit_implementation_prompt": "Проведи строгий аудит реализации {{reference}} по его артефактам OpenSpec. Выяви каждое расхождение, упущение, риск регрессии, пробел в пограничных сценариях и недостающий тест; затем исправь код, тесты и все необходимые сопутствующие изменения. Если по ходу нужно синхронизировать спецификацию, обнови в той же задаче соответствующие файлы OpenSpec внутри {{reference}}. Не останавливайся на отчете: доведи реализацию до полного соответствия спецификации.",
+ "audit_spec_prompt": "Проведи строгий аудит спецификации {{reference}}. Усиль границы задачи, требования, критерии приемки, пограничные случаи, режимы отказа, зависимости и проверяемость; устрани неоднозначность и внутренние противоречия; затем напрямую обнови proposal, design, specs и tasks внутри {{reference}} до состояния, готового к реализации. Не ограничивайся замечаниями ревью.",
+ "implement_prompt": "Жестко доведи реализацию {{reference}}. Разбей работу на конкретные подзадачи, раздай подагентам четкие зоны ответственности, интегрируй их результат, закрой разрывы относительно спецификации, добавь или обнови тесты и проверь финальный результат. По ходу работы держи артефакты OpenSpec внутри {{reference}} синхронизированными, а не оставляй это как последующую заметку. На тебе оркестрация, технические решения и итоговая приемка.",
+ "achieve_prompt": "Доведи {{reference}} до состояния done по полному процессу OpenSpec. Проверь все оставшиеся задачи и артефакты, выполни необходимую работу по реализации и спецификации, при необходимости напрямую обнови proposal, design, specs и tasks внутри {{reference}}, закрой незавершенные пункты, при необходимости синхронизируй затронутые основные спецификации и архивируй изменение, когда оно будет соответствовать критериям завершения. Не останавливайся на отчете о статусе.",
+ "propose_from_discussion_prompt": "Сгенерируй изменение OpenSpec на основе недавнего обсуждения. Выдели цель, scope, ключевые требования и критерии приемки, неясные места перечисли как вопросы на уточнение и запиши реальные артефакты в openspec/changes/ (proposal, design, specs и tasks), а не останавливайся на черновой заметке.",
+ "propose_from_description_prompt": "Сгенерируй изменение OpenSpec на основе описания ниже. Сформируй цель, scope, ключевые требования и критерии приемки, недостающие детали перечисли как вопросы на уточнение и запиши реальные артефакты в openspec/changes/ (proposal, design, specs и tasks), а не останавливайся на черновой заметке."
},
"upload": {
"upload_file": "Загрузить файл",
diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json
index 9934f6faa..9a6c0cd1c 100644
--- a/web/src/i18n/locales/zh-CN.json
+++ b/web/src/i18n/locales/zh-CN.json
@@ -182,7 +182,8 @@
"toast": {
"finished": "完成了",
"upgrade_blocked_title": "升级已阻止",
- "upgrade_blocked_p2p_active": "P2P 仍在运行。请先停止,再升级 daemon。"
+ "upgrade_blocked_p2p_active": "P2P 仍在运行。请先停止,再升级 daemon。",
+ "upgrade_blocked_transport_busy": "还有 transport session 正在 turn 中。等它回到 idle 再升级 daemon。"
},
"discussion": {
"role_critic": "批判者",
@@ -476,12 +477,12 @@
"propose_action": "Propose",
"propose_from_discussion_action": "根据最近讨论生成",
"propose_from_description_action": "根据下面的描述生成",
- "audit_implementation_prompt": "对 {{reference}} 执行严格的实现审计,逐项对照其 OpenSpec 产物。找出所有不一致、遗漏、回归风险、边界场景缺口和缺失测试;然后直接修复代码、测试以及必要的配套改动。不要停留在报告层面,必须把实现修到与规范完全一致。",
- "audit_spec_prompt": "对 {{reference}} 执行严格的规范审计。强化范围定义、需求、验收标准、边界场景、失败模式、依赖关系与可测试性,消除歧义和内部不一致,把规范修到可直接落地实施的质量。不要只给审查意见,直接修规范。",
- "implement_prompt": "强力推进 {{reference}} 的实施。把工作拆成明确子任务,给子代理分配清晰所有权,整合输出,逐项补齐与规范的差距,补充或更新测试,并对最终结果负责验收。你负责调度、技术决策、集成收口和最终质量把关。",
- "achieve_prompt": "按完整 OpenSpec 工作流把 {{reference}} 推到完成。检查所有剩余任务和产物,完成必要的实现与规范工作,关闭未完成项,必要时同步受影响的主规范,并在满足完成条件后归档该变更。不要只汇报状态,直接把它做完。",
- "propose_from_discussion_prompt": "根据最近的讨论生成一个 OpenSpec proposal。提炼目标、范围、关键需求和验收标准;不明确的部分明确列为待确认项。先给出可直接落库的 change proposal 草稿。",
- "propose_from_description_prompt": "根据下面的描述生成一个 OpenSpec proposal。整理目标、范围、关键需求和验收标准;缺失信息明确列为待确认项。先给出可直接落库的 change proposal 草稿。"
+ "audit_implementation_prompt": "对 {{reference}} 执行严格的实现审计,逐项对照其 OpenSpec 产物。找出所有不一致、遗漏、回归风险、边界场景缺口和缺失测试;然后直接修复代码、测试以及必要的配套改动。如果实现推进过程中需要同步变更规范产物,也要在同一次任务里直接更新 {{reference}} 下的 OpenSpec 文件。不要停留在报告层面,必须把实现修到与规范完全一致。",
+ "audit_spec_prompt": "对 {{reference}} 执行严格的规范审计。强化范围定义、需求、验收标准、边界场景、失败模式、依赖关系与可测试性,消除歧义和内部不一致;然后直接更新 {{reference}} 下的 proposal、design、specs、tasks,直到规范达到可直接落地实施的质量。不要只给审查意见。",
+ "implement_prompt": "强力推进 {{reference}} 的实施。把工作拆成明确子任务,给子代理分配清晰所有权,整合输出,逐项补齐与规范的差距,补充或更新测试,并对最终结果负责验收。实施过程中要同步维护 {{reference}} 下的 OpenSpec 产物,不要把规范更新留成后续备注。你负责调度、技术决策、集成收口和最终质量把关。",
+ "achieve_prompt": "按完整 OpenSpec 工作流把 {{reference}} 推到完成。检查所有剩余任务和产物,完成必要的实现与规范工作,按需直接更新 {{reference}} 下的 proposal、design、specs、tasks,关闭未完成项,必要时同步受影响的主规范,并在满足完成条件后归档该变更。不要只汇报状态,直接把它做完。",
+ "propose_from_discussion_prompt": "根据最近的讨论生成一个 OpenSpec 变更。提炼目标、范围、关键需求和验收标准;不明确的部分明确列为待确认项;并直接把 proposal、design、specs、tasks 写到 openspec/changes/ 下,而不是只停留在草稿说明。",
+ "propose_from_description_prompt": "根据下面的描述生成一个 OpenSpec 变更。整理目标、范围、关键需求和验收标准;缺失信息明确列为待确认项;并直接把 proposal、design、specs、tasks 写到 openspec/changes/ 下,而不是只停留在草稿说明。"
},
"upload": {
"upload_file": "上传文件",
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index 90a2075be..a647bbb3c 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -182,7 +182,8 @@
"toast": {
"finished": "已完成",
"upgrade_blocked_title": "升級已阻止",
- "upgrade_blocked_p2p_active": "P2P 仍在執行中。請先停止,再升級 daemon。"
+ "upgrade_blocked_p2p_active": "P2P 仍在執行中。請先停止,再升級 daemon。",
+ "upgrade_blocked_transport_busy": "還有 transport session 正在 turn 中。等它回到 idle 再升級 daemon。"
},
"discussion": {
"role_critic": "評論者",
@@ -476,12 +477,12 @@
"propose_action": "Propose",
"propose_from_discussion_action": "根據最近討論生成",
"propose_from_description_action": "根據下面的描述生成",
- "audit_implementation_prompt": "對 {{reference}} 執行嚴格的實作審計,逐項對照其 OpenSpec 產物。找出所有不一致、遺漏、回歸風險、邊界情境缺口與缺失測試;然後直接修復程式碼、測試以及必要的配套改動。不要停留在報告層面,必須把實作修到與規格完全一致。",
- "audit_spec_prompt": "對 {{reference}} 執行嚴格的規格審計。強化範圍定義、需求、驗收標準、邊界情境、失敗模式、依賴關係與可測試性,消除歧義與內部不一致,把規格修到可直接落地實作的品質。不要只給審查意見,直接修規格。",
- "implement_prompt": "強力推進 {{reference}} 的實作。把工作拆成明確子任務,給子代理分配清楚所有權,整合輸出,逐項補齊與規格的差距,補充或更新測試,並對最終結果負責驗收。你負責調度、技術決策、整合收口與最終品質把關。",
- "achieve_prompt": "依照完整 OpenSpec 工作流程把 {{reference}} 推到完成。檢查所有剩餘任務與產物,完成必要的實作與規格工作,關閉未完成項,必要時同步受影響的主規格,並在符合完成條件後封存此變更。不要只回報狀態,直接把它做完。",
- "propose_from_discussion_prompt": "根據最近的討論生成一個 OpenSpec proposal。提煉目標、範圍、關鍵需求和驗收標準;不明確的部分明確列為待確認項。先給出可直接落庫的 change proposal 草稿。",
- "propose_from_description_prompt": "根據下面的描述生成一個 OpenSpec proposal。整理目標、範圍、關鍵需求和驗收標準;缺失資訊明確列為待確認項。先給出可直接落庫的 change proposal 草稿。"
+ "audit_implementation_prompt": "對 {{reference}} 執行嚴格的實作審計,逐項對照其 OpenSpec 產物。找出所有不一致、遺漏、回歸風險、邊界情境缺口與缺失測試;然後直接修復程式碼、測試以及必要的配套改動。如果實作推進過程需要同步調整規格產物,也要在同一次任務裡直接更新 {{reference}} 下的 OpenSpec 檔案。不要停留在報告層面,必須把實作修到與規格完全一致。",
+ "audit_spec_prompt": "對 {{reference}} 執行嚴格的規格審計。強化範圍定義、需求、驗收標準、邊界情境、失敗模式、依賴關係與可測試性,消除歧義與內部不一致;然後直接更新 {{reference}} 下的 proposal、design、specs、tasks,直到規格達到可直接落地實作的品質。不要只給審查意見。",
+ "implement_prompt": "強力推進 {{reference}} 的實作。把工作拆成明確子任務,給子代理分配清楚所有權,整合輸出,逐項補齊與規格的差距,補充或更新測試,並對最終結果負責驗收。實作過程中要同步維護 {{reference}} 下的 OpenSpec 產物,不要把規格更新留成後續備註。你負責調度、技術決策、整合收口與最終品質把關。",
+ "achieve_prompt": "依照完整 OpenSpec 工作流程把 {{reference}} 推到完成。檢查所有剩餘任務與產物,完成必要的實作與規格工作,按需直接更新 {{reference}} 下的 proposal、design、specs、tasks,關閉未完成項,必要時同步受影響的主規格,並在符合完成條件後封存此變更。不要只回報狀態,直接把它做完。",
+ "propose_from_discussion_prompt": "根據最近的討論生成一個 OpenSpec 變更。提煉目標、範圍、關鍵需求和驗收標準;不明確的部分明確列為待確認項;並直接把 proposal、design、specs、tasks 寫到 openspec/changes/ 下,而不是只停留在草稿說明。",
+ "propose_from_description_prompt": "根據下面的描述生成一個 OpenSpec 變更。整理目標、範圍、關鍵需求和驗收標準;缺失資訊明確列為待確認項;並直接把 proposal、design、specs、tasks 寫到 openspec/changes/ 下,而不是只停留在草稿說明。"
},
"upload": {
"upload_file": "上傳檔案",
diff --git a/web/src/server-selection.ts b/web/src/server-selection.ts
index 2adeacbac..13f3c06e1 100644
--- a/web/src/server-selection.ts
+++ b/web/src/server-selection.ts
@@ -3,6 +3,10 @@ export interface SelectableServerInfo {
name: string;
}
+export interface NamedSessionInfo {
+ name: string;
+}
+
export function hasSelectedServer(
selectedServerId: string | null,
servers: readonly SelectableServerInfo[],
@@ -35,7 +39,14 @@ export function shouldShowInitialConnectingGate(
selectedServerId: string | null,
connected: boolean,
sessionsLoaded: boolean,
- serversLoaded: boolean,
): boolean {
- return Boolean(authReady && selectedServerId && !sessionsLoaded && !connected && !serversLoaded);
+ return Boolean(authReady && selectedServerId && !sessionsLoaded && !connected);
+}
+
+export function hasResolvedActiveSession(
+ activeSession: string | null,
+ sessions: readonly NamedSessionInfo[],
+): boolean {
+ if (!activeSession) return false;
+ return sessions.some((session) => session.name === activeSession);
}
diff --git a/web/src/session-label-api.ts b/web/src/session-label-api.ts
new file mode 100644
index 000000000..86f21aa7a
--- /dev/null
+++ b/web/src/session-label-api.ts
@@ -0,0 +1,13 @@
+import { apiFetch } from './api.js';
+
+export async function updateMainSessionLabel(
+ serverId: string,
+ sessionName: string,
+ nextLabel: string | null,
+): Promise {
+ await apiFetch(`/api/server/${serverId}/sessions/${encodeURIComponent(sessionName)}/label`, {
+ method: 'PATCH',
+ keepalive: true,
+ body: JSON.stringify({ label: nextLabel }),
+ });
+}
diff --git a/web/src/styles.css b/web/src/styles.css
index 43a11ca9a..f5559c6c7 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -858,12 +858,33 @@ body {
.session-ctx-input { position: absolute; left: 0; top: 0; height: 100%; background: #34d399; border-radius: 3px; }
.session-ctx-cache { position: absolute; left: 0; top: 0; height: 100%; background: #818cf8; border-radius: 3px; }
.session-usage-stats { display: flex; justify-content: space-between; font-size: 10px; color: #475569; }
+.session-live-status-inline { display: inline-flex; align-items: center; justify-content: center; gap: 1px; min-width: 20px; color: #818cf8; min-width: 0; max-width: min(42vw, 240px); }
+.session-live-status-emoji { display: inline-block; font-size: 12px; line-height: 1; filter: saturate(1.1); }
+.session-live-status-emoji.robot { transform: translateY(0.2px); }
+.session-live-status-text { color: #818cf8; font-size: 10px; line-height: 1.1; margin-left: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.92; }
+.session-live-status-inline.running .session-live-status-emoji.gear { animation: status-gear-spin 0.8s linear infinite; transform-origin: 50% 50%; }
+.session-live-status-inline.thinking .session-live-status-emoji.thought { font-size: 10px; transform: translateY(-2px) translateX(-1px); opacity: 0.94; animation: status-thought-breathe 1.8s ease-in-out infinite; transform-origin: 50% 100%; }
+.session-live-status-inline.tool .session-live-status-emoji.tool { animation: status-tool-peek 1.15s ease-in-out infinite; transform-origin: 50% 50%; }
+.session-live-status-inline.idle .session-live-status-emoji.sleep { font-size: 9px; transform: translateY(-3px) translateX(-1px); opacity: 0.9; animation: status-sleep-breathe 1.8s ease-in-out infinite; transform-origin: 50% 100%; }
.session-usage-model { color: #a78bfa; font-size: 10px; font-weight: 500; margin-right: 6px; }
.session-usage-tokens { color: #64748b; }
.session-usage-badge { color: #93c5fd; border: 1px solid #1d4ed8; border-radius: 999px; padding: 1px 6px; line-height: 1.4; }
.session-usage-quota-inline { color: #64748b; font-size: 9px; line-height: 1.4; white-space: nowrap; }
.session-usage-cost { color: #94a3b8; }
-.session-thinking-inline { color: #818cf8; font-style: italic; margin-left: auto; padding-left: 8px; }
+@keyframes status-gear-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
+@keyframes status-thought-breathe {
+ 0%, 100% { transform: translateY(-2px) translateX(-1px) scale(0.9); opacity: 0.72; }
+ 50% { transform: translateY(-3px) translateX(-1px) scale(1.06); opacity: 1; }
+}
+@keyframes status-tool-peek {
+ 0%, 100% { transform: translateX(0) rotate(0deg) scale(0.98); }
+ 35% { transform: translateX(0.5px) rotate(-8deg) scale(1.03); }
+ 65% { transform: translateX(0.5px) rotate(8deg) scale(1.03); }
+}
+@keyframes status-sleep-breathe {
+ 0%, 100% { transform: translateY(-3px) translateX(-1px) scale(0.88); opacity: 0.72; }
+ 50% { transform: translateY(-4px) translateX(-1px) scale(1.08); opacity: 1; }
+}
.subsession-input-bar { display: flex; gap: 6px; padding: 6px 8px; background: #0d1117; border-top: 1px solid #1e293b; flex-shrink: 0; }
.subsession-input { flex: 1; background: #1e293b; border: 1px solid #334155; border-radius: 6px; color: #e2e8f0; font-family: inherit; font-size: 13px; padding: 5px 10px; outline: none; }
.subsession-input:focus { border-color: #3b82f6; }
diff --git a/web/src/thinking-utils.ts b/web/src/thinking-utils.ts
index a6fb5896b..1d869494f 100644
--- a/web/src/thinking-utils.ts
+++ b/web/src/thinking-utils.ts
@@ -66,6 +66,42 @@ export function getActiveStatusText(events: Array<{ type: string; payload?: Reco
return null;
}
+/**
+ * Detect whether the current live tail is inside an active tool call.
+ * Only a trailing tool.call counts. A trailing tool.result means the tool already finished.
+ */
+export function hasActiveToolCall(events: Array<{ type: string; payload?: Record }>): boolean {
+ for (let i = events.length - 1; i >= 0; i--) {
+ const e = events[i];
+ if (e.type === 'tool.call') return true;
+ if (e.type === 'tool.result') return false;
+ if (e.type === 'session.state') {
+ if (e.payload?.state === 'idle') return false;
+ continue;
+ }
+ if (e.type === 'assistant.thinking' || THINKING_SKIP_TYPES.has(e.type)) continue;
+ return false;
+ }
+ return false;
+}
+
+/**
+ * Read the most recent authoritative session.state from the timeline tail.
+ * This is more reliable than outer session store state for footer rendering,
+ * because timeline updates can arrive before higher-level session snapshots settle.
+ */
+export function getTailSessionState(
+ events: Array<{ type: string; payload?: Record }>,
+): string | null {
+ for (let i = events.length - 1; i >= 0; i--) {
+ const e = events[i];
+ if (e.type !== 'session.state') continue;
+ const state = e.payload?.state;
+ return typeof state === 'string' && state ? state : null;
+ }
+ return null;
+}
+
export function isRunningSessionState(sessionState: string | undefined): boolean {
return sessionState === 'running';
}
diff --git a/web/src/timeline-running.ts b/web/src/timeline-running.ts
index 90a710feb..2f3f7043f 100644
--- a/web/src/timeline-running.ts
+++ b/web/src/timeline-running.ts
@@ -1,6 +1,7 @@
import type { TimelineEvent } from '../../src/shared/timeline/types.js';
const RUNNING_TIMELINE_EVENT_TYPES = new Set([
+ 'assistant.thinking',
'assistant.text',
'tool.call',
'tool.result',
diff --git a/web/src/transport-queue.ts b/web/src/transport-queue.ts
index 3e7b33505..542da142d 100644
--- a/web/src/transport-queue.ts
+++ b/web/src/transport-queue.ts
@@ -4,3 +4,15 @@ export function extractTransportPendingMessages(value: unknown): string[] {
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
+
+export function mergeTransportPendingMessagesForRunningState(
+ existing: string[] | null | undefined,
+ pendingFromEvent: unknown,
+ hasPendingMessagesField: boolean,
+): string[] {
+ const existingMessages = Array.isArray(existing) ? existing.filter((entry) => typeof entry === 'string' && entry.length > 0) : [];
+ if (!hasPendingMessagesField) return existingMessages;
+ const nextMessages = extractTransportPendingMessages(pendingFromEvent);
+ if (nextMessages.length > 0) return nextMessages;
+ return existingMessages;
+}
diff --git a/web/src/ws-client.ts b/web/src/ws-client.ts
index 2eb616428..5ed98b36f 100644
--- a/web/src/ws-client.ts
+++ b/web/src/ws-client.ts
@@ -30,6 +30,7 @@ export type ServerMessage =
| { type: typeof DAEMON_MSG.RECONNECTED }
| { type: typeof DAEMON_MSG.DISCONNECTED }
| { type: typeof DAEMON_MSG.UPGRADE_BLOCKED; reason: 'p2p_active'; activeRunIds?: string[] }
+ | { type: typeof DAEMON_MSG.UPGRADE_BLOCKED; reason: 'transport_busy'; activeSessionNames?: string[] }
| { type: 'daemon.error'; kind: 'uncaughtException' | 'unhandledRejection' | 'warning'; message: string; stack?: string; ts: number }
| { type: 'session_list'; daemonVersion?: string | null; sessions: Array<{ name: string; project: string; role: string; agentType: string; agentVersion?: string; state: string; projectDir?: string; runtimeType?: 'process' | 'transport'; label?: string; description?: string; qwenModel?: string; requestedModel?: string; activeModel?: string; qwenAuthType?: string; qwenAuthLimit?: string; qwenAvailableModels?: string[]; modelDisplay?: string; planLabel?: string; permissionLabel?: string; quotaLabel?: string; quotaUsageLabel?: string; quotaMeta?: import('../../shared/provider-quota.js').ProviderQuotaMeta | null; effort?: import('../../shared/effort-levels.js').TransportEffortLevel }> }
| { type: 'outbound'; platform: string; channelId: string; content: string }
diff --git a/web/test/chat-view-tool-format.test.tsx b/web/test/chat-view-tool-format.test.tsx
index 7c068f82f..8e8e17344 100644
--- a/web/test/chat-view-tool-format.test.tsx
+++ b/web/test/chat-view-tool-format.test.tsx
@@ -3,7 +3,7 @@
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { h } from 'preact';
-import { render, screen, cleanup, fireEvent } from '@testing-library/preact';
+import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/preact';
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = vi.fn();
@@ -27,8 +27,13 @@ vi.mock('../src/components/FileBrowser.js', () => ({
FileBrowser: () => null,
}));
+vi.mock('../src/api.js', () => ({
+ downloadAttachment: vi.fn().mockResolvedValue(undefined),
+}));
+
import { ChatView } from '../src/components/ChatView.js';
import type { TimelineEvent } from '../src/ws-client.js';
+import { downloadAttachment } from '../src/api.js';
function makeEvent(overrides: Partial & { type: string; payload: Record }): TimelineEvent {
return {
@@ -146,6 +151,65 @@ describe('ChatView tool payload formatting', () => {
expect(screen.getByText('output')).toBeDefined();
});
+ it('connects Windows file paths in tool output to preview and download', async () => {
+ const fsReadFile = vi.fn(() => 'req-win-path');
+ const onMessage = vi.fn(() => vi.fn());
+ const events = [
+ makeEvent({
+ type: 'tool.result',
+ payload: { output: { path: 'C:\\Users\\admin\\screenshot.png' } },
+ }),
+ ];
+
+ const { container } = render(
+ ,
+ );
+
+ const link = container.querySelector('.chat-path-link') as HTMLElement | null;
+ const button = container.querySelector('.chat-dl-btn') as HTMLButtonElement | null;
+ expect(link?.textContent).toBe('C:\\Users\\admin\\screenshot.png');
+ expect(button).not.toBeNull();
+
+ fireEvent.click(button!);
+
+ expect(fsReadFile).toHaveBeenCalledWith('C:\\Users\\admin\\screenshot.png');
+ onMessage.mock.calls[0][0]({
+ type: 'fs.read_response',
+ requestId: 'req-win-path',
+ downloadId: 'dl-win-path',
+ });
+ await waitFor(() => {
+ expect(downloadAttachment).toHaveBeenCalledWith('server-1', 'dl-win-path');
+ });
+ });
+
+ it('keeps adjacent Chinese-punctuated URLs as external links instead of file paths', () => {
+ const events = [
+ makeEvent({
+ type: 'assistant.text',
+ payload: {
+ text: 'https://blog.csdn.net/2502_91125447/article/details/146912737(CSDN博客 - PCDN市场深水区)https://m.c114.com.cn/w16-1296322.html⬇(C114 - PCDN即将成为历史)',
+ streaming: false,
+ },
+ }),
+ ];
+
+ const { container } = render();
+
+ const externalLinks = Array.from(container.querySelectorAll('.chat-external-link')) as HTMLAnchorElement[];
+ expect(externalLinks.map((el) => el.textContent)).toEqual([
+ 'https://blog.csdn.net/2502_91125447/article/details/146912737',
+ 'https://m.c114.com.cn/w16-1296322.html',
+ ]);
+ expect(container.querySelector('.chat-path-link')).toBeNull();
+ expect(container.querySelector('.chat-dl-btn')).toBeNull();
+ });
+
it('renders OpenClaw transport tool rows for realistic sessions_send payloads', () => {
const events = [
makeEvent({
diff --git a/web/test/components/ChatView.test.tsx b/web/test/components/ChatView.test.tsx
index c89806838..93e525a37 100644
--- a/web/test/components/ChatView.test.tsx
+++ b/web/test/components/ChatView.test.tsx
@@ -206,6 +206,58 @@ describe('ChatView', () => {
expect(chatMarkdownRenderSpy.mock.calls.filter(([text]) => text === 'stable block')).toHaveLength(1);
});
+ it('does not render running or idle session states as chat rows', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.textContent).not.toContain('Agent working...');
+ expect(container.textContent).not.toContain('Agent idle');
+ expect(container.textContent).toContain('real message');
+ });
+
+ it('still renders non-live session state entries such as stopped', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.textContent).toContain('Session stopped');
+ });
+
it('restores mobile keyboard scroll position from bottom offset instead of snapping to top', async () => {
const initialEvents = [
{
diff --git a/web/test/components/FileBrowser.test.tsx b/web/test/components/FileBrowser.test.tsx
index f677514ed..8050954f7 100644
--- a/web/test/components/FileBrowser.test.tsx
+++ b/web/test/components/FileBrowser.test.tsx
@@ -962,6 +962,59 @@ describe('FileBrowser', () => {
expect((ws.fsGitDiff as any).mock.calls).toHaveLength(0);
});
+ it('preserves a manual diff tab selection when the same preview path refreshes', async () => {
+ const { ws } = makeWsFactory();
+ const view = render(
+ diff before
',
+ }}
+ onConfirm={vi.fn()}
+ />,
+ );
+
+ const toggle = screen.getByTitle('Toggle diff view');
+ expect(document.querySelector('.fb-diff')).toBeNull();
+ expect(toggle.className).not.toContain('active');
+
+ await act(async () => {
+ fireEvent.click(toggle);
+ });
+
+ expect(document.querySelector('.fb-diff')).not.toBeNull();
+ expect(toggle.className).toContain('active');
+
+ view.rerender(
+ diff after