* feat(chat): prompt navigator list preview with prompt filtering The hover preview is now an interactive scrolling mini-list of prompts: rows render as bordered two-line cards, the highlighted row stays inside a center dead zone and the list glides only near the window edges, wheel steps the highlight, and the panel stays open when the pointer moves into it so a click can be corrected inside the list. Rail entries are filtered to real prompts: previews are built from normalized user display parts (synthetic context stripped), fully synthetic user messages are excluded, and shell-mode messages show their extracted command via the shared shell bridge helpers. * fix(chat): render shell command status transitions The injected /shell text part carries live state in shellAction, which the render-relevant part comparator ignored — a running→completed update reached the store but never re-rendered the message row until the next send. Compare shellAction command/output/status for text parts. * fix(sync): stream shell bridge part updates while running Streaming suspension keeps part updates out of the static message records while an assistant message streams, relying on the live streaming-tail path to render it. Shell-mode bridge messages are hidden from the timeline and rendered inside the user row, so they have no live path — suspension froze their output chunks and left the card without a Show output action until the run finished. Exempt shell bridges (single bash tool part parented to a synthetic shell-marker user message) from suspension; their updates arrive at command-output pace, not delta pace. * feat(chat): syntax-highlight shell command card Render the shell-mode command and its output through the shared WorkerHighlightedCode (Shiki) with bash grammar, matching the bash tool part presentation, instead of plain pre blocks.
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
|
import type { TurnActivityGroup, TurnActivityRecord, TurnChangedFile, TurnDiffStats, TurnGroupingContext } from '../lib/turns/types';
|
|
|
|
type MessageRecord = {
|
|
info: Message;
|
|
parts: Part[];
|
|
};
|
|
|
|
const readPartId = (part: Part | undefined): string | null => {
|
|
if (!part) return null;
|
|
const candidate = (part as { id?: unknown }).id;
|
|
return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
|
|
};
|
|
|
|
const readToolStatus = (part: Part | undefined): string | null => {
|
|
const status = (part as { state?: { status?: unknown } } | undefined)?.state?.status;
|
|
return typeof status === 'string' ? status : null;
|
|
};
|
|
|
|
const readToolStateRef = (part: Part | undefined): unknown => {
|
|
return (part as { state?: unknown } | undefined)?.state;
|
|
};
|
|
|
|
const readPartMetadataRef = (part: Part | undefined): unknown => {
|
|
return (part as { metadata?: unknown } | undefined)?.metadata;
|
|
};
|
|
|
|
const readPartTime = (part: Part | undefined) => {
|
|
const time = (part as { time?: { start?: unknown; end?: unknown } } | undefined)?.time;
|
|
return {
|
|
start: typeof time?.start === 'number' ? time.start : null,
|
|
end: typeof time?.end === 'number' ? time.end : null,
|
|
};
|
|
};
|
|
|
|
const readPartText = (part: Part | undefined): string => {
|
|
const candidate = part as { text?: unknown; content?: unknown; value?: unknown } | undefined;
|
|
if (!candidate) return '';
|
|
const text = typeof candidate.text === 'string' ? candidate.text : '';
|
|
const content = typeof candidate.content === 'string' ? candidate.content : '';
|
|
const value = typeof candidate.value === 'string' ? candidate.value : '';
|
|
return [text, content, value].reduce((best, next) => (next.length > best.length ? next : best), '');
|
|
};
|
|
|
|
export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolean => {
|
|
if (left === right) return true;
|
|
if (left.length !== right.length) return false;
|
|
|
|
for (let index = 0; index < left.length; index += 1) {
|
|
const leftPart = left[index];
|
|
const rightPart = right[index];
|
|
|
|
if (leftPart.type !== rightPart.type) {
|
|
return false;
|
|
}
|
|
|
|
const leftId = readPartId(leftPart);
|
|
const rightId = readPartId(rightPart);
|
|
if (leftId !== rightId) {
|
|
return false;
|
|
}
|
|
|
|
if (leftPart.type === 'tool') {
|
|
if (readToolStateRef(leftPart) !== readToolStateRef(rightPart)) {
|
|
return false;
|
|
}
|
|
if (readPartMetadataRef(leftPart) !== readPartMetadataRef(rightPart)) {
|
|
return false;
|
|
}
|
|
if (readToolStatus(leftPart) !== readToolStatus(rightPart)) {
|
|
return false;
|
|
}
|
|
const leftTime = readPartTime(leftPart);
|
|
const rightTime = readPartTime(rightPart);
|
|
if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) {
|
|
return false;
|
|
}
|
|
const leftTool = (leftPart as { tool?: unknown }).tool;
|
|
const rightTool = (rightPart as { tool?: unknown }).tool;
|
|
if (leftTool !== rightTool) {
|
|
return false;
|
|
}
|
|
// Compare tool output so streaming updates (e.g., todo list progress)
|
|
// trigger re-renders when the output payload changes.
|
|
const leftOutput = (leftPart as { output?: unknown }).output;
|
|
const rightOutput = (rightPart as { output?: unknown }).output;
|
|
if (leftOutput !== rightOutput) {
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const leftTime = readPartTime(leftPart);
|
|
const rightTime = readPartTime(rightPart);
|
|
if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) {
|
|
return false;
|
|
}
|
|
|
|
if (leftPart.type === 'text' || leftPart.type === 'reasoning') {
|
|
if (readPartText(leftPart) !== readPartText(rightPart)) {
|
|
return false;
|
|
}
|
|
// Shell-mode user messages carry their live command state in an
|
|
// injected `shellAction` payload on a synthetic text part; without
|
|
// comparing it, a running→completed transition never re-renders.
|
|
const leftShell = (leftPart as { shellAction?: { command?: unknown; output?: unknown; status?: unknown } }).shellAction;
|
|
const rightShell = (rightPart as { shellAction?: { command?: unknown; output?: unknown; status?: unknown } }).shellAction;
|
|
if (leftShell || rightShell) {
|
|
if (leftShell?.command !== rightShell?.command
|
|
|| leftShell?.output !== rightShell?.output
|
|
|| leftShell?.status !== rightShell?.status) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => {
|
|
if (left === right) return true;
|
|
|
|
return left.id === right.id
|
|
&& left.role === right.role
|
|
&& left.sessionID === right.sessionID
|
|
&& (left as { finish?: unknown }).finish === (right as { finish?: unknown }).finish
|
|
&& (left as { status?: unknown }).status === (right as { status?: unknown }).status
|
|
&& (left as { mode?: unknown }).mode === (right as { mode?: unknown }).mode
|
|
&& (left as { agent?: unknown }).agent === (right as { agent?: unknown }).agent
|
|
&& (left as { providerID?: unknown }).providerID === (right as { providerID?: unknown }).providerID
|
|
&& (left as { modelID?: unknown }).modelID === (right as { modelID?: unknown }).modelID
|
|
&& (left as { variant?: unknown }).variant === (right as { variant?: unknown }).variant
|
|
&& (left as { clientRole?: unknown }).clientRole === (right as { clientRole?: unknown }).clientRole
|
|
&& (left as { userMessageMarker?: unknown }).userMessageMarker === (right as { userMessageMarker?: unknown }).userMessageMarker
|
|
&& ((left as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null)
|
|
&& ((left as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null);
|
|
};
|
|
|
|
export const areRenderRelevantMessagesEqual = (left: MessageRecord, right: MessageRecord): boolean => {
|
|
return areRenderRelevantMessageInfoEqual(left.info, right.info) && areRenderRelevantPartsEqual(left.parts, right.parts);
|
|
};
|
|
|
|
export const areOptionalRenderRelevantMessagesEqual = (left?: MessageRecord, right?: MessageRecord): boolean => {
|
|
if (!left || !right) {
|
|
return left === right;
|
|
}
|
|
return areRenderRelevantMessagesEqual(left, right);
|
|
};
|
|
|
|
const areTurnDiffStatsEqual = (left?: TurnDiffStats, right?: TurnDiffStats): boolean => {
|
|
if (!left || !right) {
|
|
return left === right;
|
|
}
|
|
|
|
return left.additions === right.additions
|
|
&& left.deletions === right.deletions
|
|
&& left.files === right.files;
|
|
};
|
|
|
|
const areTurnChangedFilesEqual = (left?: TurnChangedFile[], right?: TurnChangedFile[]): boolean => {
|
|
if (left === right) return true;
|
|
if (!left || !right) return !left && !right;
|
|
if (left.length !== right.length) return false;
|
|
for (let index = 0; index < left.length; index += 1) {
|
|
const leftFile = left[index];
|
|
const rightFile = right[index];
|
|
if (
|
|
leftFile.file !== rightFile.file
|
|
|| leftFile.additions !== rightFile.additions
|
|
|| leftFile.deletions !== rightFile.deletions
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const areTurnActivityRecordsEqual = (left: TurnActivityRecord, right: TurnActivityRecord): boolean => {
|
|
return left.id === right.id
|
|
&& left.messageId === right.messageId
|
|
&& left.kind === right.kind
|
|
&& left.partIndex === right.partIndex
|
|
&& left.endedAt === right.endedAt
|
|
&& areRenderRelevantPartsEqual([left.part], [right.part]);
|
|
};
|
|
|
|
const areRelevantActivityPartsEqual = (
|
|
left: TurnActivityRecord[] | undefined,
|
|
right: TurnActivityRecord[] | undefined,
|
|
messageId: string,
|
|
): boolean => {
|
|
let leftIndex = 0;
|
|
let rightIndex = 0;
|
|
|
|
while (true) {
|
|
while (leftIndex < (left?.length ?? 0) && left?.[leftIndex]?.messageId !== messageId) {
|
|
leftIndex += 1;
|
|
}
|
|
while (rightIndex < (right?.length ?? 0) && right?.[rightIndex]?.messageId !== messageId) {
|
|
rightIndex += 1;
|
|
}
|
|
|
|
const leftRecord = left?.[leftIndex];
|
|
const rightRecord = right?.[rightIndex];
|
|
|
|
if (!leftRecord || !rightRecord) {
|
|
return leftRecord === rightRecord;
|
|
}
|
|
|
|
if (!areTurnActivityRecordsEqual(leftRecord, rightRecord)) {
|
|
return false;
|
|
}
|
|
|
|
leftIndex += 1;
|
|
rightIndex += 1;
|
|
}
|
|
};
|
|
|
|
const areTurnActivityGroupsEqual = (left: TurnActivityGroup, right: TurnActivityGroup): boolean => {
|
|
if (left.id !== right.id || left.anchorMessageId !== right.anchorMessageId || left.afterToolPartId !== right.afterToolPartId) {
|
|
return false;
|
|
}
|
|
|
|
if (left.parts.length !== right.parts.length) {
|
|
return false;
|
|
}
|
|
|
|
for (let index = 0; index < left.parts.length; index += 1) {
|
|
if (!areTurnActivityRecordsEqual(left.parts[index], right.parts[index])) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const hasRelevantActivitySegments = (segments: TurnActivityGroup[] | undefined, messageId: string): boolean => {
|
|
return Boolean(segments?.some((segment) => segment.anchorMessageId === messageId));
|
|
};
|
|
|
|
const areRelevantActivitySegmentsEqual = (
|
|
left: TurnActivityGroup[] | undefined,
|
|
right: TurnActivityGroup[] | undefined,
|
|
messageId: string,
|
|
): boolean => {
|
|
let leftIndex = 0;
|
|
let rightIndex = 0;
|
|
|
|
while (true) {
|
|
while (leftIndex < (left?.length ?? 0) && left?.[leftIndex]?.anchorMessageId !== messageId) {
|
|
leftIndex += 1;
|
|
}
|
|
while (rightIndex < (right?.length ?? 0) && right?.[rightIndex]?.anchorMessageId !== messageId) {
|
|
rightIndex += 1;
|
|
}
|
|
|
|
const leftSegment = left?.[leftIndex];
|
|
const rightSegment = right?.[rightIndex];
|
|
|
|
if (!leftSegment || !rightSegment) {
|
|
return leftSegment === rightSegment;
|
|
}
|
|
|
|
if (!areTurnActivityGroupsEqual(leftSegment, rightSegment)) {
|
|
return false;
|
|
}
|
|
|
|
leftIndex += 1;
|
|
rightIndex += 1;
|
|
}
|
|
};
|
|
|
|
export const areRelevantTurnGroupingContextsEqual = (
|
|
left: TurnGroupingContext | undefined,
|
|
right: TurnGroupingContext | undefined,
|
|
messageId: string,
|
|
isUserMessage: boolean,
|
|
): boolean => {
|
|
if (left === right) {
|
|
return true;
|
|
}
|
|
|
|
if (!left || !right) {
|
|
return left === right;
|
|
}
|
|
|
|
if (isUserMessage) {
|
|
return true;
|
|
}
|
|
|
|
if (left.turnId !== right.turnId) return false;
|
|
if (left.isFirstAssistantInTurn !== right.isFirstAssistantInTurn) return false;
|
|
if (left.isLastAssistantInTurn !== right.isLastAssistantInTurn) return false;
|
|
if (left.isLatestTurn !== right.isLatestTurn) return false;
|
|
if (left.isWorking !== right.isWorking) return false;
|
|
if (left.hasTools !== right.hasTools) return false;
|
|
if (left.hasReasoning !== right.hasReasoning) return false;
|
|
if (left.userMessageCreatedAt !== right.userMessageCreatedAt) return false;
|
|
if (left.userMessageVariant !== right.userMessageVariant) return false;
|
|
|
|
const headerRelevant = left.headerMessageId === messageId || right.headerMessageId === messageId;
|
|
if (headerRelevant && left.headerMessageId !== right.headerMessageId) {
|
|
return false;
|
|
}
|
|
|
|
const ownerRelevant = left.activityOwnerMessageId === messageId || right.activityOwnerMessageId === messageId;
|
|
if (ownerRelevant && left.activityOwnerMessageId !== right.activityOwnerMessageId) {
|
|
return false;
|
|
}
|
|
|
|
if (!areRelevantActivityPartsEqual(left.activityParts, right.activityParts, messageId)) {
|
|
return false;
|
|
}
|
|
|
|
if (!areRelevantActivitySegmentsEqual(left.activityGroupSegments, right.activityGroupSegments, messageId)) {
|
|
return false;
|
|
}
|
|
|
|
const segmentsRelevant = hasRelevantActivitySegments(left.activityGroupSegments, messageId)
|
|
|| hasRelevantActivitySegments(right.activityGroupSegments, messageId);
|
|
|
|
if ((ownerRelevant || segmentsRelevant) && left.isGroupExpanded !== right.isGroupExpanded) {
|
|
return false;
|
|
}
|
|
|
|
if ((ownerRelevant || segmentsRelevant) && left.toggleGroup !== right.toggleGroup) {
|
|
return false;
|
|
}
|
|
|
|
if ((ownerRelevant || segmentsRelevant) && !areTurnDiffStatsEqual(left.diffStats, right.diffStats)) {
|
|
return false;
|
|
}
|
|
|
|
if ((ownerRelevant || segmentsRelevant || left.isLastAssistantInTurn || right.isLastAssistantInTurn) && !areTurnChangedFilesEqual(left.changedFiles, right.changedFiles)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|