Merge pull request #1158 from pikasTech/fix/v03-r1-workbench-parity-1151

R1: 补齐 v0.3 Vue Workbench parity foundation
This commit is contained in:
Lyon
2026-06-13 23:48:55 +08:00
committed by GitHub
10 changed files with 630 additions and 18 deletions
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AgentRunProvenance, ChatMessage, TraceEvent } from "../src/types/index.ts";
import { canCancelMessage, canReplayTrace, canRetryMessage, messageTraceId, renderSafeMarkdown, traceEventBody, traceEventLabel, traceIdentityText, visibleTraceEvents } from "../src/components/workbench/message-rendering.ts";
test("R1 markdown rendering keeps structure and strips unsafe HTML", () => {
const html = renderSafeMarkdown("# 标题\n\n- 项目\n\n<script>alert('x')</script>\n\n[link](https://example.com)");
assert.match(html, /<h1><\/h1>/u);
assert.match(html, /<li><\/li>/u);
assert.match(html, /href="https:\/\/example.com"/u);
assert.doesNotMatch(html, /<script|alert/u);
});
test("R1 message actions follow per-message status and trace rules", () => {
const running = agentMessage({ status: "running", traceId: "trc_running", retryInput: "retry me" });
assert.equal(messageTraceId(running), "trc_running");
assert.equal(canCancelMessage(running), true);
assert.equal(canRetryMessage(running), true);
assert.equal(canReplayTrace(running), true);
const completed = agentMessage({ status: "completed", traceId: "trc_done", retryInput: "retry me" });
assert.equal(canCancelMessage(completed), false);
assert.equal(canRetryMessage(completed), false);
assert.equal(canReplayTrace(completed), true);
const failed = agentMessage({ status: "failed", traceId: "trc_failed", retryInput: "retry me" });
assert.equal(canCancelMessage(failed), false);
assert.equal(canRetryMessage(failed), true);
assert.equal(canReplayTrace(failed), true);
const user: ChatMessage = { id: "msg_user", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-01-01T00:00:00.000Z", traceId: "trc_user" };
assert.equal(canReplayTrace(user), false);
});
test("R1 trace rendering hides chunk noise without dropping meaningful events", () => {
const events: TraceEvent[] = [
{ label: "assistant:delta", message: "token" },
{ label: "command:completed", command: "npm test", exitCode: 0, elapsedMs: 42 },
{ type: "assistant_message", text: "final", final: true }
];
const compact = visibleTraceEvents(events, true);
assert.equal(compact.length, 2);
assert.equal(traceEventLabel(compact[0]!), "command:completed");
assert.equal(traceEventBody(compact[0]!), "npm test");
assert.equal(traceEventLabel(compact[1]!), "assistant_message");
assert.equal(traceEventBody(compact[1]!), "final");
assert.equal(visibleTraceEvents(events, false).length, 3);
});
test("R1 status summary keeps the restored 23-row floor", () => {
const message = agentMessage({
status: "failed",
traceId: "trc_summary",
conversationId: "cnv_summary",
sessionId: "ses_summary",
threadId: "thr_summary",
runnerTrace: {
traceId: "trc_summary",
status: "failed",
eventCount: 2,
eventsCompacted: false,
fullTraceLoaded: true,
lastEventLabel: "assistant:completed",
waitingFor: "result",
runnerKind: "agentrun",
sessionMode: "long-lived",
agentRun: {
runId: "run_1",
commandId: "cmd_1",
attemptId: "att_1",
runnerId: "runner_1",
jobName: "job_1",
namespace: "hwlab-v03",
terminalStatus: "failed"
}
},
error: { code: "provider_timeout", category: "provider", providerStatus: 504 }
});
const values = [
message.status,
message.runnerTrace?.status,
message.traceId,
message.conversationId,
message.sessionId,
message.threadId,
message.runnerTrace?.runnerKind,
message.runnerTrace?.sessionMode,
message.runnerTrace?.waitingFor,
message.runnerTrace?.lastEventLabel,
String(message.runnerTrace?.eventCount),
String(message.runnerTrace?.eventsCompacted),
message.runnerTrace?.fullTraceLoaded === true ? "loaded" : "unverified",
asAgentRun(message.runnerTrace?.agentRun)?.runId,
asAgentRun(message.runnerTrace?.agentRun)?.commandId,
asAgentRun(message.runnerTrace?.agentRun)?.attemptId,
asAgentRun(message.runnerTrace?.agentRun)?.runnerId,
asAgentRun(message.runnerTrace?.agentRun)?.jobName,
asAgentRun(message.runnerTrace?.agentRun)?.namespace,
asAgentRun(message.runnerTrace?.agentRun)?.terminalStatus,
message.error?.code,
message.error?.category,
String(message.error?.providerStatus)
];
assert.equal(values.length, 23);
assert.equal(values.every((value) => value != null && value !== ""), true);
assert.match(traceIdentityText(message), /traceId=trc_summary/u);
assert.match(traceIdentityText(message), /sessionId=ses_summary/u);
});
function agentMessage(patch: Partial<ChatMessage>): ChatMessage {
return {
id: "msg_agent",
role: "agent",
title: "Code Agent",
text: "done",
status: "completed",
createdAt: "2026-01-01T00:00:00.000Z",
...patch
} as ChatMessage;
}
function asAgentRun(value: unknown): AgentRunProvenance | null {
return value && typeof value === "object" ? value as AgentRunProvenance : null;
}
@@ -1,21 +1,68 @@
<script setup lang="ts">
import type { RunnerTrace } from "@/types";
import { computed, nextTick, ref, watch } from "vue";
import type { RunnerTrace, TraceEvent } from "@/types";
import { firstNonEmptyString } from "@/utils";
import { traceEventBody, traceEventLabel, traceEventMeta, visibleTraceEvents } from "@/components/workbench/message-rendering";
const props = defineProps<{ trace?: RunnerTrace | null }>();
const listRef = ref<HTMLOListElement | null>(null);
const following = ref(true);
const expanded = ref(true);
const compact = ref(true);
const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
const visibleEvents = computed(() => visibleTraceEvents(events.value, compact.value));
const noiseCount = computed(() => Math.max(0, events.value.length - visibleEvents.value.length));
const lastEvent = computed(() => events.value.at(-1) ?? null);
const lastEventLabel = computed(() => firstNonEmptyString(props.trace?.lastEventLabel, lastEvent.value?.label, lastEvent.value?.type, lastEvent.value?.kind) ?? "未观测");
watch(() => [eventCount.value, props.trace?.status, following.value, expanded.value], async () => {
if (!following.value || !expanded.value) return;
await nextTick();
jumpToBottom();
});
function jumpToBottom(): void {
const list = listRef.value;
if (!list) return;
list.scrollTop = list.scrollHeight;
}
function onScroll(): void {
const list = listRef.value;
if (!list) return;
following.value = list.scrollTop + list.clientHeight >= list.scrollHeight - 24;
}
function eventKey(event: TraceEvent, index: number): string {
return `${event.ts ?? "no-ts"}-${event.label ?? event.type ?? event.kind ?? "event"}-${index}`;
}
defineProps<{ trace?: RunnerTrace | null }>();
</script>
<template>
<section v-if="trace" class="trace-timeline" :data-status="trace.status">
<header>
<section v-if="trace" class="trace-timeline" :data-status="trace.status" :data-following="following ? 'true' : 'false'">
<header class="trace-toolbar">
<button class="trace-toggle" type="button" @click="expanded = !expanded">{{ expanded ? "收起" : "展开" }}</button>
<strong>Trace</strong>
<span>{{ trace.traceId }}</span>
<small>{{ trace.eventCount ?? trace.events?.length ?? 0 }} events</small>
<span class="trace-id">{{ trace.traceId || "pending" }}</span>
<small>{{ visibleEvents.length }}/{{ eventCount }} events</small>
<small>last={{ lastEventLabel }}</small>
<button class="btn btn-secondary btn-sm" type="button" @click="following = !following">{{ following ? "跟随" : "暂停" }}</button>
<button class="btn btn-secondary btn-sm" type="button" @click="jumpToBottom">到底部</button>
<button class="btn btn-secondary btn-sm" type="button" @click="compact = !compact">{{ compact ? "显示全部" : "低噪声" }}</button>
</header>
<ol>
<li v-for="(event, index) in trace.events || []" :key="`${event.ts || index}-${event.label || event.type || event.kind}`">
<code>{{ event.label || event.type || event.kind || "event" }}</code>
<span>{{ event.message || event.text || event.outputSummary || event.status || "已记录" }}</span>
<p v-if="trace.eventsCompacted" class="trace-warning">当前 result 返回被压缩已通过 trace replay 入口请求完整事件完成前不要把压缩窗口当完整 trace</p>
<p v-if="noiseCount > 0 && compact" class="trace-warning">已聚合隐藏 {{ noiseCount }} chunk/delta/token 噪声事件</p>
<ol v-show="expanded" ref="listRef" @scroll="onScroll">
<li v-for="(event, index) in visibleEvents" :key="eventKey(event, index)" :data-event-status="event.status || 'source'">
<code>{{ traceEventLabel(event) }}</code>
<span>{{ traceEventBody(event) }}</span>
<small v-if="traceEventMeta(event)">{{ traceEventMeta(event) }}</small>
</li>
</ol>
<p v-if="expanded && visibleEvents.length === 0" class="trace-empty">trace={{ trace.traceId || "pending" }}等待后端事件</p>
</section>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from "vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import type { AgentRunProvenance, ChatMessage } from "@/types";
import { firstNonEmptyString } from "@/utils";
const props = defineProps<{ message: ChatMessage }>();
const trace = computed(() => props.message.runnerTrace ?? null);
const agentRun = computed(() => asAgentRun(trace.value?.agentRun));
const error = computed(() => props.message.error ?? null);
const eventCount = computed(() => trace.value?.eventCount ?? trace.value?.events?.length ?? 0);
const rows = computed(() => [
{ key: "messageStatus", label: "消息状态", value: props.message.status },
{ key: "traceStatus", label: "Trace 状态", value: firstNonEmptyString(trace.value?.status, trace.value?.traceStatus) },
{ key: "traceId", label: "Trace", value: props.message.traceId ?? trace.value?.traceId },
{ key: "conversationId", label: "Conversation", value: props.message.conversationId },
{ key: "sessionId", label: "Session", value: props.message.sessionId ?? trace.value?.sessionId },
{ key: "threadId", label: "Thread", value: props.message.threadId ?? trace.value?.threadId },
{ key: "runnerKind", label: "Runner", value: trace.value?.runnerKind ?? agentRun.value?.adapter },
{ key: "sessionMode", label: "Session Mode", value: trace.value?.sessionMode ?? agentRun.value?.backendProfile },
{ key: "waitingFor", label: "Waiting For", value: trace.value?.waitingFor },
{ key: "lastEvent", label: "Last Event", value: trace.value?.lastEventLabel },
{ key: "eventCount", label: "Events", value: String(eventCount.value) },
{ key: "eventsCompacted", label: "Compacted", value: trace.value?.eventsCompacted === true ? "true" : "false" },
{ key: "fullTraceLoaded", label: "Full Trace", value: trace.value?.fullTraceLoaded === true ? "loaded" : "unverified" },
{ key: "runId", label: "AgentRun", value: agentRun.value?.runId },
{ key: "commandId", label: "Command", value: agentRun.value?.commandId },
{ key: "attemptId", label: "Attempt", value: agentRun.value?.attemptId },
{ key: "runnerId", label: "Runner ID", value: agentRun.value?.runnerId },
{ key: "jobName", label: "Job", value: agentRun.value?.jobName },
{ key: "namespace", label: "Namespace", value: agentRun.value?.namespace },
{ key: "terminalStatus", label: "Terminal", value: agentRun.value?.terminalStatus },
{ key: "errorCode", label: "Error Code", value: error.value?.code },
{ key: "errorCategory", label: "Error Category", value: error.value?.category },
{ key: "providerStatus", label: "Provider Status", value: error.value?.providerStatus == null ? null : String(error.value.providerStatus) }
].map((row) => ({ ...row, value: row.value == null || row.value === "" ? "未观测" : String(row.value) })));
function asAgentRun(value: unknown): AgentRunProvenance | null {
return value && typeof value === "object" ? value as AgentRunProvenance : null;
}
</script>
<template>
<details v-if="message.role === 'agent'" class="code-agent-status-summary" :open="message.status !== 'completed'">
<summary>
<span>Code Agent 状态摘要</span>
<StatusBadge :status="message.status" />
</summary>
<dl>
<div v-for="row in rows" :key="row.key" class="code-agent-status-row" :data-status-row="row.key">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</details>
</template>
@@ -1,9 +1,16 @@
<script setup lang="ts">
import type { ChatMessage } from "@/types";
import StatusBadge from "@/components/common/StatusBadge.vue";
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
import CodeAgentStatusSummary from "@/components/workbench/CodeAgentStatusSummary.vue";
import MessageActions from "@/components/workbench/MessageActions.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import { useClipboard } from "@/composables/useClipboard";
import { useWorkbenchStore } from "@/stores/workbench";
import { traceIdentityText } from "./message-rendering";
const workbench = useWorkbenchStore();
const clipboard = useClipboard();
</script>
<template>
@@ -13,12 +20,20 @@ const workbench = useWorkbenchStore();
<strong>{{ message.title }}</strong>
<StatusBadge :status="message.status" />
</header>
<p class="message-text">{{ message.text }}</p>
<MessageMarkdown class="message-text" :source="message.text" />
<TraceTimeline :trace="message.runnerTrace" />
<CodeAgentStatusSummary :message="message" />
<MessageActions
:message="message"
@cancel="workbench.cancelAgentMessage"
@retry="workbench.retryAgentMessage"
@replay-trace="workbench.replayAgentTrace"
@copy-trace="(target: ChatMessage) => clipboard.copy(traceIdentityText(target))"
/>
</article>
<article v-if="workbench.messages.length === 0" class="message-card empty-message">
<header><strong>等待会话</strong><StatusBadge status="source" label="SOURCE" /></header>
<p class="message-text">请先显式新建或选择 sessionCode Agent 不会自动滚动新 session 掩盖失败</p>
<MessageMarkdown class="message-text" source="请先显式新建或选择 sessionCode Agent 不会自动滚动新 session 掩盖失败" />
</article>
</section>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from "vue";
import type { ChatMessage } from "@/types";
import { canCancelMessage, canReplayTrace, canRetryMessage, messageTraceId } from "./message-rendering";
const props = defineProps<{ message: ChatMessage }>();
const emit = defineEmits<{
cancel: [message: ChatMessage];
retry: [message: ChatMessage];
replayTrace: [message: ChatMessage];
copyTrace: [message: ChatMessage];
}>();
const traceId = computed(() => messageTraceId(props.message));
const traceHref = computed(() => traceId.value ? `/v1/agent/chat/trace/${encodeURIComponent(traceId.value)}` : "#");
const canCancel = computed(() => canCancelMessage(props.message));
const canRetry = computed(() => canRetryMessage(props.message));
const canReplay = computed(() => canReplayTrace(props.message));
</script>
<template>
<div v-if="message.role === 'agent'" class="message-actions" role="toolbar" aria-label="消息操作">
<button v-if="canCancel" class="btn btn-secondary btn-sm" type="button" @click="emit('cancel', message)">取消</button>
<button v-if="canRetry" class="btn btn-secondary btn-sm" type="button" @click="emit('retry', message)">重试</button>
<button v-if="canReplay" class="btn btn-secondary btn-sm" type="button" @click="emit('replayTrace', message)">回放 Trace</button>
<button v-if="canReplay" class="btn btn-secondary btn-sm" type="button" @click="emit('copyTrace', message)">复制 Trace</button>
<a v-if="canReplay" class="btn btn-secondary btn-sm" :href="traceHref" target="_blank" rel="noreferrer">打开 Trace</a>
<span v-if="message.traceReplayStatus" class="message-action-status">{{ message.traceReplayStatus }}</span>
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { computed } from "vue";
import { renderSafeMarkdown } from "./message-rendering";
const props = defineProps<{ source?: string | null }>();
const html = computed(() => renderSafeMarkdown(props.source));
</script>
<template>
<div v-if="html" class="message-markdown" v-html="html" />
</template>
@@ -0,0 +1,100 @@
import createDOMPurify from "dompurify";
import { marked } from "marked";
import type { ChatMessage, TraceEvent } from "@/types";
import { firstNonEmptyString } from "@/utils";
marked.setOptions({ async: false, breaks: true, gfm: true });
export function renderSafeMarkdown(source: string | null | undefined): string {
const text = source?.trim();
if (!text) return "";
const rendered = marked.parse(text) as string;
return sanitizeHtml(rendered, {
ADD_ATTR: ["target", "rel"],
USE_PROFILES: { html: true }
});
}
interface HtmlSanitizeConfig {
ADD_ATTR?: string[];
USE_PROFILES?: { html?: boolean };
}
interface HtmlSanitizer {
sanitize(dirty: string, config?: HtmlSanitizeConfig): string;
}
type DomPurifyFactory = (targetWindow: Window) => HtmlSanitizer;
type DomPurifyExport = HtmlSanitizer | DomPurifyFactory;
function sanitizeHtml(html: string, config: HtmlSanitizeConfig): string {
const purifierExport = createDOMPurify as unknown as DomPurifyExport;
if (hasSanitize(purifierExport)) return purifierExport.sanitize(html, config);
if (typeof window !== "undefined" && typeof purifierExport === "function") {
const purifier = purifierExport(window);
if (hasSanitize(purifier)) return purifier.sanitize(html, config);
}
return conservativeSanitize(html);
}
function hasSanitize(value: unknown): value is HtmlSanitizer {
if (typeof value !== "function" && (value === null || typeof value !== "object")) return false;
return typeof (value as { sanitize?: unknown }).sanitize === "function";
}
function conservativeSanitize(html: string): string {
return html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, "")
.replace(/\son[a-z]+=("[^"]*"|'[^']*'|[^\s>]+)/giu, "")
.replace(/\s(href|src)=("javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]+)/giu, "");
}
export function messageTraceId(message: ChatMessage): string | null {
return firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
}
export function canCancelMessage(message: ChatMessage): boolean {
return message.role === "agent" && String(message.status ?? "").toLowerCase() === "running" && Boolean(messageTraceId(message));
}
export function canRetryMessage(message: ChatMessage): boolean {
if (message.role !== "agent" || !message.retryInput) return false;
return ["running", "failed", "timeout", "canceled", "cancelled", "blocked"].includes(String(message.status ?? "").toLowerCase());
}
export function canReplayTrace(message: ChatMessage): boolean {
return message.role === "agent" && Boolean(messageTraceId(message));
}
export function traceIdentityText(message: ChatMessage): string {
return [
messageTraceId(message) ? `traceId=${messageTraceId(message)}` : null,
message.conversationId ? `conversationId=${message.conversationId}` : null,
message.sessionId ? `sessionId=${message.sessionId}` : null,
message.threadId ? `threadId=${message.threadId}` : null
].filter((item): item is string => Boolean(item)).join("\n");
}
export function isNoiseTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? event.type ?? event.kind ?? "").toLowerCase();
return label.includes("delta") || label.includes("chunk") || label.includes("token");
}
export function visibleTraceEvents(events: TraceEvent[], compact: boolean): TraceEvent[] {
return compact ? events.filter((event) => !isNoiseTraceEvent(event)) : events;
}
export function traceEventLabel(event: TraceEvent): string {
return firstNonEmptyString(event.label, event.type, event.kind) ?? "event";
}
export function traceEventBody(event: TraceEvent): string {
const body = firstNonEmptyString(event.message, event.text, event.outputSummary, event.stdoutSummary, event.stderrSummary, event.command, event.status);
if (body) return body;
if (event.exitCode != null) return `exitCode=${event.exitCode}`;
return "已记录";
}
export function traceEventMeta(event: TraceEvent): string {
return [event.ts, event.elapsedMs == null ? null : `${event.elapsedMs}ms`, event.exitCode == null ? null : `exit=${event.exitCode}`].filter((item): item is string => Boolean(item)).join(" / ");
}
+52 -3
View File
@@ -119,11 +119,60 @@ export const useWorkbenchStore = defineStore("workbench", () => {
subscribe(traceId, response.data);
}
async function cancelAgentMessage(message: ChatMessage): Promise<void> {
const traceId = message.traceId ?? message.runnerTrace?.traceId;
if (!traceId) return;
await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value, conversationId: message.conversationId ?? activeConversationId.value });
markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" });
if (message.status === "running") chatPending.value = false;
}
async function cancelRunningTrace(): Promise<void> {
const traceId = messages.value.find((message) => message.status === "running" && message.traceId)?.traceId;
if (!traceId) return;
await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: selectedSessionId.value });
markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" });
const message = messages.value.find((item) => item.traceId === traceId && item.role === "agent");
if (message) await cancelAgentMessage(message);
}
async function retryAgentMessage(message: ChatMessage): Promise<void> {
const retryInput = firstNonEmptyString(message.retryInput, messages.value.find((item) => item.role === "user" && item.traceId === message.traceId)?.text);
if (!retryInput) {
if (message.traceId) markMessage(message.traceId, { traceReplayStatus: "没有可重试的上一条输入。" });
return;
}
await submitMessage(retryInput);
}
async function replayAgentTrace(message: ChatMessage): Promise<void> {
const traceId = message.traceId ?? message.runnerTrace?.traceId;
if (!traceId) return;
markMessage(traceId, { traceReplayStatus: "Trace 回放中" });
const result = await api.agent.getAgentChatResult(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}`, codeAgentTimeoutMs.value, () => activityRef.value);
if (!result.ok || !result.data) {
markMessage(traceId, { traceReplayStatus: result.error ?? "Trace 回放失败" });
return;
}
const events = Array.isArray(result.data.events) ? result.data.events : Array.isArray(result.data.traceEvents) ? result.data.traceEvents : [];
const runnerTrace = mergeRunnerTrace(message.runnerTrace, {
...(result.data.runnerTrace ?? {}),
traceId: result.data.traceId ?? traceId,
status: result.data.status ?? result.data.traceStatus ?? message.runnerTrace?.status,
sessionId: result.data.sessionId ?? message.runnerTrace?.sessionId,
threadId: result.data.threadId ?? message.runnerTrace?.threadId,
events,
eventCount: result.data.eventCount ?? events.length,
eventsCompacted: result.data.runnerTrace?.eventsCompacted ?? false,
fullTraceLoaded: true,
traceStatus: result.data.traceStatus,
retention: result.data.retention,
terminalEvidence: result.data.terminalEvidence,
finalResponse: result.data.finalResponse,
traceSummary: result.data.traceSummary,
agentRun: result.data.agentRun,
lastEventLabel: result.data.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type,
updatedAt: new Date().toISOString()
});
markMessage(traceId, { runnerTrace, traceReplayStatus: "Trace 已回放" });
}
function setProviderProfile(value: ProviderProfile): void {
@@ -170,7 +219,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return workspace.value;
}
return { projectId, workspace, conversations, messages, providerProfile, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, refreshConversations, submitMessage, cancelRunningTrace, setProviderProfile, recordActivity };
return { projectId, workspace, conversations, messages, providerProfile, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, recordActivity };
});
function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] {
+179 -3
View File
@@ -304,13 +304,61 @@
gap: 10px;
}
.message-text {
.message-text,
.message-markdown {
margin: 0;
white-space: pre-wrap;
color: #1f2937;
line-height: 1.55;
}
.message-markdown {
display: grid;
gap: 8px;
min-width: 0;
}
.message-markdown > * {
margin: 0;
}
.message-markdown ul,
.message-markdown ol {
display: grid;
gap: 4px;
padding-left: 20px;
}
.message-markdown pre,
.message-markdown code {
border-radius: 6px;
background: #0f172a;
color: #dbeafe;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.message-markdown pre {
max-width: 100%;
overflow: auto;
padding: 10px;
}
.message-markdown code {
padding: 2px 4px;
}
.message-markdown pre code {
padding: 0;
background: transparent;
}
.message-markdown a {
color: #0e7490;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
}
.trace-timeline {
display: grid;
gap: 8px;
@@ -319,6 +367,57 @@
padding: 10px;
}
.trace-toolbar {
flex-wrap: wrap;
}
.trace-toolbar strong,
.trace-id {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trace-id {
max-width: 220px;
color: #475569;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.trace-toggle {
min-height: 28px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: white;
color: #334155;
padding: 0 9px;
font-size: 12px;
font-weight: 700;
}
.trace-warning,
.trace-empty {
margin: 0;
border-radius: 8px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.45;
}
.trace-warning {
border: 1px solid #fde68a;
background: #fffbeb;
color: #92400e;
}
.trace-empty {
border: 1px solid #d8e1eb;
background: white;
color: #64748b;
}
.trace-timeline ol {
display: grid;
max-height: 170px;
@@ -331,12 +430,89 @@
.trace-timeline li {
display: grid;
grid-template-columns: minmax(96px, 160px) minmax(0, 1fr);
grid-template-columns: minmax(96px, 160px) minmax(0, 1fr) auto;
gap: 8px;
color: #475569;
font-size: 12px;
}
.trace-timeline li span,
.trace-timeline li small {
min-width: 0;
overflow-wrap: anywhere;
}
.message-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
border-top: 1px solid #e2e8f0;
padding-top: 10px;
}
.message-actions a {
text-decoration: none;
}
.message-action-status {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.code-agent-status-summary {
border: 1px solid #d8e1eb;
border-radius: 8px;
background: #f8fafc;
}
.code-agent-status-summary summary {
display: flex;
cursor: pointer;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
color: #334155;
font-size: 12px;
font-weight: 800;
}
.code-agent-status-summary dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
margin: 0;
border-top: 1px solid #d8e1eb;
}
.code-agent-status-row {
display: grid;
grid-template-columns: minmax(110px, 0.8fr) minmax(0, 1.2fr);
gap: 8px;
border-bottom: 1px solid #e2e8f0;
padding: 7px 10px;
font-size: 12px;
}
.code-agent-status-row dt,
.code-agent-status-row dd {
min-width: 0;
margin: 0;
}
.code-agent-status-row dt {
color: #64748b;
font-weight: 700;
}
.code-agent-status-row dd {
overflow-wrap: anywhere;
color: #1f2937;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.trace-timeline code,
.mini-log,
.json-panel,
+1
View File
@@ -162,6 +162,7 @@ export interface ChatMessage {
sessionId?: string | null;
threadId?: string | null;
retryInput?: string | null;
traceReplayStatus?: string | null;
runnerTrace?: RunnerTrace | null;
traceEvents?: TraceEvent[];
error?: { code?: string; message?: string; category?: string; providerStatus?: number; [key: string]: unknown } | null;