feat: remove legacy OpenCode runtime
Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
This commit is contained in:
@@ -8,9 +8,9 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { prepareAgentAvatar } from '@/lib/agent-avatar-upload';
|
||||
import { agentAvatarOptions } from '@/lib/agent-avatars';
|
||||
import { formatModelRefLabel, type ConfiguredModelOption } from '@/lib/model-options';
|
||||
import { codingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProjectAgentConfig } from '../../../shared/project-config';
|
||||
import type { CodingProjectAgent } from '@/types/coding-project';
|
||||
import { DEFAULT_PROJECT_AGENT_SKILL_IDS } from '../../../shared/coding-skills';
|
||||
|
||||
export type AgentCreationSkillOption = {
|
||||
@@ -32,10 +32,10 @@ export type AgentCreationInput = {
|
||||
type AgentCreationDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
modelOptions: ConfiguredModelOption[];
|
||||
modelOptions: CodingModelOption[];
|
||||
skills?: AgentCreationSkillOption[];
|
||||
existingAgentNames?: string[];
|
||||
agent?: ProjectAgentConfig | null;
|
||||
agent?: CodingProjectAgent | null;
|
||||
onCreate?: (input: AgentCreationInput) => Promise<void>;
|
||||
onUpdate?: (input: AgentCreationInput) => Promise<void>;
|
||||
onArchive?: () => void;
|
||||
@@ -43,13 +43,13 @@ type AgentCreationDialogProps = {
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
function createInitialInput(modelOptions: ConfiguredModelOption[], agent?: ProjectAgentConfig | null): AgentCreationInput {
|
||||
function createInitialInput(modelOptions: CodingModelOption[], agent?: CodingProjectAgent | null): AgentCreationInput {
|
||||
if (agent) {
|
||||
return {
|
||||
name: agent.name,
|
||||
avatarId: agent.avatarId,
|
||||
avatarDataUrl: agent.avatarDataUrl,
|
||||
model: agent.model ?? '',
|
||||
model: agent.model ? codingModelKey(agent.model) : '',
|
||||
responsibility: agent.responsibility.mission,
|
||||
prompt: agent.prompt,
|
||||
skillIds: [...agent.skillIds],
|
||||
@@ -60,7 +60,7 @@ function createInitialInput(modelOptions: ConfiguredModelOption[], agent?: Proje
|
||||
name: '',
|
||||
avatarId: agentAvatarOptions[0]?.id ?? 'avatar-01',
|
||||
avatarDataUrl: undefined,
|
||||
model: modelOptions[0]?.modelRef ?? '',
|
||||
model: modelOptions[0]?.key ?? '',
|
||||
responsibility: '',
|
||||
prompt: '',
|
||||
skillIds: [],
|
||||
@@ -181,7 +181,7 @@ export function AgentCreationDialog({
|
||||
<p className="mt-1 text-xs font-medium text-muted-foreground">当前伙伴的消息固定使用此模型;如需切换,请在这里修改。</p>
|
||||
<select id="agent-create-model" value={input.model} onChange={(event) => setInput((current) => ({ ...current, model: event.target.value }))} className="mt-2 h-10 w-full rounded-xl border border-border bg-surface-input px-3 text-sm">
|
||||
<option value="" disabled>请选择已配置模型</option>
|
||||
{modelOptions.map((option) => <option key={option.modelRef} value={option.modelRef}>{formatModelRefLabel(option.modelRef)} · {option.runtimeProviderKey}</option>)}
|
||||
{modelOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -9,7 +9,7 @@ import { Sidebar } from './Sidebar';
|
||||
import { TitleBar } from './TitleBar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getAiModuleForPath } from '@/lib/ai-modules';
|
||||
import { useOpencodeStore } from '@/stores/opencode';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -18,7 +18,7 @@ import type { SidebarPeekSource } from './sidebar-peek';
|
||||
const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180;
|
||||
|
||||
export function MainLayout() {
|
||||
const activeProject = useOpencodeStore((state) => state.activeProject);
|
||||
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const config = useProjectConfigStore((state) => activeProject ? state.configsByProjectId[activeProject.id] : undefined);
|
||||
const load = useProjectConfigStore((state) => state.load);
|
||||
@@ -36,7 +36,7 @@ export function MainLayout() {
|
||||
const isProgrammingModule = activeModule === 'programming';
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const isChatWorkspace = location.pathname === '/opencode-chat';
|
||||
const isChatWorkspace = location.pathname === '/chat';
|
||||
const isInitializationSafeRoute = location.pathname === '/project-config' || !isProgrammingModule;
|
||||
|
||||
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
|
||||
|
||||
@@ -46,9 +46,9 @@ import { getAccountInitial } from '@/components/profile/user-avatar-utils';
|
||||
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useAuthStore, type AuthUser } from '@/stores/auth';
|
||||
import { useOpencodeStore, type ProjectDirectoryMode } from '@/stores/opencode';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import type { OpencodeProject } from '@/types/opencode';
|
||||
import type { CodingProjectSummary } from '@/types/coding-project';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import type { ProjectType } from '../../../shared/project-config';
|
||||
import { toast } from 'sonner';
|
||||
@@ -65,10 +65,12 @@ type TokenUsageState = {
|
||||
};
|
||||
|
||||
type ProjectEntryError = {
|
||||
project: OpencodeProject;
|
||||
project: CodingProjectSummary;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory';
|
||||
|
||||
function getFolderName(pathValue: string): string {
|
||||
const trimmed = pathValue.trim();
|
||||
const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/u, '');
|
||||
@@ -133,13 +135,12 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const { t } = useTranslation('common');
|
||||
const sidebarPinnedCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen;
|
||||
const opencodeProjects = useOpencodeStore((state) => state.projects);
|
||||
const activeProject = useOpencodeStore((state) => state.activeProject);
|
||||
const loadProjectList = useOpencodeStore((state) => state.loadProjectList);
|
||||
const activateProjectLocally = useOpencodeStore((state) => state.activateProjectLocally);
|
||||
const setActiveProject = useOpencodeStore((state) => state.setActiveProject);
|
||||
const removeProject = useOpencodeStore((state) => state.removeProject);
|
||||
const createProject = useOpencodeStore((state) => state.createProject);
|
||||
const projects = useCodingWorkspaceStore((state) => state.projects);
|
||||
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
|
||||
const loadProjectList = useCodingWorkspaceStore((state) => state.load);
|
||||
const setActiveProject = useCodingWorkspaceStore((state) => state.setActiveProject);
|
||||
const removeProject = useCodingWorkspaceStore((state) => state.removeProject);
|
||||
const createProject = useCodingWorkspaceStore((state) => state.createProject);
|
||||
const projectConfigs = useProjectConfigStore((state) => state.configsByProjectId);
|
||||
const loadProjectConfig = useProjectConfigStore((state) => state.load);
|
||||
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
|
||||
@@ -166,7 +167,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
|
||||
const [removeProjectCandidate, setRemoveProjectCandidate] = useState<OpencodeProject | null>(null);
|
||||
const [removeProjectCandidate, setRemoveProjectCandidate] = useState<CodingProjectSummary | null>(null);
|
||||
const [robotOverview, setRobotOverview] = useState<AiHardwareOverview | null>(null);
|
||||
const [robotOverviewLoading, setRobotOverviewLoading] = useState(false);
|
||||
const [robotSelectedAgentId, setRobotSelectedAgentId] = useState<string | null>(() => readAiHardwareAgentId());
|
||||
@@ -182,7 +183,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const isRobotModule = activeModule === 'robot';
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const projectConfigPath = '/project-config';
|
||||
const visibleProjects = opencodeProjects;
|
||||
const visibleProjects = projects;
|
||||
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
|
||||
const accountName = userProfile?.displayName?.trim() || getAuthUserDisplayName(authUser) || '未登录用户';
|
||||
const accountFullName = accountName;
|
||||
@@ -416,24 +417,23 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
setCreateProjectError(null);
|
||||
};
|
||||
|
||||
const showProjectEntryError = (project: OpencodeProject, message: string) => {
|
||||
const showProjectEntryError = (project: CodingProjectSummary, message: string) => {
|
||||
setProjectEntryError({ project, message });
|
||||
};
|
||||
|
||||
const enterProject = async (project: OpencodeProject) => {
|
||||
const enterProject = async (project: CodingProjectSummary) => {
|
||||
const result = await loadProjectConfig(project.id);
|
||||
if (result.status !== 'valid' || !result.config) {
|
||||
showProjectEntryError(project, result.status === 'missing' ? '项目缺少 .niancode/project.json' : result.error ?? '项目配置无效');
|
||||
return;
|
||||
}
|
||||
activateProjectLocally(project);
|
||||
await setActiveProject(project.id);
|
||||
navigate(result.config.initialized
|
||||
? '/opencode-chat'
|
||||
? '/chat'
|
||||
: projectConfigPath);
|
||||
void setActiveProject(project.id).catch(() => undefined);
|
||||
};
|
||||
|
||||
const readAndEnterProject = async (project: OpencodeProject) => {
|
||||
const readAndEnterProject = async (project: CodingProjectSummary) => {
|
||||
setProjectEntryError(null);
|
||||
try {
|
||||
await enterProject(project);
|
||||
@@ -459,10 +459,10 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
setCreateProjectError(null);
|
||||
try {
|
||||
const project = await createProject({
|
||||
directoryMode: newProjectDirectoryMode,
|
||||
selectedPath,
|
||||
projectType: newProjectType,
|
||||
...(newProjectDirectoryMode === 'create-child-directory' ? { projectName } : {}),
|
||||
...(newProjectDirectoryMode === 'create-child-directory'
|
||||
? { parentPath: selectedPath, projectName }
|
||||
: { projectPath: selectedPath }),
|
||||
});
|
||||
setCreateDialogOpen(false);
|
||||
await enterProject(project);
|
||||
@@ -473,7 +473,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
}
|
||||
};
|
||||
|
||||
const openProject = async (project: OpencodeProject) => {
|
||||
const openProject = async (project: CodingProjectSummary) => {
|
||||
await readAndEnterProject(project);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import { createPortal } from 'react-dom';
|
||||
import { Copy, FolderOpen, Minus, PanelLeft, PanelLeftClose, Square, X } from 'lucide-react';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useOpencodeStore } from '@/stores/opencode';
|
||||
import logoWordmarkSource from '@/assets/makelore-wordmark-source.png';
|
||||
import type { SidebarPeekSource } from './sidebar-peek';
|
||||
|
||||
@@ -61,7 +61,7 @@ function ProductTitleBar({
|
||||
onSidebarPeekChange?: SidebarPeekChange;
|
||||
}) {
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const activeProject = useOpencodeStore((state) => state.activeProject);
|
||||
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
|
||||
const toolbarButtonClass = 'no-drag motion-press flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-surface-subtle hover:text-foreground';
|
||||
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||
const toggleSidebar = () => setSidebarCollapsed(!sidebarCollapsed);
|
||||
|
||||
@@ -1,561 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Archive, ChevronRight, FolderOpen, Pin, Plus, RotateCcw, Settings as SettingsIcon, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { DisclosureContent } from '@/components/ui/disclosure';
|
||||
import { AgentCreationDialog, type AgentCreationInput, type AgentCreationSkillOption } from '@/components/opencode/AgentCreationDialog';
|
||||
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
|
||||
import type { ConfiguredModelOption } from '@/lib/model-options';
|
||||
import { getSkillDisplayInfo } from '@/lib/skill-display';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { extractText } from '@/pages/Chat/message-utils';
|
||||
import type { ProjectAgentConfig } from '../../../shared/project-config';
|
||||
import type { ProjectSessionMetadata } from '../../../shared/project-conversations';
|
||||
import type { RawMessage } from '@/types/chat';
|
||||
import type { OpencodeSession, OpencodeSessionStatusMap } from '@/types/opencode';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
type ArchiveTarget =
|
||||
| { kind: 'agent'; agent: ProjectAgentConfig }
|
||||
| { kind: 'session'; sessionId: string; title: string }
|
||||
| null;
|
||||
|
||||
type DeleteTarget =
|
||||
| { kind: 'agent'; agent: ProjectAgentConfig; sessionCount: number }
|
||||
| { kind: 'session'; sessionId: string; title: string }
|
||||
| null;
|
||||
|
||||
const MIN_LAYOUT_COLUMN_WIDTH = 256;
|
||||
const AGENT_SWITCH_EXIT_MS = 180;
|
||||
const EMPTY_SESSION_METADATA: ProjectSessionMetadata[] = [];
|
||||
type RuntimeSkillInfo = { name: string };
|
||||
|
||||
type AgentConversationSidebarProps = {
|
||||
projectName?: string;
|
||||
agents: ProjectAgentConfig[];
|
||||
sessions: OpencodeSession[];
|
||||
sessionMessagesBySessionId: Record<string, RawMessage[]>;
|
||||
conversationSessions: ProjectSessionMetadata[];
|
||||
selectedAgentId: string | null;
|
||||
selectedSessionId: string | null;
|
||||
sessionStatuses: OpencodeSessionStatusMap;
|
||||
pendingQuestionCountBySession: Record<string, number>;
|
||||
pendingPermissionCountBySession: Record<string, number>;
|
||||
startingAgentId: string | null;
|
||||
modelOptions: ConfiguredModelOption[];
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onNewSession: (agentId: string) => void;
|
||||
onCreateAgent: (input: AgentCreationInput) => Promise<void>;
|
||||
onArchiveAgent: (agent: ProjectAgentConfig) => Promise<void>;
|
||||
onRestoreAgent: (agent: ProjectAgentConfig) => Promise<void>;
|
||||
onDeleteAgent: (agent: ProjectAgentConfig) => Promise<void>;
|
||||
onArchiveSession: (sessionId: string) => Promise<void>;
|
||||
onRestoreSession: (sessionId: string) => Promise<void>;
|
||||
onDeleteSession: (sessionId: string) => Promise<void>;
|
||||
onTogglePinAgent: (agent: ProjectAgentConfig) => Promise<void>;
|
||||
onOpenProjectSettings?: () => void;
|
||||
};
|
||||
|
||||
function getSessionId(session: OpencodeSession): string {
|
||||
return String(session.id ?? session.sessionID ?? '');
|
||||
}
|
||||
|
||||
function sessionTimestamp(session: OpencodeSession | undefined, metadata: ProjectSessionMetadata | undefined): number {
|
||||
const runtimeTime = session?.time?.updated;
|
||||
if (typeof runtimeTime === 'number') return runtimeTime;
|
||||
const runtimeUpdated = Date.parse(session?.updatedAt ?? '');
|
||||
if (Number.isFinite(runtimeUpdated)) return runtimeUpdated;
|
||||
const metadataUpdated = Date.parse(metadata?.updatedAt ?? '');
|
||||
return Number.isFinite(metadataUpdated) ? metadataUpdated : 0;
|
||||
}
|
||||
|
||||
function formatSessionTime(timestamp: number): string {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
|
||||
}
|
||||
|
||||
function sessionTitle(session: OpencodeSession | undefined): string {
|
||||
const title = session?.title?.trim() || session?.name?.trim();
|
||||
return title || '新对话';
|
||||
}
|
||||
|
||||
function latestMessagePreview(messages: RawMessage[] | undefined, fallback: string): string {
|
||||
for (let index = (messages?.length ?? 0) - 1; index >= 0; index -= 1) {
|
||||
const message = messages?.[index];
|
||||
if (!message) continue;
|
||||
const preview = extractText(message).replace(/\s+/gu, ' ').trim();
|
||||
if (preview) return preview;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const SESSION_PREVIEW_LIMIT = 24;
|
||||
|
||||
function compactSessionPreview(preview: string): string {
|
||||
const characters = Array.from(preview);
|
||||
return characters.length > SESSION_PREVIEW_LIMIT
|
||||
? `${characters.slice(0, SESSION_PREVIEW_LIMIT - 1).join('')}…`
|
||||
: preview;
|
||||
}
|
||||
|
||||
export function AgentConversationSidebar({
|
||||
agents,
|
||||
sessions,
|
||||
sessionMessagesBySessionId,
|
||||
conversationSessions,
|
||||
selectedAgentId,
|
||||
selectedSessionId,
|
||||
sessionStatuses,
|
||||
pendingQuestionCountBySession,
|
||||
pendingPermissionCountBySession,
|
||||
startingAgentId,
|
||||
modelOptions,
|
||||
onSelectAgent,
|
||||
onSelectSession,
|
||||
onNewSession,
|
||||
onCreateAgent,
|
||||
onArchiveAgent,
|
||||
onRestoreAgent,
|
||||
onDeleteAgent,
|
||||
onArchiveSession,
|
||||
onRestoreSession,
|
||||
onDeleteSession,
|
||||
onTogglePinAgent,
|
||||
onOpenProjectSettings,
|
||||
projectName,
|
||||
}: AgentConversationSidebarProps) {
|
||||
const sidebarPinnedCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const platform = typeof window === 'undefined' ? undefined : window.electron?.platform;
|
||||
const hasCustomTitleBar = platform === 'darwin' || platform === 'win32';
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [skills, setSkills] = useState<AgentCreationSkillOption[]>([]);
|
||||
const [archiveTarget, setArchiveTarget] = useState<ArchiveTarget>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [expandedSessionLists, setExpandedSessionLists] = useState<Record<string, boolean>>({});
|
||||
const [expandedArchivedSessionLists, setExpandedArchivedSessionLists] = useState<Record<string, boolean>>({});
|
||||
const [previousSelectedAgentId, setPreviousSelectedAgentId] = useState(selectedAgentId);
|
||||
const [exitingAgentIds, setExitingAgentIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const exitTimersRef = useRef(new Map<string, number>());
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return;
|
||||
let cancelled = false;
|
||||
void hostApiFetch<{ skills?: RuntimeSkillInfo[] }>('/api/opencode/skills')
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
const runtimeSkills = Array.isArray(result?.skills) ? result.skills : [];
|
||||
setSkills(runtimeSkills.map((skill) => ({
|
||||
id: skill.name,
|
||||
...getSkillDisplayInfo(skill.name),
|
||||
})));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSkills([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [createOpen]);
|
||||
|
||||
const runtimeBySessionId = useMemo(
|
||||
() => new Map(sessions.map((session) => [getSessionId(session), session])),
|
||||
[sessions],
|
||||
);
|
||||
const sessionsByAgentId = useMemo(() => {
|
||||
const grouped = new Map<string, ProjectSessionMetadata[]>();
|
||||
for (const metadata of conversationSessions) {
|
||||
const current = grouped.get(metadata.agentId) ?? [];
|
||||
current.push(metadata);
|
||||
grouped.set(metadata.agentId, current);
|
||||
}
|
||||
for (const agentSessions of grouped.values()) {
|
||||
agentSessions.sort((left, right) => {
|
||||
const leftArchived = Boolean(left.archivedAt);
|
||||
const rightArchived = Boolean(right.archivedAt);
|
||||
if (leftArchived !== rightArchived) return leftArchived ? 1 : -1;
|
||||
return sessionTimestamp(runtimeBySessionId.get(right.sessionId), right)
|
||||
- sessionTimestamp(runtimeBySessionId.get(left.sessionId), left);
|
||||
});
|
||||
}
|
||||
return grouped;
|
||||
}, [conversationSessions, runtimeBySessionId]);
|
||||
|
||||
const getAgentSessions = useCallback((agentId: string) => {
|
||||
return sessionsByAgentId.get(agentId) ?? EMPTY_SESSION_METADATA;
|
||||
}, [sessionsByAgentId]);
|
||||
|
||||
const agentActivityById = useMemo(() => {
|
||||
const activities = new Map<string, { unread: number; running: boolean; pending: number; latestAt: number }>();
|
||||
for (const agent of agents) {
|
||||
const agentSessions = getAgentSessions(agent.id);
|
||||
const unread = agentSessions.reduce((total, item) => total + item.unreadCount, 0);
|
||||
const running = agentSessions.some((item) => {
|
||||
const type = sessionStatuses[item.sessionId]?.type;
|
||||
return type === 'busy' || type === 'retry';
|
||||
});
|
||||
const pending = agentSessions.reduce(
|
||||
(total, item) => total + (pendingQuestionCountBySession[item.sessionId] ?? 0) + (pendingPermissionCountBySession[item.sessionId] ?? 0),
|
||||
0,
|
||||
);
|
||||
const latest = agentSessions[0];
|
||||
activities.set(agent.id, {
|
||||
unread,
|
||||
running,
|
||||
pending,
|
||||
latestAt: latest ? sessionTimestamp(runtimeBySessionId.get(latest.sessionId), latest) : 0,
|
||||
});
|
||||
}
|
||||
return activities;
|
||||
}, [agents, getAgentSessions, pendingPermissionCountBySession, pendingQuestionCountBySession, runtimeBySessionId, sessionStatuses]);
|
||||
|
||||
const agentActivity = useCallback((agent: ProjectAgentConfig) => (
|
||||
agentActivityById.get(agent.id) ?? { unread: 0, running: false, pending: 0, latestAt: 0 }
|
||||
), [agentActivityById]);
|
||||
|
||||
const activeAgents = useMemo(
|
||||
() => agents
|
||||
.filter((agent) => !agent.archivedAt)
|
||||
.sort((left, right) => {
|
||||
if (Boolean(left.pinned) !== Boolean(right.pinned)) return left.pinned ? -1 : 1;
|
||||
return agentActivity(right).latestAt - agentActivity(left).latestAt;
|
||||
}),
|
||||
[agentActivity, agents],
|
||||
);
|
||||
const archivedAgents = useMemo(() => agents.filter((agent) => Boolean(agent.archivedAt)), [agents]);
|
||||
|
||||
const renderedAgentIds = useMemo(() => {
|
||||
const ids = new Set(exitingAgentIds);
|
||||
if (selectedAgentId) ids.add(selectedAgentId);
|
||||
if (previousSelectedAgentId) ids.add(previousSelectedAgentId);
|
||||
return ids;
|
||||
}, [exitingAgentIds, previousSelectedAgentId, selectedAgentId]);
|
||||
|
||||
const sessionPreviewById = useMemo(() => {
|
||||
const previews = new Map<string, string>();
|
||||
for (const metadata of conversationSessions) {
|
||||
if (!renderedAgentIds.has(metadata.agentId)) continue;
|
||||
const title = sessionTitle(runtimeBySessionId.get(metadata.sessionId));
|
||||
previews.set(
|
||||
metadata.sessionId,
|
||||
latestMessagePreview(sessionMessagesBySessionId[metadata.sessionId], title),
|
||||
);
|
||||
}
|
||||
return previews;
|
||||
}, [conversationSessions, renderedAgentIds, runtimeBySessionId, sessionMessagesBySessionId]);
|
||||
|
||||
const scheduleAgentExit = useCallback((agentId: string) => {
|
||||
setExitingAgentIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const existingTimer = exitTimersRef.current.get(agentId);
|
||||
if (existingTimer !== undefined) window.clearTimeout(existingTimer);
|
||||
const timer = window.setTimeout(() => {
|
||||
setExitingAgentIds((current) => {
|
||||
if (!current.has(agentId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
setPreviousSelectedAgentId((current) => current === agentId ? null : current);
|
||||
exitTimersRef.current.delete(agentId);
|
||||
}, AGENT_SWITCH_EXIT_MS);
|
||||
exitTimersRef.current.set(agentId, timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const timer of exitTimersRef.current.values()) window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const getAgentStatus = (activity: ReturnType<typeof agentActivity>) => {
|
||||
if (activity.pending > 0) return { label: '处理中', dot: 'bg-amber-500 animate-pulse' };
|
||||
if (activity.running) return { label: '处理中', dot: 'bg-emerald-500 animate-pulse' };
|
||||
if (activity.unread > 0) return { label: '有新消息', dot: 'bg-brand' };
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAgentSelection = useCallback((agentId: string) => {
|
||||
if (selectedAgentId) {
|
||||
setPreviousSelectedAgentId(selectedAgentId);
|
||||
scheduleAgentExit(selectedAgentId);
|
||||
}
|
||||
onSelectAgent(agentId);
|
||||
}, [onSelectAgent, scheduleAgentExit, selectedAgentId]);
|
||||
|
||||
const renderAgentSessions = (agent: ProjectAgentConfig, open: boolean, renderContent: boolean) => {
|
||||
const agentSessions = renderContent ? getAgentSessions(agent.id) : EMPTY_SESSION_METADATA;
|
||||
const activeSessions = agentSessions.filter((item) => !item.archivedAt);
|
||||
const visibleSessions = activeSessions.slice(0, 5);
|
||||
const hiddenSessions = activeSessions.slice(5);
|
||||
const archivedSessions = agentSessions.filter((item) => Boolean(item.archivedAt));
|
||||
const renderSession = (metadata: ProjectSessionMetadata) => {
|
||||
const runtimeSession = runtimeBySessionId.get(metadata.sessionId);
|
||||
const title = sessionTitle(runtimeSession);
|
||||
const fullPreview = sessionPreviewById.get(metadata.sessionId) ?? title;
|
||||
const preview = compactSessionPreview(fullPreview);
|
||||
const selected = metadata.sessionId === selectedSessionId;
|
||||
const pending = (pendingQuestionCountBySession[metadata.sessionId] ?? 0) + (pendingPermissionCountBySession[metadata.sessionId] ?? 0);
|
||||
return (
|
||||
<div
|
||||
key={metadata.sessionId}
|
||||
className={cn(
|
||||
'group/session-card flex w-full items-center gap-1 rounded-lg px-1 py-0.5',
|
||||
selected ? 'bg-background shadow-soft' : 'hover:bg-background/70',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 px-1.5 py-1 text-left"
|
||||
onClick={() => onSelectSession(metadata.sessionId)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold" title={fullPreview}>
|
||||
{preview}
|
||||
</span>
|
||||
{metadata.unreadCount > 0 ? <Badge className="shrink-0 rounded-full border-0 bg-brand px-1.5 text-[10px] text-primary-foreground">{metadata.unreadCount > 99 ? '99+' : metadata.unreadCount}</Badge> : pending > 0 ? <Badge className="shrink-0 rounded-full border-0 bg-amber-500 px-1.5 text-[10px] text-white">{pending}</Badge> : null}
|
||||
<span className="shrink-0 whitespace-nowrap text-[10px] text-muted-foreground">
|
||||
{formatSessionTime(sessionTimestamp(runtimeSession, metadata)) || '刚刚'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-none shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-surface-subtle hover:text-destructive group-hover/session-card:pointer-events-auto group-hover/session-card:opacity-100"
|
||||
aria-label={`归档会话 ${title}`}
|
||||
title="归档会话"
|
||||
onClick={() => setArchiveTarget({ kind: 'session', sessionId: metadata.sessionId, title })}
|
||||
>
|
||||
<Archive className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const moreSessionsOpen = expandedSessionLists[agent.id] ?? hiddenSessions.some((item) => item.sessionId === selectedSessionId);
|
||||
const archivedSessionsOpen = expandedArchivedSessionLists[agent.id] ?? false;
|
||||
|
||||
return (
|
||||
<DisclosureContent
|
||||
open={open}
|
||||
className="agent-session-disclosure border-t border-brand/15"
|
||||
innerClassName="px-2 pb-1.5 pt-1"
|
||||
>
|
||||
<section aria-label={`${agent.name || '伙伴'}的会话列表`}>
|
||||
{renderContent ? <>
|
||||
<div className="flex items-center justify-end">
|
||||
<button type="button" className="rounded p-1 text-muted-foreground hover:bg-background hover:text-brand" aria-label="新建会话" title="新建会话" onClick={() => onNewSession(agent.id)}><Plus className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{visibleSessions.map(renderSession)}
|
||||
{activeSessions.length === 0 ? <button type="button" className="flex w-full items-center gap-2 rounded-lg border border-dashed border-border/80 px-2.5 py-2.5 text-left text-xs text-muted-foreground hover:bg-background" onClick={() => onNewSession(agent.id)}><Plus className="h-3.5 w-3.5" />开始一条新对话</button> : null}
|
||||
{hiddenSessions.length > 0 ? (
|
||||
<div className="pt-1">
|
||||
<button type="button" className="flex w-full items-center justify-between px-2.5 py-1 text-left text-[10px] font-semibold text-muted-foreground" aria-expanded={moreSessionsOpen} onClick={() => setExpandedSessionLists((current) => ({ ...current, [agent.id]: !moreSessionsOpen }))}>
|
||||
<span>更多会话({hiddenSessions.length})</span>
|
||||
<ChevronRight className={cn('h-3.5 w-3.5', moreSessionsOpen && 'rotate-90')} />
|
||||
</button>
|
||||
<DisclosureContent open={moreSessionsOpen} className="mt-0.5" innerClassName="space-y-0.5">
|
||||
{hiddenSessions.map(renderSession)}
|
||||
</DisclosureContent>
|
||||
</div>
|
||||
) : null}
|
||||
{archivedSessions.length > 0 ? (
|
||||
<div className="pt-2">
|
||||
<button type="button" className="flex w-full items-center justify-between px-2.5 py-1 text-left text-[10px] font-semibold text-muted-foreground" aria-expanded={archivedSessionsOpen} onClick={() => setExpandedArchivedSessionLists((current) => ({ ...current, [agent.id]: !archivedSessionsOpen }))}>
|
||||
<span>已归档会话({archivedSessions.length})</span>
|
||||
<ChevronRight className={cn('h-3.5 w-3.5', archivedSessionsOpen && 'rotate-90')} />
|
||||
</button>
|
||||
<DisclosureContent open={archivedSessionsOpen} className="mt-1" innerClassName="space-y-1">
|
||||
{archivedSessions.map((metadata) => {
|
||||
const title = sessionTitle(runtimeBySessionId.get(metadata.sessionId));
|
||||
return <div key={metadata.sessionId} className="flex items-center gap-2 rounded-lg px-2.5 py-2 text-xs text-muted-foreground"><span className="min-w-0 flex-1 truncate" title={title}>{compactSessionPreview(title)}</span><button type="button" className="rounded p-1 text-muted-foreground hover:bg-background hover:text-brand" aria-label={`恢复会话 ${title}`} onClick={() => void onRestoreSession(metadata.sessionId)}><RotateCcw className="h-3 w-3" /></button><button type="button" className="rounded p-1 text-muted-foreground hover:bg-accent-soft hover:text-destructive" aria-label={`永久删除会话 ${title}`} title="永久删除会话" onClick={() => setDeleteTarget({ kind: 'session', sessionId: metadata.sessionId, title })}><Trash2 className="h-3 w-3" /></button></div>;
|
||||
})}
|
||||
</DisclosureContent>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</> : null}
|
||||
</section>
|
||||
</DisclosureContent>
|
||||
);
|
||||
};
|
||||
|
||||
const handleArchiveConfirm = async () => {
|
||||
if (!archiveTarget) return;
|
||||
if (archiveTarget.kind === 'agent') await onArchiveAgent(archiveTarget.agent);
|
||||
else await onArchiveSession(archiveTarget.sessionId);
|
||||
setArchiveTarget(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (deleteTarget.kind === 'agent') await onDeleteAgent(deleteTarget.agent);
|
||||
else await onDeleteSession(deleteTarget.sessionId);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const conversationHeader = (
|
||||
<div
|
||||
className="pointer-events-none flex h-10 w-full shrink-0 items-center justify-end gap-2 bg-surface-tertiary px-2"
|
||||
data-testid="agent-conversation-sidebar-header"
|
||||
>
|
||||
<div className="no-drag pointer-events-auto flex shrink-0 items-center gap-1">
|
||||
{onOpenProjectSettings ? (
|
||||
<Button type="button" size="icon" variant="ghost" className="no-drag h-8 w-8 rounded-full text-muted-foreground transition-colors duration-100 hover:bg-surface-subtle hover:text-foreground" aria-label="项目设置" title="项目设置" onClick={onOpenProjectSettings}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" size="icon" className={cn('group no-drag h-8 w-8 rounded-full border border-brand/20 transition-colors duration-100', createOpen ? 'bg-brand text-white hover:bg-brand-hover' : 'bg-brand-soft text-brand hover:bg-brand-hover hover:text-white')} aria-label="创建伙伴" aria-expanded={createOpen} onClick={() => { if (modelOptions.length === 0) { toast.error('请先配置至少一个可用模型。'); return; } setCreateOpen(true); }}>
|
||||
<Plus className={cn('h-4 w-4', createOpen ? 'text-white' : 'text-brand group-hover:text-white')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const projectContextHeader = (
|
||||
<div
|
||||
className="drag-region flex h-10 items-center gap-2 px-3"
|
||||
data-testid="agent-conversation-dialog-context"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span
|
||||
className="truncate text-sm font-semibold"
|
||||
data-testid="agent-conversation-project-context"
|
||||
>
|
||||
{projectName ?? 'Makelore 工作台'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const conversationSidebar = (
|
||||
<aside
|
||||
className={cn(
|
||||
'no-drag pointer-events-auto absolute inset-y-0 left-0 z-[60] flex min-h-0 w-[256px] min-w-[256px] max-w-[256px] flex-col overflow-hidden rounded-none border-r border-border/80 bg-surface-tertiary text-foreground',
|
||||
!hasCustomTitleBar && 'pt-10',
|
||||
)}
|
||||
data-testid="agent-conversation-sidebar"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-0" data-testid="agent-conversation-sidebar-content">
|
||||
<div className="space-y-1">
|
||||
{activeAgents.map((agent) => {
|
||||
const activity = agentActivity(agent);
|
||||
const selected = agent.id === selectedAgentId;
|
||||
const wasSelected = agent.id === previousSelectedAgentId && agent.id !== selectedAgentId;
|
||||
const status = getAgentStatus(activity);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
data-selected={selected}
|
||||
className={cn(
|
||||
'agent-switch-card group/agent-card relative overflow-hidden rounded-none border',
|
||||
selected ? 'border-brand/45 bg-brand-soft shadow-soft' : 'border-transparent hover:border-border/70 hover:bg-background',
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<button type="button" className="flex w-full min-w-0 items-center gap-2.5 px-2.5 py-2.5 pr-12 text-left" aria-pressed={selected} aria-expanded={selected} aria-busy={startingAgentId === agent.id} title={selected ? '收起最近会话' : '展开最近会话'} data-testid={`project-agent-chat-${agent.id}`} disabled={startingAgentId === agent.id} onClick={() => handleAgentSelection(agent.id)}>
|
||||
<span className="relative shrink-0"><img src={getAgentAvatarSrc(agent.avatarId, agent.avatarDataUrl)} alt="" className="h-11 w-11 rounded-xl border border-border object-cover [image-rendering:pixelated]" /></span>
|
||||
<span className="min-w-0 flex-1"><span className="flex items-center gap-1.5"><span className="truncate text-sm font-semibold">{agent.name || '未命名伙伴'}</span>{agent.pinned ? <Pin className="h-3 w-3 shrink-0 text-brand" /> : null}</span><span className="mt-0.5 block truncate text-[11px] text-muted-foreground">{agent.responsibility.mission || '还没有填写职责'}</span></span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span data-testid={`agent-status-row-${agent.id}`} className="inline-flex items-center gap-1">
|
||||
{status ? <span className="inline-flex max-w-24 items-center gap-1 text-[10px] font-medium text-muted-foreground" title={status.label}><span aria-hidden="true" className={cn('h-2 w-2 shrink-0 rounded-full', status.dot)} /><span className="truncate">{status.label}</span></span> : null}
|
||||
{activity.unread > 0 ? <Badge className="min-w-5 justify-center rounded-full border-0 bg-brand px-1.5 text-[10px] text-primary-foreground">{activity.unread > 99 ? '99+' : activity.unread}</Badge> : activity.pending > 0 ? <Badge className="min-w-5 justify-center rounded-full border-0 bg-amber-500 px-1.5 text-[10px] text-white">{activity.pending}</Badge> : null}
|
||||
</span>
|
||||
{activity.latestAt ? <span className="text-[10px] text-muted-foreground">{formatSessionTime(activity.latestAt)}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
<div className="absolute right-1 top-1/2 hidden -translate-y-1/2 flex-col gap-0.5 rounded-md bg-background/90 p-0.5 shadow-soft group-hover/agent-card:flex">
|
||||
<button type="button" className="rounded p-1.5 text-muted-foreground hover:bg-surface-subtle hover:text-brand" aria-label={agent.pinned ? '取消置顶伙伴' : '置顶伙伴'} title={agent.pinned ? '取消置顶' : '置顶'} onClick={() => void onTogglePinAgent(agent)}><Pin className={cn('h-3.5 w-3.5', agent.pinned && 'fill-current text-brand')} /></button>
|
||||
<button type="button" className="rounded p-1.5 text-muted-foreground hover:bg-surface-subtle hover:text-destructive" aria-label={`归档伙伴 ${agent.name || '未命名伙伴'}`} title="归档伙伴" onClick={() => setArchiveTarget({ kind: 'agent', agent })}><Archive className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
{renderAgentSessions(agent, selected, selected || wasSelected || exitingAgentIds.has(agent.id))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{activeAgents.length === 0 ? <div className="rounded-xl border border-dashed border-border/80 bg-background/60 p-5 text-center text-xs leading-5 text-muted-foreground">还没有伙伴<br />点击右上角的 + 创建第一个</div> : null}
|
||||
</div>
|
||||
|
||||
{archivedAgents.length > 0 ? <section className="mt-3 border-t border-border/70 pt-3"><button type="button" className="flex w-full items-center justify-between px-1 text-xs font-semibold text-muted-foreground" aria-expanded={showArchived} onClick={() => setShowArchived((current) => !current)}><span>已归档伙伴({archivedAgents.length})</span><ChevronRight className={cn('h-3.5 w-3.5', showArchived && 'rotate-90')} /></button><DisclosureContent open={showArchived} className="mt-1" innerClassName="space-y-1">{archivedAgents.map((agent) => <div key={agent.id} className="flex items-center gap-2 rounded-lg px-2.5 py-2 text-xs text-muted-foreground"><img src={getAgentAvatarSrc(agent.avatarId, agent.avatarDataUrl)} alt="" className="h-7 w-7 rounded-lg object-cover [image-rendering:pixelated]" /><span className="min-w-0 flex-1 truncate">{agent.name || '未命名伙伴'}</span><button type="button" className="rounded p-1.5 hover:bg-background hover:text-brand" aria-label={`恢复伙伴 ${agent.name || '未命名伙伴'}`} onClick={() => void onRestoreAgent(agent)}><RotateCcw className="h-3.5 w-3.5" /></button>{!agent.builtIn ? <button type="button" className="rounded p-1.5 text-muted-foreground hover:bg-accent-soft hover:text-destructive" aria-label={`永久删除伙伴 ${agent.name || '未命名伙伴'}`} title="永久删除伙伴" onClick={() => setDeleteTarget({ kind: 'agent', agent, sessionCount: getAgentSessions(agent.id).length })}><Trash2 className="h-3.5 w-3.5" /></button> : null}</div>)}</DisclosureContent></section> : null}
|
||||
</div>
|
||||
|
||||
<AgentCreationDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
modelOptions={modelOptions}
|
||||
skills={skills}
|
||||
existingAgentNames={agents.map((agent) => agent.name)}
|
||||
onCreate={onCreateAgent}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(archiveTarget)}
|
||||
title={archiveTarget?.kind === 'agent' ? `归档伙伴“${archiveTarget.agent.name || '未命名伙伴'}”?` : `归档会话“${archiveTarget?.title ?? ''}”?`}
|
||||
message={archiveTarget?.kind === 'agent' ? '归档会停止这个伙伴下正在运行的所有会话,但会保留伙伴、会话记录和未完成内容,之后可以在底部恢复。' : '归档会停止这个会话并保留全部历史,不会影响同一伙伴的其他会话。'}
|
||||
confirmLabel="确认归档"
|
||||
cancelLabel="取消"
|
||||
onCancel={() => setArchiveTarget(null)}
|
||||
onConfirm={() => void handleArchiveConfirm()}
|
||||
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={deleteTarget?.kind === 'agent' ? `永久删除伙伴“${deleteTarget.agent.name || '未命名伙伴'}”?` : `永久删除会话“${deleteTarget?.title ?? ''}”?`}
|
||||
message={deleteTarget?.kind === 'agent'
|
||||
? `这会永久删除伙伴及其 ${deleteTarget.sessionCount} 条会话记录,删除后无法恢复。`
|
||||
: '这会永久删除会话及其全部历史记录,删除后无法恢复。'}
|
||||
confirmLabel="永久删除"
|
||||
cancelLabel="取消"
|
||||
variant="destructive"
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => void handleDeleteConfirm()}
|
||||
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{typeof document !== 'undefined' ? createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed top-0 z-[80] w-[256px] border-r border-border/80"
|
||||
data-testid="agent-conversation-sidebar-titlebar-portal"
|
||||
style={{
|
||||
left: `${sidebarPinnedCollapsed ? 0 : MIN_LAYOUT_COLUMN_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
{conversationHeader}
|
||||
</div>,
|
||||
document.body,
|
||||
) : null}
|
||||
{typeof document !== 'undefined' ? createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed top-0 z-[80]"
|
||||
data-testid="agent-conversation-dialog-context-portal"
|
||||
style={{
|
||||
left: `${sidebarPinnedCollapsed ? MIN_LAYOUT_COLUMN_WIDTH : MIN_LAYOUT_COLUMN_WIDTH * 2}px`,
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-auto">{projectContextHeader}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="min-h-0 w-[256px] min-w-[256px] max-w-[256px] shrink-0 basis-[256px]"
|
||||
data-testid="agent-conversation-sidebar-spacer"
|
||||
/>
|
||||
{conversationSidebar}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Clock3,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DisclosureContent } from '@/components/ui/disclosure';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
OpencodeLifecycleState,
|
||||
OpencodeProject,
|
||||
OpencodeSession,
|
||||
OpencodeSessionStatus,
|
||||
OpencodeSessionStatusMap,
|
||||
} from '@/types/opencode';
|
||||
|
||||
function getSessionId(session: OpencodeSession): string {
|
||||
return String(session.id ?? session.sessionID ?? 'unknown-session');
|
||||
}
|
||||
|
||||
function getSessionTitle(session: OpencodeSession): string {
|
||||
const value = session.title ?? session.name ?? session.summary ?? session.id ?? session.sessionID;
|
||||
return typeof value === 'string' && value.trim() ? value : 'Untitled session';
|
||||
}
|
||||
|
||||
function getTimestampLabel(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function getSessionTimestamp(session: OpencodeSession): string | null {
|
||||
return getTimestampLabel(session.updatedAt ?? session.createdAt);
|
||||
}
|
||||
|
||||
function getProjectTimestamp(project: OpencodeProject): string | null {
|
||||
return getTimestampLabel(project.lastOpenedAt ?? project.updatedAt ?? project.createdAt);
|
||||
}
|
||||
|
||||
function getSessionStatusLabel(status: OpencodeSessionStatus | undefined): string {
|
||||
if (!status) return 'idle';
|
||||
if (status.type === 'retry' && typeof status.attempt === 'number') {
|
||||
return `retry ${status.attempt}`;
|
||||
}
|
||||
return status.type;
|
||||
}
|
||||
|
||||
function getSessionStatusVariant(status: OpencodeSessionStatus | undefined): 'default' | 'secondary' | 'outline' {
|
||||
if (!status) return 'secondary';
|
||||
if (status.type === 'busy') return 'default';
|
||||
if (status.type === 'retry') return 'outline';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
type ProjectSessionTreeProps = {
|
||||
projects: OpencodeProject[];
|
||||
activeProject: OpencodeProject | null;
|
||||
sessions: OpencodeSession[];
|
||||
selectedSessionId: string | null;
|
||||
sessionStatuses: OpencodeSessionStatusMap;
|
||||
pendingRequestCounts?: Record<string, number>;
|
||||
runtimeState: OpencodeLifecycleState;
|
||||
loading: boolean;
|
||||
onSelectProject: (projectId: string) => void | Promise<unknown>;
|
||||
onCreateSession: (projectId: string) => void | Promise<unknown>;
|
||||
onSelectSession: (sessionId: string) => void | Promise<unknown>;
|
||||
onStartRuntime?: () => void | Promise<unknown>;
|
||||
onDeleteSession?: (sessionId: string, label: string) => void | Promise<unknown>;
|
||||
onBeginRenameSession?: (session: OpencodeSession) => void;
|
||||
editingSessionId?: string | null;
|
||||
editingSessionTitle?: string;
|
||||
onEditingSessionTitleChange?: (title: string) => void;
|
||||
onSaveSessionTitle?: () => void | Promise<unknown>;
|
||||
onCancelSessionRename?: () => void;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function ProjectSessionTree({
|
||||
projects,
|
||||
activeProject,
|
||||
sessions,
|
||||
selectedSessionId,
|
||||
sessionStatuses,
|
||||
pendingRequestCounts = {},
|
||||
runtimeState,
|
||||
loading,
|
||||
onSelectProject,
|
||||
onCreateSession,
|
||||
onSelectSession,
|
||||
onStartRuntime,
|
||||
onDeleteSession,
|
||||
onBeginRenameSession,
|
||||
editingSessionId,
|
||||
editingSessionTitle = '',
|
||||
onEditingSessionTitleChange,
|
||||
onSaveSessionTitle,
|
||||
onCancelSessionRename,
|
||||
className,
|
||||
testId = 'opencode-project-session-tree',
|
||||
compact,
|
||||
}: ProjectSessionTreeProps) {
|
||||
const activeProjectId = activeProject?.id ?? null;
|
||||
const projectIdsKey = useMemo(() => projects.map((project) => project.id).join('\n'), [projects]);
|
||||
const [expandedProjectIds, setExpandedProjectIds] = useState<Set<string>>(() => (
|
||||
activeProjectId ? new Set([activeProjectId]) : new Set()
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const projectIds = new Set(projectIdsKey ? projectIdsKey.split('\n') : []);
|
||||
setExpandedProjectIds((current) => {
|
||||
const next = new Set<string>();
|
||||
let changed = false;
|
||||
|
||||
for (const id of current) {
|
||||
if (projectIds.has(id)) {
|
||||
next.add(id);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeProjectId && !next.has(activeProjectId)) {
|
||||
next.add(activeProjectId);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [activeProjectId, projectIdsKey]);
|
||||
|
||||
const runtimeRunning = runtimeState === 'running';
|
||||
|
||||
const toggleProjectExpanded = (projectId: string) => {
|
||||
setExpandedProjectIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId);
|
||||
} else {
|
||||
next.add(projectId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div data-testid={testId} className={cn('min-h-0 overflow-auto', className)}>
|
||||
<div className="flex min-h-24 items-center justify-center rounded-lg border border-dashed border-foreground/15 bg-background text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<FolderOpen className="mx-auto h-6 w-6" aria-hidden="true" />
|
||||
<p className="mt-2 text-xs font-semibold">No projects yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid={testId} className={cn('min-h-0 overflow-auto', className)}>
|
||||
<ul className={cn('space-y-1.5', compact && 'space-y-1')}>
|
||||
{projects.map((project) => {
|
||||
const active = project.id === activeProjectId;
|
||||
const expanded = expandedProjectIds.has(project.id);
|
||||
const timestamp = getProjectTimestamp(project);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={project.id}
|
||||
data-testid={`opencode-project-node-${project.id}`}
|
||||
className={cn(
|
||||
'rounded-lg border p-1 transition-colors duration-100',
|
||||
active
|
||||
? 'border-foreground/15 bg-brand-soft text-foreground shadow-soft'
|
||||
: 'border-border/70 bg-surface-subtle text-foreground',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1 px-1.5 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'mt-0.5 h-8 w-8 shrink-0 rounded-md border p-0',
|
||||
active
|
||||
? 'border-foreground/15 bg-background text-foreground hover:bg-surface-subtle'
|
||||
: 'border-border text-foreground hover:border-brand/25 hover:bg-background hover:text-foreground',
|
||||
)}
|
||||
aria-label={expanded ? `Collapse project ${project.name}` : `Expand project ${project.name}`}
|
||||
onClick={() => toggleProjectExpanded(project.id)}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'min-w-0 flex-1 rounded-md px-2 py-1.5 text-left transition-colors disabled:hover:bg-transparent',
|
||||
active ? 'hover:bg-background/45' : 'hover:bg-background hover:text-foreground',
|
||||
)}
|
||||
aria-label={active ? `Active project ${project.name}` : `Select project ${project.name}`}
|
||||
disabled={loading || active}
|
||||
onClick={() => void onSelectProject(project.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{project.name}</span>
|
||||
{active && (
|
||||
<Badge className="h-5 shrink-0 border border-foreground/15 bg-accent-soft px-1.5 text-[10px] font-semibold text-foreground">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('mt-1 truncate text-xs font-medium', active ? 'text-foreground' : 'text-muted-foreground')}>{project.path}</div>
|
||||
{timestamp && (
|
||||
<div className={cn('mt-1 flex items-center gap-1 text-[11px] font-medium', active ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
<Clock3 className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{timestamp}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'mt-0.5 h-8 w-8 shrink-0 rounded-md border p-0',
|
||||
active
|
||||
? 'border-foreground/15 bg-surface-tertiary text-foreground hover:bg-surface-subtle'
|
||||
: 'border-border text-foreground hover:border-brand/25 hover:bg-brand-soft hover:text-foreground',
|
||||
)}
|
||||
aria-label={`Create session in ${project.name}`}
|
||||
disabled={loading}
|
||||
onClick={() => void onCreateSession(project.id)}
|
||||
>
|
||||
{loading && active ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DisclosureContent
|
||||
open={expanded && active}
|
||||
className="mt-1"
|
||||
innerClassName="border-t border-foreground/15 px-1.5 pb-1.5 pt-2"
|
||||
>
|
||||
{!runtimeRunning ? (
|
||||
<div className="rounded-md border border-dashed border-foreground/15 bg-background px-3 py-3 text-center text-muted-foreground">
|
||||
<MessageSquareText className="mx-auto h-5 w-5" aria-hidden="true" />
|
||||
<p className="mt-1 text-xs font-semibold">{`Runtime is ${runtimeState}`}</p>
|
||||
{onStartRuntime && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-2 h-8 border border-foreground/15 bg-brand-soft px-2 text-xs font-semibold text-foreground"
|
||||
onClick={() => void onStartRuntime()}
|
||||
disabled={loading || runtimeState === 'starting'}
|
||||
>
|
||||
{loading || runtimeState === 'starting' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
Start runtime
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-foreground/15 bg-background px-3 py-3 text-center text-muted-foreground">
|
||||
<MessageSquareText className="mx-auto h-5 w-5" aria-hidden="true" />
|
||||
<p className="mt-1 text-xs font-semibold">No sessions</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{sessions.map((session) => {
|
||||
const sessionId = getSessionId(session);
|
||||
const title = getSessionTitle(session);
|
||||
const timestampLabel = getSessionTimestamp(session);
|
||||
const selected = sessionId === selectedSessionId;
|
||||
const editing = editingSessionId === sessionId && Boolean(onSaveSessionTitle);
|
||||
const pendingRequestCount = pendingRequestCounts[sessionId] ?? 0;
|
||||
const roleLabel = session.agent?.trim() || null;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={sessionId}
|
||||
data-testid={`opencode-session-node-${sessionId}`}
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-2 text-foreground transition-colors',
|
||||
selected
|
||||
? 'border-foreground/15 bg-surface-subtle'
|
||||
: 'border-foreground/15 bg-background hover:bg-surface-subtle',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{editing ? (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquareText className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<Input
|
||||
value={editingSessionTitle}
|
||||
onChange={(event) => onEditingSessionTitleChange?.(event.target.value)}
|
||||
placeholder="Rename session title"
|
||||
className="h-8 min-w-0 border border-foreground/15 bg-white text-foreground"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-md border border-foreground/15 bg-white text-foreground hover:bg-surface-subtle"
|
||||
aria-label="Save session title"
|
||||
onClick={() => void onSaveSessionTitle?.()}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-md border border-foreground/15 bg-white text-foreground hover:bg-surface-subtle"
|
||||
aria-label="Cancel session rename"
|
||||
onClick={onCancelSessionRename}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
aria-label={`Open session ${title}`}
|
||||
disabled={loading}
|
||||
onClick={() => void onSelectSession(sessionId)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareText className="h-4 w-4 shrink-0 text-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{title}</span>
|
||||
{pendingRequestCount > 0 && (
|
||||
<Badge
|
||||
className="h-5 shrink-0 gap-1 border border-foreground/15 bg-accent px-1.5 text-[10px] font-semibold text-foreground"
|
||||
aria-label={`Session ${title} needs input (${pendingRequestCount} pending)`}
|
||||
>
|
||||
<CircleAlert className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{pendingRequestCount}</span>
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={getSessionStatusVariant(sessionStatuses[sessionId])} className="h-5 border border-foreground/15 px-1.5 text-[10px] font-semibold">
|
||||
{getSessionStatusLabel(sessionStatuses[sessionId])}
|
||||
</Badge>
|
||||
</div>
|
||||
{roleLabel && (
|
||||
<Badge variant="outline" className="mt-1 h-5 max-w-full border-foreground/15 bg-white px-1.5 text-[10px] font-medium text-foreground">
|
||||
<span className="truncate">{roleLabel}</span>
|
||||
</Badge>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<Clock3 className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{timestampLabel ?? sessionId}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!editing && (onBeginRenameSession || onDeleteSession) && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{onBeginRenameSession && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-md border border-foreground/15 bg-white text-foreground hover:bg-surface-subtle"
|
||||
aria-label={`Rename session ${title}`}
|
||||
onClick={() => onBeginRenameSession(session)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDeleteSession && sessionId !== 'unknown-session' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-md border border-foreground/15 bg-white text-destructive hover:bg-accent-soft hover:text-foreground"
|
||||
aria-label={`Delete session ${title}`}
|
||||
onClick={() => void onDeleteSession(sessionId, title)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</DisclosureContent>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type WorksProjectCoverUpload,
|
||||
type WorksProjectMetadataInput,
|
||||
} from '@/lib/works-square';
|
||||
import type { OpencodeProject } from '@/types/opencode';
|
||||
import type { CodingProjectSummary } from '@/types/coding-project';
|
||||
import type { ProjectType } from '../../../shared/project-config';
|
||||
|
||||
type PublishPhase = 'idle' | 'submitting' | 'polling' | 'succeeded' | 'failed' | 'uncertain';
|
||||
@@ -48,7 +48,7 @@ const PROJECT_METADATA_CONFLICT_FAILURE: WorksPublishFailure = {
|
||||
};
|
||||
|
||||
type ProjectPublishActionProps = {
|
||||
project: OpencodeProject;
|
||||
project: CodingProjectSummary;
|
||||
projectType: Exclude<ProjectType, 'custom'>;
|
||||
buttonVariant?: ButtonProps['variant'];
|
||||
buttonSize?: ButtonProps['size'];
|
||||
|
||||
Reference in New Issue
Block a user