chore(integration): snapshot local main worktree

This commit is contained in:
inman
2026-08-31 10:23:21 +08:00
parent 48a9189939
commit 33fb31fb28
116 changed files with 8108 additions and 3026 deletions

View File

@@ -1,10 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
ChevronRight,
CircleAlert,
LoaderCircle,
MessageSquarePlus,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -16,8 +13,10 @@ import {
uploadCodingAttachment,
type CodingAttachmentRef,
} from '@/lib/coding-attachments';
import { forkCodingConversation } from '@/lib/coding-conversations';
import { cn } from '@/lib/utils';
import {
abortCodingConversation,
forkCodingConversation,
} from '@/lib/coding-conversations';
import {
codingConversationStore,
selectCodingConversationDraft,
@@ -35,10 +34,11 @@ import type {
CodingConversationMetadata,
} from '@/types/coding-project';
import { CodingComposer } from './CodingComposer';
import { CodingChangesSummary } from './CodingChangesSummary';
import { CodingConversationSidebar } from './CodingConversationSidebar';
import { CodingConversationHeader } from './CodingConversationHeader';
import { CodingConversationTimeline } from './CodingConversationTimeline';
import { CodingInteractionPanel } from './CodingInteractionPanel';
import { CodingWorkspaceInspector } from './CodingWorkspaceInspector';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
export interface CodingChatPanelProps {
@@ -126,10 +126,8 @@ export function CodingChatPanel({
const createConversation = useCodingWorkspaceStore((state) => state.createConversation);
const patchConversation = useCodingWorkspaceStore((state) => state.patchConversation);
const upsertConversation = useCodingWorkspaceStore((state) => state.upsertConversation);
const acknowledgeLegacyNotice = useCodingWorkspaceStore((state) => state.acknowledgeLegacyNotice);
const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId);
const connectionState = useCodingConversationStore((state) => state.connectionState);
const connectionError = useCodingConversationStore((state) => state.globalError);
const primeConversation = useCodingConversationStore((state) => state.primeConversation);
const selectConversation = useCodingConversationStore((state) => state.selectConversation);
@@ -145,7 +143,6 @@ export function CodingChatPanel({
const [provisionalDrafts, setProvisionalDrafts] = useState<Record<string, string>>({});
const [submissionErrors, setSubmissionErrors] = useState<Record<string, LocalSubmissionError>>({});
const [modesByDraftKey, setModesByDraftKey] = useState<Record<string, PromptMode>>({});
const [inspectorOpen, setInspectorOpen] = useState(false);
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
Record<string, LocalComposerAttachment[]>
>({});
@@ -160,13 +157,6 @@ export function CodingChatPanel({
config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? []
), [config]);
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
const agentConversations = useMemo(() => (
selectedAgent
? conversations
.filter((conversation) => conversation.agentId === selectedAgent.id && !conversation.archivedAt)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
: []
), [conversations, selectedAgent]);
const selectedConversation = conversations.find((conversation) => (
conversation.id === selectedConversationId
&& conversation.agentId === selectedAgent?.id
@@ -571,7 +561,7 @@ export function CodingChatPanel({
</div>
<h1 className="mt-4 text-balance text-lg font-semibold">先选择一个编程项目</h1>
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
项目和伙伴信息只在本地读取,不会为了打开输入框启动 Agent。
项目与智能体配置从本地读取,打开输入框不会提前启动 Pi 运行实例。
</p>
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
打开项目设置
@@ -583,167 +573,36 @@ export function CodingChatPanel({
return (
<section className="flex min-h-0 flex-1 overflow-hidden bg-background text-foreground" data-testid="coding-chat-panel">
<aside className="hidden w-60 shrink-0 flex-col border-r border-foreground/10 bg-surface-subtle/45 md:flex">
<div className="border-b border-foreground/10 px-4 py-4">
<p className="truncate text-sm font-semibold">{activeProject?.name ?? 'Makelore Code'}</p>
<p className="mt-0.5 text-xs text-muted-foreground">本地 Conversation</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">伙伴与对话</p>
<div className="space-y-1.5">
{agents.map((agent) => {
const expanded = selectedAgent?.id === agent.id;
const conversationGroupId = `agent-conversations-${agent.id}`;
return (
<div
key={agent.id}
className={cn(
'rounded-2xl',
expanded && 'bg-background p-1 shadow-soft',
)}
>
<button
type="button"
className={cn(
'flex min-h-10 w-full items-center gap-2 rounded-xl px-2.5 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
expanded ? 'font-semibold' : 'hover:bg-background/70',
)}
aria-label={agent.name}
aria-expanded={expanded}
aria-controls={conversationGroupId}
onClick={() => {
clearConversationSelection();
selectAgent(agent.id);
}}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground text-xs font-semibold text-background" aria-hidden="true">
{agent.name.trim().slice(0, 1) || 'A'}
</span>
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
<ChevronRight
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out',
expanded && 'rotate-90',
)}
aria-hidden="true"
/>
</button>
{expanded && (
<div
id={conversationGroupId}
role="group"
aria-label={`${agent.name} 的对话`}
className="mb-1 ml-3 mt-1 border-l border-foreground/10 pl-3 pr-1"
>
<div className="flex min-h-10 items-center justify-between pl-2">
<p className="min-w-0 truncate text-[11px] font-semibold text-muted-foreground">
{agent.name} 的对话
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 rounded-xl transition-transform duration-150 ease-out active:scale-[0.96]"
aria-label="新建对话"
disabled={Boolean(creatingAgentIds[agent.id])}
onClick={() => void handleCreateConversation()}
>
{creatingAgentIds[agent.id]
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
: <MessageSquarePlus className="h-4 w-4" aria-hidden="true" />}
</Button>
</div>
<div className="space-y-1 pb-1">
{agentConversations.map((conversation) => {
const unread = conversationSummaries[conversation.id]?.unread ?? conversation.unread;
const summary = conversationSummaries[conversation.id];
return (
<button
key={conversation.id}
type="button"
className={cn(
'flex min-h-10 w-full items-center gap-2 rounded-xl px-3 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
targetConversationId === conversation.id
? 'bg-surface-subtle font-medium'
: 'text-muted-foreground hover:bg-surface-subtle/70 hover:text-foreground',
)}
onClick={() => handleSelectConversation(conversation)}
>
<span className="min-w-0 flex-1 truncate">{conversation.title}</span>
{summary && ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(summary.runStatus) && (
<LoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-brand" aria-label="对话正在运行" />
)}
{unread && <span className="h-2 w-2 shrink-0 rounded-full bg-brand" aria-label="未读" />}
</button>
);
})}
{autoCreating && (
<div className="flex min-h-10 items-center gap-2 px-3 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
正在创建首个对话…
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</aside>
<CodingConversationSidebar
projectName={activeProject?.name}
agents={agents}
conversations={conversations}
conversationSummaries={conversationSummaries}
selectedAgentId={selectedAgent?.id ?? null}
selectedConversationId={targetConversationId}
creatingAgentIds={creatingAgentIds}
onSelectAgent={(agentId) => {
clearConversationSelection();
selectAgent(agentId);
}}
onSelectConversation={handleSelectConversation}
onCreateConversation={() => void handleCreateConversation()}
onOpenProjectSettings={onOpenProjectSettings}
/>
<div className="flex min-w-0 flex-1 flex-col">
<CodingConversationHeader
key={`header:${targetConversationId ?? 'none'}`}
conversation={selectedConversation}
snapshot={snapshot}
connectionState={connectionState}
onRename={async (title) => {
if (targetConversationId) await patchConversation(targetConversationId, { title });
}}
onArchive={async () => {
if (!targetConversationId) return;
const sourceConversationId = targetConversationId;
await patchConversation(sourceConversationId, { archived: true });
if (codingConversationStore.getState().selectedConversationId === sourceConversationId) {
clearConversationSelection();
}
}}
onToggleUnread={async () => {
if (!targetConversationId || !selectedConversation) return;
const unread = !selectedConversation.unread;
await patchConversation(targetConversationId, { unread });
markConversationUnread(targetConversationId, unread);
}}
onFork={() => handleForkConversation()}
onRefresh={async () => {
if (targetConversationId) await loadConversationSnapshot(targetConversationId, true);
}}
onRecover={async () => {
if (targetConversationId) await recoverConversation(targetConversationId);
}}
onOpenInspector={() => setInspectorOpen(true)}
onOpenSettings={onOpenProjectSettings}
/>
{config?.legacyConversationNotice === 'pending' && (
<div className="mx-4 mt-3 flex min-h-10 items-center gap-3 rounded-xl border border-amber-300/70 bg-amber-50 px-3 py-2 text-xs text-amber-950 sm:mx-5" data-testid="legacy-conversation-notice">
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 flex-1 text-pretty">
旧版 OpenCode 对话不能在 Pi 中继续;原数据已保留,仅用于旧版本回滚或导出。
</p>
<Button
type="button"
variant="ghost"
className="min-h-10 shrink-0 rounded-xl px-3 text-amber-950"
onClick={() => void acknowledgeLegacyNotice()}
>
知道了
</Button>
</div>
)}
{(workspaceError || conversationMetadataError || connectionError) && (
<div className="mx-4 mt-3 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive sm:mx-5">
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
@@ -769,6 +628,7 @@ export function CodingChatPanel({
<CodingConversationTimeline
key={targetConversationId}
conversationId={targetConversationId}
assistantName={selectedAgent?.name}
onFork={(sourceEntryId) => {
void handleForkConversation(sourceEntryId).catch((error) => {
if (!draftKey) return;
@@ -789,7 +649,15 @@ export function CodingChatPanel({
key={`interaction:${targetConversationId}`}
conversationId={targetConversationId}
interactions={snapshot?.pendingInteractions ?? []}
onSettled={() => loadConversationSnapshot(targetConversationId, true).then(() => undefined)}
onSettled={() => loadConversationSnapshot(targetConversationId, 'silent').then(() => undefined)}
/>
)}
{targetConversationId && (
<CodingChangesSummary
key={`changes:${targetConversationId}`}
conversationId={targetConversationId}
snapshot={snapshot}
/>
)}
@@ -807,7 +675,10 @@ export function CodingChatPanel({
acceptedCount={acceptedRequestCount}
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}
submitting={submittingRequestCount > 0}
placeholder={selectedAgent ? '给当前对话发送消息' : '请选择一个伙伴后再发送'}
placeholder={selectedAgent ? '随心输入' : '请选择一个智能体后再发送'}
conversation={selectedConversation}
snapshot={snapshot}
voiceScopeKey={draftKey}
onModeChange={(mode) => {
if (draftKey) setModesByDraftKey((current) => ({ ...current, [draftKey]: mode }));
}}
@@ -825,6 +696,16 @@ export function CodingChatPanel({
}
}}
onSubmit={handleSubmit}
onAbort={() => {
if (!targetConversationId) return;
void abortCodingConversation(targetConversationId).catch((error) => {
if (!draftKey) return;
setSubmissionErrors((current) => ({
...current,
[draftKey]: localSubmissionError(error),
}));
});
}}
onRecover={() => {
if (targetConversationId) {
void recoverConversation(targetConversationId).catch(() => undefined);
@@ -832,18 +713,8 @@ export function CodingChatPanel({
}}
onAddFiles={handleAddFiles}
onRemoveAttachment={handleRemoveAttachment}
/>
<CodingWorkspaceInspector
key={`inspector:${targetConversationId ?? 'none'}`}
open={inspectorOpen}
onOpenChange={setInspectorOpen}
conversationId={targetConversationId}
agentId={selectedAgent?.id ?? null}
snapshot={snapshot}
onUseCommand={(command) => {
if (!targetConversationId) return;
const current = codingConversationStore.getState().draftsByConversationId[targetConversationId]?.text ?? '';
setConversationDraft(targetConversationId, current ? `${current}\n${command}` : command);
onRefreshRuntime={async () => {
if (targetConversationId) await loadConversationSnapshot(targetConversationId, 'silent');
}}
/>
</div>