merge: integrate remote main
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user