diff --git a/web/packages/superagent-ui/src/PendingQuestionBatch.test.tsx b/web/packages/superagent-ui/src/PendingQuestionBatch.test.tsx
index a661d4d..feafed5 100644
--- a/web/packages/superagent-ui/src/PendingQuestionBatch.test.tsx
+++ b/web/packages/superagent-ui/src/PendingQuestionBatch.test.tsx
@@ -101,6 +101,24 @@ describe("PendingQuestionBatch", () => {
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
expect(onSubmit).not.toHaveBeenCalled();
});
+
+ it("exposes the full navigation header when its label is truncated", () => {
+ const header = "Deployment region and compliance boundary";
+ render(
+
,
+ );
+
+ const tab = screen.getByRole("button", { name: header });
+ expect(tab).toHaveAttribute("title", header);
+ expect(tab.querySelector(".sa-question-tab-label")).toHaveTextContent(
+ header,
+ );
+ });
});
function question(
diff --git a/web/packages/superagent-ui/src/PendingQuestionBatch.tsx b/web/packages/superagent-ui/src/PendingQuestionBatch.tsx
index fbf20cf..e7c2b14 100644
--- a/web/packages/superagent-ui/src/PendingQuestionBatch.tsx
+++ b/web/packages/superagent-ui/src/PendingQuestionBatch.tsx
@@ -119,12 +119,13 @@ export function PendingQuestionBatch({
: "sa-question-button sa-question-button--secondary secondary"
}
aria-current={index === currentIndex ? "step" : undefined}
+ title={candidate.header}
key={candidate.id}
onClick={() => {
setCurrentIndex(index);
}}
>
- {candidate.header}
+
{candidate.header}
{questionAnswer(drafts[candidate.id]) !== "" && (
{
);
});
+ it("places historical activity in its associated or nearest turn", () => {
+ const messages = [
+ message(1, "user", "first"),
+ message(2, "assistant", "first answer"),
+ message(10, "user", "second"),
+ message(11, "assistant", "second answer"),
+ ];
+ const activities: TimelineActivityEntry[] = [
+ {
+ resumeToken: "associated",
+ source: "activity",
+ createdAt: iso(3_000),
+ value: {
+ kind: "compacted",
+ message: "Compacted through message 2.",
+ messageSequence: 2,
+ },
+ },
+ {
+ resumeToken: "legacy",
+ source: "activity",
+ createdAt: iso(9_000),
+ value: {
+ kind: "compacted",
+ message: "Legacy compaction event.",
+ messageSequence: null,
+ },
+ },
+ ];
+
+ const view = buildConversationPresentation({ messages, activities });
+
+ expect(view.earlierActivity).toHaveLength(0);
+ expect(required(view.turns[0]).historicalActivities).toEqual([
+ activities[0],
+ ]);
+ expect(required(view.turns[1]).historicalActivities).toEqual([
+ activities[1],
+ ]);
+ });
+
it("keeps current and resolved waits separate from tool execution", () => {
const messages = [
message(1, "user", "build"),
@@ -350,6 +391,77 @@ describe("conversation presentation", () => {
expect(required(presentation.turns[0]).tools).toHaveLength(0);
});
+ it("marks a rejected input request as failed history", () => {
+ const messages = [
+ message(1, "user", "start"),
+ message(2, "assistant", "", {
+ toolCalls: [
+ {
+ id: "question-failed",
+ name: "request_user_input",
+ argumentsJson: JSON.stringify({
+ questions: [
+ {
+ id: "scope",
+ header: "A header rejected by the runtime",
+ question: "Full fix?",
+ options: [
+ { label: "Yes", description: "Implement everything" },
+ { label: "No", description: "Stop here" },
+ ],
+ },
+ ],
+ }),
+ },
+ ],
+ }),
+ message(3, "tool", '{"status":"failed","error":"invalid_user_input"}', {
+ toolCallId: "question-failed",
+ toolName: "request_user_input",
+ }),
+ ];
+
+ const question = required(
+ required(buildConversationPresentation({ messages }).turns[0])
+ .questions[0],
+ );
+ expect(question.status).toBe("failed");
+ expect(question.questions[0]?.header).toBe(
+ "A header rejected by the runtime",
+ );
+ });
+
+ it("marks legacy rejected input without a tool result as failed", () => {
+ const messages = [
+ message(1, "user", "start"),
+ message(2, "assistant", "", {
+ toolCalls: [
+ {
+ id: "question-rejected-before-execution",
+ name: "request_user_input",
+ argumentsJson: JSON.stringify({
+ questions: [
+ {
+ id: "scope",
+ header: "Scope",
+ question: "Full fix?",
+ options: [],
+ },
+ ],
+ }),
+ },
+ ],
+ }),
+ message(3, "assistant", "Please clarify the scope."),
+ ];
+
+ const question = required(
+ required(buildConversationPresentation({ messages }).turns[0])
+ .questions[0],
+ );
+ expect(question.status).toBe("failed");
+ });
+
it("formats durations and unions parallel work safely", () => {
expect(formatDuration(500)).toBe("<1s");
expect(formatDuration(12_000)).toBe("12s");
diff --git a/web/packages/superagent-ui/src/conversationPresentation.ts b/web/packages/superagent-ui/src/conversationPresentation.ts
index de311ae..fe2c8a9 100644
--- a/web/packages/superagent-ui/src/conversationPresentation.ts
+++ b/web/packages/superagent-ui/src/conversationPresentation.ts
@@ -72,7 +72,7 @@ export interface ConversationQuestionItem {
key: string;
callId: string;
questions: ConversationQuestion[];
- status: "pending" | "answered" | "cancelled" | "historical";
+ status: "pending" | "answered" | "cancelled" | "failed";
answer: TimelineSequencedMessage | null;
isMalformed: boolean;
}
@@ -86,6 +86,7 @@ export interface ConversationTurn {
operations: ConversationWorkItem[];
questions: ConversationQuestionItem[];
activities: TimelineActivityEntry[];
+ historicalActivities: TimelineActivityEntry[];
durationMs: number | null;
workDurationMs: number | null;
waitDurationMs: number | null;
@@ -109,6 +110,11 @@ export interface ConversationPresentationInput {
const MODEL_TERMINAL = new Set(["model_completed", "model_failed"]);
const MODEL_LIFECYCLE = new Set(["model_started", ...MODEL_TERMINAL]);
+const HISTORICAL_ACTIVITY = new Set([
+ "compacted",
+ "compaction_failed",
+ "snapshot_required",
+]);
function timestamp(value: string | null | undefined): number | null {
const parsed = Date.parse(value ?? "");
@@ -196,6 +202,7 @@ function groupDurableTurns(messages: readonly TimelineSequencedMessage[]) {
operations: [],
questions: [],
activities: [],
+ historicalActivities: [],
durationMs: null,
workDurationMs: null,
waitDurationMs: null,
@@ -253,13 +260,18 @@ export function buildConversationPresentation(
values.push(activity);
activityBySequence.set(activity.value.messageSequence, values);
}
- const turn = activity.value.callId
+ let turn = activity.value.callId
? callToTurn.get(activity.value.callId)
: activity.value.messageSequence === null
? undefined
: sequenceToTurn.get(activity.value.messageSequence);
- if (turn) turn.activities.push(activity);
- else if (!MODEL_LIFECYCLE.has(activity.value.kind))
+ if (turn === undefined && HISTORICAL_ACTIVITY.has(activity.value.kind))
+ turn = nearestTurnByTime(turns, activity.createdAt);
+ if (turn) {
+ turn.activities.push(activity);
+ if (HISTORICAL_ACTIVITY.has(activity.value.kind))
+ turn.historicalActivities.push(activity);
+ } else if (!MODEL_LIFECYCLE.has(activity.value.kind))
earlierActivity.push(activity);
}
@@ -278,6 +290,7 @@ export function buildConversationPresentation(
operations: [],
questions: [],
activities: [],
+ historicalActivities: [],
durationMs: null,
workDurationMs: null,
waitDurationMs: null,
@@ -376,6 +389,7 @@ export function buildConversationPresentation(
buildQuestionItem(
call,
messages,
+ resultByCall.get(call.id) ?? null,
activityByCall,
pendingWaitByCall,
),
@@ -618,6 +632,35 @@ export function buildConversationPresentation(
return { turns, earlierActivity };
}
+function nearestTurnByTime(
+ turns: readonly ConversationTurn[],
+ createdAt: string,
+): ConversationTurn | undefined {
+ const activityTime = timestamp(createdAt);
+ if (activityTime === null) return undefined;
+ let nearest: ConversationTurn | undefined;
+ let nearestDistance = Number.POSITIVE_INFINITY;
+ for (const turn of turns) {
+ const times = turn.messages
+ .map((entry) => timestamp(entry.message.createdAt))
+ .filter((value): value is number => value !== null);
+ if (times.length === 0) continue;
+ const start = Math.min(...times);
+ const end = Math.max(...times);
+ const distance =
+ activityTime < start
+ ? start - activityTime
+ : activityTime > end
+ ? activityTime - end
+ : 0;
+ if (distance < nearestDistance) {
+ nearest = turn;
+ nearestDistance = distance;
+ }
+ }
+ return nearest;
+}
+
function maximumAttempt(
activities: readonly TimelineActivityEntry[],
): number | null {
@@ -667,6 +710,7 @@ function toolStartedAt(
function buildQuestionItem(
call: TimelineToolCall,
messages: readonly TimelineSequencedMessage[],
+ result: TimelineSequencedMessage | null,
activities: ReadonlyMap,
pending: ReadonlyMap,
): ConversationQuestionItem {
@@ -677,22 +721,44 @@ function buildQuestionItem(
const cancelled = (activities.get(call.id) ?? []).some(
(entry) => entry.value.kind === "user_input_cancelled",
);
+ const failed =
+ isFailedToolResult(result) ||
+ (activities.get(call.id) ?? []).some(
+ (entry) => entry.value.kind === "tool_failed",
+ );
return {
key: `question-${call.id}`,
callId: call.id,
questions: parsed.questions,
status: answer
? "answered"
- : cancelled
- ? "cancelled"
- : pending.has(call.id)
- ? "pending"
- : "historical",
+ : failed
+ ? "failed"
+ : cancelled
+ ? "cancelled"
+ : pending.has(call.id)
+ ? "pending"
+ : "failed",
answer,
isMalformed: parsed.isMalformed,
};
}
+function isFailedToolResult(result: TimelineSequencedMessage | null): boolean {
+ if (result === null) return false;
+ try {
+ const value: unknown = JSON.parse(result.message.content);
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ "status" in value &&
+ value.status === "failed"
+ );
+ } catch {
+ return false;
+ }
+}
+
function parseQuestions(argumentsJson: string): {
questions: ConversationQuestion[];
isMalformed: boolean;
diff --git a/web/packages/superagent-ui/styles.css b/web/packages/superagent-ui/styles.css
index 7879d60..2b07dc0 100644
--- a/web/packages/superagent-ui/styles.css
+++ b/web/packages/superagent-ui/styles.css
@@ -346,9 +346,20 @@
}
.sa-question-tabs .sa-question-button {
+ display: flex;
+ min-width: 0;
+ max-width: min(12rem, 100%);
+ align-items: center;
padding: 6px 9px;
}
+.sa-question-tab-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.sa-question-button--active,
.sa-question-option--selected {
color: var(--sa-button-text-color, white);
@@ -836,13 +847,73 @@
gap: 16px;
}
-.sa-conversation-user {
+.sa-conversation-message {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ width: 100%;
+ outline: none;
+}
+
+.sa-conversation-message__content {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+}
+
+.sa-conversation-message.sa-conversation-message--user {
+ align-items: flex-end;
align-self: flex-end;
- max-width: 92%;
+ width: fit-content;
+ max-width: min(76%, 42rem);
+}
+
+.sa-conversation-message--user .sa-conversation-message__content {
+ align-items: flex-end;
+ width: 100%;
+}
+
+.sa-conversation-message--user .sa-conversation-message__content > * {
+ width: fit-content;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ border: 1px solid var(--sa-user-message-border, #d9dfd2);
+ border-radius: 14px 14px 4px 14px;
+ padding: 10px 13px;
+ background: var(--sa-user-message-background, #edf2e8);
+}
+
+.sa-conversation-message--assistant {
+ align-items: flex-start;
+}
+
+.sa-conversation-message--assistant .sa-conversation-message__content {
+ width: 100%;
+}
+
+.sa-message-timestamp {
+ height: 1.25em;
+ margin-top: 4px;
+ overflow: hidden;
+ color: var(--sa-faint-color, #8b93a7);
+ font-size: 11px;
+ line-height: 1.25;
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+
+.sa-conversation-message:hover .sa-message-timestamp,
+.sa-conversation-message:focus-within .sa-message-timestamp {
+ opacity: 1;
+}
+
+.sa-conversation-message--user .sa-message-timestamp {
+ align-self: flex-end;
+}
+
+.sa-conversation-user {
white-space: pre-wrap;
- border-radius: 10px 10px 3px 10px;
- padding: 9px 12px;
- background: var(--sa-raised-background, #eef2e8);
+ overflow-wrap: anywhere;
}
.sa-work-log,
@@ -856,6 +927,12 @@
background: var(--sa-panel-background, #fff);
}
+.sa-work-log {
+ color: var(--sa-muted-color, #667066);
+ font-size: var(--sa-work-log-font-size, 12px);
+ line-height: 1.45;
+}
+
.sa-work-log--failed {
border-color: var(--sa-danger-color, #9b2c25);
}
@@ -875,6 +952,18 @@
padding: 10px 12px;
}
+.sa-work-log > summary > strong {
+ color: var(--sa-text-color, #1d2530);
+ font-size: inherit;
+ font-weight: 650;
+}
+
+.sa-work-log
+ > summary
+ > span:not(.sa-work-log__durations):not(.sa-running-indicator) {
+ font-size: 11px;
+}
+
.sa-work-log__durations {
margin-left: auto;
display: flex;
@@ -905,6 +994,9 @@
border: 1px solid var(--sa-border, #d8dfd2);
border-radius: 0.75rem;
background: var(--sa-surface, #fff);
+ color: var(--sa-muted-color, #667066);
+ font-size: var(--sa-question-font-size, 12px);
+ line-height: 1.5;
}
.sa-question-card__heading {
@@ -912,6 +1004,12 @@
align-items: center;
justify-content: space-between;
gap: 0.75rem;
+ color: var(--sa-faint-color, #8b93a7);
+ font-size: 11px;
+}
+
+.sa-question-card__heading > strong {
+ font-weight: 650;
}
.sa-question-card section + section {
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index 991e1f8..41fc68e 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -631,7 +631,7 @@ describe("App", () => {
const history = await screen.findByLabelText("Conversation history");
await within(history).findByText("Running local-tools.search.");
expect(
- within(history).getByText(/Earlier activity · 3 recovered events/),
+ within(history).getByText(/Historical activity · 3 events/),
).toBeInTheDocument();
expect(
within(history).getByText("Calling mock/reliable."),
diff --git a/web/src/ConversationView.tsx b/web/src/ConversationView.tsx
index 7fb8406..e041e8a 100644
--- a/web/src/ConversationView.tsx
+++ b/web/src/ConversationView.tsx
@@ -316,24 +316,20 @@ export function ConversationView({
) {
return null;
}
- return (
-
-
- {messageRoleLabel(message.role)}
-
-
- {message.content !== "" &&
- (message.role === MessageRole.ASSISTANT ? (
-
- ) : (
- {message.content}
- ))}
-
+
+
+ ) : (
+