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:
2026-08-24 12:17:43 +08:00
parent 61fede2b4d
commit 5a275b93a7
199 changed files with 1330 additions and 64725 deletions

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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);
};

View File

@@ -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);

View File

@@ -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}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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'];