merge: integrate remote main
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-09-03 17:19:21 +08:00
58 changed files with 3989 additions and 199 deletions

View File

@@ -17,6 +17,7 @@ import {
abortCodingConversation,
forkCodingConversation,
} from '@/lib/coding-conversations';
import { subscribeHostEvent } from '@/lib/host-events';
import {
codingConversationStore,
selectCodingConversationDraft,
@@ -148,6 +149,7 @@ export function CodingChatPanel({
>({});
const appliedNavigationDraftRef = useRef<string | null>(null);
const automaticCreationKeyRef = useRef<string | null>(null);
const selectedConversationContextRef = useRef<string | null>(null);
const attachmentsRef = useRef(attachmentsByDraftKey);
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
@@ -183,6 +185,11 @@ export function CodingChatPanel({
attachmentsRef.current = attachmentsByDraftKey;
const selectProjectConversation = useCallback((projectId: string, conversationId: string) => {
selectedConversationContextRef.current = `${projectId}:${conversationId}`;
return selectConversation(conversationId);
}, [selectConversation]);
const selectDraft = useCallback((state: CodingConversationStoreState) => (
targetConversationId
? selectCodingConversationDraft(targetConversationId)(state)
@@ -236,6 +243,24 @@ export function CodingChatPanel({
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
useEffect(() => subscribeHostEvent('lifecycle:sleep', () => {
disconnectEvents();
}), [disconnectEvents]);
useEffect(() => {
if (!targetConversationId) return undefined;
const refreshVisibleConversation = () => {
if (document.visibilityState !== 'visible') return;
void selectConversation(targetConversationId).catch(() => undefined);
};
window.addEventListener('focus', refreshVisibleConversation);
document.addEventListener('visibilitychange', refreshVisibleConversation);
return () => {
window.removeEventListener('focus', refreshVisibleConversation);
document.removeEventListener('visibilitychange', refreshVisibleConversation);
};
}, [selectConversation, targetConversationId]);
useEffect(() => () => {
for (const attachments of Object.values(attachmentsRef.current)) {
for (const attachment of attachments) URL.revokeObjectURL(attachment.previewUrl);
@@ -251,15 +276,23 @@ export function CodingChatPanel({
useEffect(() => {
if (!activeProject || !selectedAgent) {
selectedConversationContextRef.current = null;
clearConversationSelection();
return;
}
if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) return;
if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) {
const selectionContext = `${activeProject.id}:${selectedConversation.id}`;
if (selectedConversationContextRef.current !== selectionContext) {
void selectProjectConversation(activeProject.id, selectedConversation.id)
.catch(() => undefined);
}
return;
}
const next = newestConversation(conversations, selectedAgent.id);
if (next) {
markConversationUnread(next.id, false);
if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined);
void selectConversation(next.id).catch(() => undefined);
void selectProjectConversation(activeProject.id, next.id).catch(() => undefined);
return;
}
const creationKey = `${activeProject.id}:${selectedAgent.id}`;
@@ -271,7 +304,8 @@ export function CodingChatPanel({
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === activeProject.id
&& current.selectedAgentId === selectedAgent.id) {
void selectConversation(conversation.id).catch(() => undefined);
void selectProjectConversation(activeProject.id, conversation.id)
.catch(() => undefined);
}
})
.catch(() => undefined);
@@ -283,7 +317,7 @@ export function CodingChatPanel({
markConversationUnread,
patchConversation,
primeConversation,
selectConversation,
selectProjectConversation,
selectedAgent,
selectedConversation,
]);
@@ -332,8 +366,8 @@ export function CodingChatPanel({
if (conversation.unread) {
void patchConversation(conversation.id, { unread: false }).catch(() => undefined);
}
void selectConversation(conversation.id).catch(() => undefined);
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectConversation]);
void selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined);
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectProjectConversation]);
const handleCreateConversation = useCallback(async () => {
if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return;
@@ -344,14 +378,14 @@ export function CodingChatPanel({
primeConversation(createLocalConversationSnapshot(projectId, conversation));
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === projectId && current.selectedAgentId === agentId) {
await selectConversation(conversation.id).catch(() => undefined);
await selectProjectConversation(projectId, conversation.id).catch(() => undefined);
}
}, [
activeProject,
createConversation,
creatingAgentIds,
primeConversation,
selectConversation,
selectProjectConversation,
selectedAgent,
]);
@@ -369,9 +403,9 @@ export function CodingChatPanel({
if (workspace.activeProjectId === sourceProjectId
&& workspace.selectedAgentId === sourceAgentId
&& selectedId === sourceConversationId) {
await selectConversation(forked.id);
await selectProjectConversation(sourceProjectId, forked.id);
}
}, [activeProject, primeConversation, selectConversation, selectedAgent, targetConversationId, upsertConversation]);
}, [activeProject, primeConversation, selectProjectConversation, selectedAgent, targetConversationId, upsertConversation]);
const handleAddFiles = useCallback((files: File[]) => {
if (!draftKey

View File

@@ -72,7 +72,6 @@ const WINDOW_STEP = 100;
const LONG_OUTPUT_CHARS = 4_000;
const STREAMING_THINKING_WINDOW_CHARS = 16_000;
const PROGRESS_PREVIEW_MAX_CHARS = 360;
const PROGRESS_ROLL_CHAR_STEP = 6;
const NODE_STATUS_LABELS: Record<string, string> = {
declared: '已声明',
@@ -701,19 +700,19 @@ function toolActivityIcon(node: ConversationToolNode) {
return <Hammer className={className} aria-hidden="true" />;
}
function latestProgressLine(value: string): string | null {
function firstProgressLine(value: string): string | null {
const lines = value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const line = lines
.at(-1)
.at(0)
?.replace(/\s+/g, ' ')
.replace(/^#{1,6}\s+/, '')
.replace(/^(?:[-*+]|\d+[.)])\s+/, '');
if (!line) return null;
return line.length > PROGRESS_PREVIEW_MAX_CHARS
? `${line.slice(-(PROGRESS_PREVIEW_MAX_CHARS - 1))}`
? `${line.slice(0, PROGRESS_PREVIEW_MAX_CHARS - 1)}`
: line;
}
@@ -748,22 +747,20 @@ const RollingProgressPreview = memo(function RollingProgressPreview({
codeClassName?: string;
}) {
const viewportRef = useRef<HTMLSpanElement | null>(null);
const revision = `${active ? 'active' : 'settled'}:${Math.floor(
contentVersion / PROGRESS_ROLL_CHAR_STEP,
)}`;
const revision = `${active ? 'active' : 'settled'}:${text}`;
const parts = progressInlineParts(text);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
viewport.scrollLeft = viewport.scrollWidth;
viewport.scrollLeft = 0;
}, [contentVersion, text]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
viewport.scrollLeft = viewport.scrollWidth;
viewport.scrollLeft = 0;
});
observer.observe(viewport);
return () => observer.disconnect();
@@ -778,9 +775,9 @@ const RollingProgressPreview = memo(function RollingProgressPreview({
className,
)}
data-progress-alignment="left"
data-progress-origin="head"
data-progress-update-motion={rollUpdates ? 'roll' : 'none'}
data-progress-shimmer={active && shimmer ? 'true' : 'false'}
data-progress-tail="true"
data-roll-revision={rollUpdates ? revision : undefined}
data-testid={testId}
title={text}
@@ -821,11 +818,11 @@ function contentBlocksProgressPreview(
blocks: ConversationContentBlock[],
fallback: string,
): string {
for (let index = blocks.length - 1; index >= 0; index -= 1) {
for (let index = 0; index < blocks.length; index += 1) {
const block = blocks[index];
if (!block) continue;
if (block.kind === 'image') return '图片附件';
const preview = latestProgressLine(block.text);
const preview = firstProgressLine(block.text);
if (preview) return preview;
}
return fallback;
@@ -882,14 +879,14 @@ function toolDetailsProgress(details: KnownToolDetails | undefined): string | nu
}
function toolProgressPreview(node: ConversationToolNode): string {
for (let index = node.output.length - 1; index >= 0; index -= 1) {
for (let index = 0; index < node.output.length; index += 1) {
const block = node.output[index];
if (!block) continue;
if (block.kind === 'image') return '已生成图片附件';
const outputPreview = latestProgressLine(block.text);
const outputPreview = firstProgressLine(block.text);
if (outputPreview) return outputPreview;
}
return latestProgressLine(node.inputText)
return firstProgressLine(node.inputText)
?? toolDetailsProgress(node.details)
?? statusLabel(node.status);
}
@@ -1209,7 +1206,7 @@ const ProcessThinking = memo(function ProcessThinking({
streaming={streaming}
rollUpdates={false}
contentVersion={block.text.length}
preview={latestProgressLine(block.text) ?? (
preview={firstProgressLine(block.text) ?? (
streaming ? 'Pi 正在形成思路…' : '思考已完成'
)}
>
@@ -1324,6 +1321,19 @@ function processItemIsFailed(item: ProcessItem): boolean {
return item.kind === 'notice' && item.node.level === 'error';
}
function persistedProviderFailureMessage(items: ProcessItem[]): string | undefined {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item?.kind === 'notice'
&& item.node.level === 'error'
&& (item.node.code === 'CODING_PROVIDER_AUTH_REQUIRED'
|| item.node.code === 'CODING_PROVIDER_QUOTA_EXHAUSTED')) {
return item.node.message;
}
}
return undefined;
}
function processGroupIsFailed(
items: ProcessItem[],
run: ConversationRunState,
@@ -1385,10 +1395,13 @@ const ProcessGroup = memo(function ProcessGroup({
? runningDurationLabel(run, now)
: settledDurationLabel(run)
: null;
const failureMessage = failed
? (latest ? run.error?.message : undefined) ?? persistedProviderFailureMessage(items)
: undefined;
const summary = active
? `处理中${duration ? ` ${duration}` : ''}`
: failed
? `处理失败${duration ? ` · ${duration}` : ''}`
? `${failureMessage ?? '处理失败'}${duration ? ` · ${duration}` : ''}`
: `已处理${duration ? ` ${duration}` : ''}`;
return (
@@ -1550,11 +1563,33 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
const nodes = useCodingConversationStore(selectNodes);
const run = useCodingConversationStore(selectRun);
const [visibleLimit, setVisibleLimit] = useState(INITIAL_WINDOW);
const scrollRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(true);
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
const windowStart = Math.max(0, nodes.length - visibleLimit);
const visibleNodes = nodes.slice(windowStart);
const turns = useMemo(() => timelineTurns(visibleNodes), [visibleNodes]);
const scrollRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(true);
const loadEarlier = useCallback(() => {
if (windowStart === 0 || prependAnchorRef.current) return;
const element = scrollRef.current;
if (!element) return;
prependAnchorRef.current = {
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop,
};
stickToBottomRef.current = false;
setVisibleLimit((current) => Math.min(nodes.length, current + WINDOW_STEP));
}, [nodes.length, windowStart]);
useLayoutEffect(() => {
const anchor = prependAnchorRef.current;
if (!anchor) return;
prependAnchorRef.current = null;
const element = scrollRef.current;
if (!element) return;
element.scrollTop = anchor.scrollTop + (element.scrollHeight - anchor.scrollHeight);
}, [visibleLimit]);
useLayoutEffect(() => {
if (!stickToBottomRef.current) return;
@@ -1572,6 +1607,7 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
onScroll={(event) => {
const element = event.currentTarget;
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 64;
if (element.scrollTop <= 48) loadEarlier();
}}
>
<div className="mx-auto flex w-full max-w-[50rem] flex-col gap-8">
@@ -1580,7 +1616,7 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
type="button"
variant="ghost"
className="mx-auto min-h-9 rounded-lg px-4 text-xs transition-transform duration-150 ease-out active:scale-[0.96]"
onClick={() => setVisibleLimit((current) => current + WINDOW_STEP)}
onClick={loadEarlier}
>
</Button>

View File

@@ -364,6 +364,33 @@ function completesOrdinaryPrompt(
return false;
}
function taskHasSettled(snapshot: ConversationSnapshot): boolean {
return snapshot.run.status === 'error'
|| (snapshot.run.status === 'idle' && snapshot.run.terminalReason !== undefined);
}
function newlyNeedsUserAttention(
current: ConversationSnapshot | null,
next: ConversationSnapshot | null,
): boolean {
if (!next) return false;
const currentPendingInteractions = new Set(
current?.pendingInteractions
.filter((interaction) => interaction.status === 'pending')
.map((interaction) => interaction.id) ?? [],
);
if (next.pendingInteractions.some((interaction) => (
interaction.status === 'pending' && !currentPendingInteractions.has(interaction.id)
))) {
return true;
}
if (!taskHasSettled(next)) return false;
return !current
|| !taskHasSettled(current)
|| current.run.runId !== next.run.runId
|| current.run.terminalReason !== next.run.terminalReason;
}
export function createCodingConversationStore(
dependencies: Partial<CodingConversationStoreDependencies> = {},
): StoreApi<CodingConversationStoreState> {
@@ -511,14 +538,12 @@ export function createCodingConversationStore(
};
});
const entry = get().entriesByConversationId[conversationId];
const snapshotFlight = !entry?.reducer.snapshot
|| entry.reducer.invalidation
|| entry.loadState !== 'live'
? get().loadSnapshot(
conversationId,
entry?.reducer.invalidation ? 'recovering' : 'loading',
)
: Promise.resolve(entry.reducer.snapshot);
const refreshMode = entry?.reducer.invalidation
? 'recovering'
: entry?.reducer.snapshot && entry.loadState === 'live'
? 'silent'
: 'loading';
const snapshotFlight = get().loadSnapshot(conversationId, refreshMode);
await Promise.all([snapshotFlight, get().connectEvents()]);
},
@@ -979,11 +1004,8 @@ export function createCodingConversationStore(
}
if (reducer === current.reducer) return state;
applied = true;
const incomingMessages = applicableEvent.items.flatMap((item) => (
item.patch.op === 'message.upsert' ? [item.patch.node] : []
));
const unread = state.selectedConversationId !== applicableEvent.conversationId
&& incomingMessages.some((message) => message.role === 'assistant')
&& newlyNeedsUserAttention(current.reducer.snapshot, reducer.snapshot)
? true
: current.unread;
const entry: CodingConversationEntry = {