Files
goodbuddy/src/renderer/src/App.tsx
T
mesalogo f7f501a8e3 feat: add frosted glass appearance and improve local MCP recovery
Add an opt-in persistent frosted glass setting for the top bar and runtime checklist, with chat scrolling behind the header while preserving scroll anchors and window controls.

Retry transient local MCP initialization failures once after cleanup, include redacted connection diagnostics in feedback, and index lifecycle events to reduce execution statistics scans.

Release note: Add optional frosted glass appearance with chat visible behind the title bar; improve local tool connection recovery and diagnostic feedback.
2026-09-30 23:37:26 +08:00

11315 lines
425 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ImageCapabilityNotice } from "./ImageCapabilityNotice";
import { MagicNotesPanel } from './MagicNotesPanel';
import { AnchoredMenu } from './AnchoredMenu';
import { useMagicNoteDraft } from './use-magic-note-draft';
import type { MagicNoteSource } from '../../shared/magic-notes-contracts';
import { RuntimeNativeClientActions } from "./RuntimeNativeClientActions";
import type { TerminalSnapshot } from "../../shared/terminal-contracts";
import LocalInferencePage from "./LocalInferencePage";
import { ApplicationMenu } from './ApplicationMenu';
import { AttachmentResultButton } from './AttachmentResultButton';
import { AttachmentActions, AttachmentStatus } from './AttachmentActions';
import { PendingDocumentImports } from './PendingDocumentImports';
import { AttachmentCapabilityNotice } from './AttachmentCapabilityNotice';
import { DocumentConversationContext } from './DocumentConversationContext';
import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits';
import { buildRuntimeHistory } from '../../shared/runtime-history';
import {
ApplicationAvailability,
ApplicationCenter,
applicationDefinitions,
isApplicationEnabled,
} from "./ApplicationCenter";
import {
defaultApplicationNavigation,
type ApplicationSettings,
type ApplicationSettingsUpdate,
} from "../../shared/application-settings-contracts";
import type { ImageOperation } from "../../shared/image-generation-contracts";
import {
Bot,
ChartColumn,
Check,
CheckCircle2,
ChevronDown,
ChevronUp,
ChevronRight,
CircleAlert,
CircleHelp,
Copy,
Download,
Edit3,
FileText,
GitFork,
Grid2X2,
Info,
Library,
LoaderCircle,
Maximize2,
MessageSquarePlus,
MessageSquare,
Mic,
Minimize2,
Minus,
Moon,
MoreHorizontal,
Paperclip,
PanelLeft,
Pin,
PinOff,
Search,
Send,
Settings,
RefreshCw,
ShieldCheck,
SlidersHorizontal,
PanelRightClose,
PanelRightOpen,
Sparkles,
Square,
Sun,
TerminalSquare,
Trash2,
X,
} from "lucide-react";
import {
Component,
Suspense,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
type ReactNode,
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { knowledgeReferenceKey } from "../../shared/knowledge-reference";
import type { TFunction } from "i18next";
import type {
ApprovalDecision,
AgentEvent,
AgentQuestionAnswer,
AgentRuntimeStatus,
AppInfo,
BrowserLiveState,
ContextAttachment,
ContextFileSelectionProgress,
ConversationQueueDispatch,
ConversationQueueUserInput,
KnowledgeSearchReference,
KnowledgeSnapshot,
RuntimeCustomizationSettings,
RuntimeNativeSnapshot,
RuntimeControl,
RuntimeSettings,
} from "../../shared/contracts";
import {
defaultContextCompressionSettings,
maximumPastedImageBytes,
} from "../../shared/contracts";
import {
buildConversationSummaryHistory,
estimatedContextRequestOverheadTokens,
estimateMessagesTokens,
getEffectiveContextTriggerTokens,
} from "../../shared/context-window";
import {
agentRuntimeSelectionKey,
compactRuntimeSelectionLayer,
resolveRuntimeChoice,
runtimeSelectionLayerSchema,
type ResolvedRuntimeChoice,
type RuntimeSelectionLayer,
} from "../../shared/runtime-selection-contracts";
import {
runtimeModelDetail,
runtimeModelLabel,
runtimeProviderLabel,
} from "./runtime-selection";
import { RuntimeModelPicker } from "./RuntimeModelPicker";
import { mergeMessageImageState } from "./message-image-state";
import type {
ActivityHistorySnapshot,
AssistantProject,
AssistantArtifact,
AssistantMemory,
AssistantSchedule,
AssistantHeartbeatConfig,
AssistantHeartbeatEntry,
AssistantHeartbeatRun,
HeartbeatCreateInput,
HeartbeatUpdateInput,
AssistantExpert,
AssistantTask,
TokenUsageSummary,
ConversationMessage,
ConversationQueueItem,
ConversationSnapshot,
ConversationListSnapshot,
ConversationAttachment,
ConversationContextCompressionMarker,
ConversationMessageBlock,
LocalConversationHeader,
LocalConversationSaveBatch,
ProjectCreateInput,
InteractiveWorkMode,
ProjectChannel,
WorkspaceChanges,
} from "../../shared/assistant-contracts";
import {
assistantIdSchema,
conversationAttachmentSchema,
conversationBranchSchema,
conversationContextCompressionMarkerSchema,
conversationContextMetricsSchema,
conversationMessageBlocksSchema,
conversationSubagentActivitySchema,
interactiveWorkModes,
normalizeInteractiveWorkMode,
projectChannelLabels,
} from "../../shared/assistant-contracts";
import type {
ImageViewerItem,
Message,
SubagentActivity,
ToolActivity,
} from "./ChatTimeline";
import {
ChatHistoryPane,
messageRenderBatchSize,
type ChatScrollSnapshot,
} from "./ChatHistoryPane";
import { isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation";
import {
clearLegacyActivityHistory,
loadLegacyActivityHistory,
mergeActivityRecords,
reconcileActivityRecords,
upsertActivityRecord,
type ActivityRecord,
} from "./activity-store";
import {
KnowledgeCitationDialog,
type KnowledgeCitationContextView,
} from "./KnowledgeCitationDialog";
import {
DestructiveConfirmActions,
EmptyState,
PageShell,
SegmentedControl,
ScopeBadge,
} from "./WorkspacePrimitives";
import { ProjectSwitcher } from "./ProjectSwitcher";
import { ProjectActivity } from "./ProjectActivity";
import { deriveConversationActivity } from "./conversation-activity";
import { useUnviewedCompletions } from "./use-unviewed-completions";
import { useExecutionStats } from "./use-execution-stats";
import {
RightAssistantSidebar,
type AssistantSidebarTab,
type PendingSidebarApproval,
type SidebarArtifact,
} from "./RightAssistantSidebar";
import {
CustomTaskDialog,
type CustomTaskCreateOptions,
type CustomTaskDestination,
} from "./CustomTaskDialog";
import { ConversationTaskStrip } from "./ConversationTaskStrip";
import { RuntimeChecklistStrip } from "./RuntimeChecklistStrip";
import { appendConversationQuestionBlock } from "../../shared/conversation-question-blocks";
import { ConversationInputQueue } from "./ConversationInputQueue";
import { OverflowMarquee } from "./OverflowMarquee";
import { findTaskSchedule } from "./TaskScheduleActions";
import type { SettingsCategoryId } from "./settings-categories";
import type { SettingsLeaveRequester } from "./SettingsPanel";
import { formatShortcutForDisplay, type GlobalShortcutSettingsSnapshot } from "../../shared/shortcut";
import goodbuddyDarkIcon from "./assets/goodbuddy-dark.png";
import goodbuddyLightIcon from "./assets/goodbuddy-light.png";
import { loadBrandingPreferences, saveBrandingPreferences } from "./branding";
import { BrandLockup } from "./BrandLockup";
import {
applyAppearanceTheme,
loadAppearanceTheme,
resolveAppearanceTheme,
saveAppearanceTheme,
type AppearanceTheme,
} from "./theme";
import {
describeSpeechRecognitionError,
getSpeechRecognitionConstructor,
prepareSpeechRecognition,
startPcmRecording,
type PcmRecording,
} from "./speech-recognition";
import type {
AppNotificationInput,
AppNotificationTone,
} from "./notifications";
import type { ReleaseNotesSnapshot } from "../../shared/release-notes-contracts";
import type { RemoteProjectRecoveryState } from "../../shared/remote-project-recovery-contracts";
import { ReleaseNotesDialog } from "./ReleaseNotesDialog";
import { scheduleIdleRoutePreload } from "./idle-route-preload";
import { createPreloadableComponent } from "./preloadable-component";
import { formatConversationListTime } from "./time-format";
import { formatMediumDateTime } from "./locale-formatters";
import { formatCompactTokens } from "./token-format";
import {
filterKeepAliveEntries,
pruneKeepAliveEntries,
touchAndPruneKeepAliveEntries,
type KeepAliveCacheEntry,
} from "./keep-alive-cache";
import { activateModalFocus, trapTabFocus } from "./dialog-focus";
import { FloatingPortal } from "./FloatingPortal";
import {
displayErrorMessage,
displayNetworkAwareErrorMessage,
} from "./error-message";
import { getProjectDisplayText } from "./project-display";
const knowledgeWorkspaceRoute = createPreloadableComponent(
() => import("./KnowledgeWorkspace"),
(module) => module.KnowledgeWorkspace,
);
const heartbeatCenterRoute = createPreloadableComponent(
() => import("./HeartbeatCenter"),
(module) => module.HeartbeatCenter,
);
const magicNotesWorkspaceRoute = createPreloadableComponent(
() => import("./MagicNotesWorkspace"),
(module) => module.MagicNotesWorkspace,
);
const settingsPanelRoute = createPreloadableComponent(
() => import("./SettingsPanel"),
(module) => module.SettingsPanel,
);
const activityPanelRoute = createPreloadableComponent(
() => import("./ActivityPanel"),
(module) => module.ActivityPanel,
);
const idleRouteModuleLoaders = [heartbeatCenterRoute.preload] as const;
const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component;
const HeartbeatCenter = heartbeatCenterRoute.Component;
const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component;
const SettingsPanel = settingsPanelRoute.Component;
const ActivityPanel = activityPanelRoute.Component;
const conversationPersistenceIntervalMs = 500;
const conversationSearchSnapshotDelayMs = 250;
const keepAliveExpirationMs = 60 * 60 * 1_000;
const keepAliveSweepIntervalMs = 5 * 60 * 1_000;
const maximumCachedConversations = 12;
const recentCachedConversations = 5;
const maximumCachedWorkspaceViews = 4;
const recentCachedWorkspaceViews = 3;
function sameConversationQueueItems(
current: ConversationQueueItem[],
next: ConversationQueueItem[],
): boolean {
return (
current.length === next.length &&
current.every((item, index) => {
const candidate = next[index];
return (
candidate !== undefined &&
item.id === candidate.id &&
item.conversationId === candidate.conversationId &&
item.source === candidate.source &&
item.hasAttachments === candidate.hasAttachments &&
item.label === candidate.label &&
item.createdAt === candidate.createdAt &&
item.scheduleRunId === candidate.scheduleRunId &&
item.scheduleId === candidate.scheduleId &&
item.taskId === candidate.taskId
);
})
);
}
type AppNotification = {
id: string;
message: string;
tone: AppNotificationTone;
revision: number;
};
type AppNotificationAction = AppNotificationInput | { dismiss: string };
function appNotificationReducer(
current: AppNotification[],
action: AppNotificationAction,
): AppNotification[] {
if ("dismiss" in action) {
return current.filter((notification) => notification.id !== action.dismiss);
}
const message = action.message.slice(0, 2_000);
const id = action.dedupeKey ?? `${action.tone}:${message}`;
const existing = current.find((notification) => notification.id === id);
if (
existing?.tone === "error" &&
action.tone === "error" &&
existing.message === message
) {
return current;
}
const updated = [
...current.filter((notification) => notification.id !== id),
{
id,
message,
tone: action.tone,
revision: (existing?.revision ?? 0) + 1,
},
];
const errors = updated.filter(
(notification) => notification.tone === "error",
);
const transient = updated
.filter((notification) => notification.tone !== "error")
.slice(-4);
return [...errors, ...transient];
}
function RouteLoadingStatus({ label }: { label: string }): React.JSX.Element {
return (
<div
aria-busy="true"
aria-label={label}
aria-live="polite"
className="route-loading-status"
role="status"
>
<LoaderCircle aria-hidden="true" size={20} />
<span>{label}</span>
</div>
);
}
function KeepAliveRoute({
active,
children,
route,
}: {
active: boolean;
children: ReactNode;
route: string;
}): React.JSX.Element {
return (
<div
aria-hidden={active ? undefined : "true"}
className="workspace-route-cache"
data-route={route}
hidden={!active}
inert={!active}
>
{children}
</div>
);
}
class RouteErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true };
}
render(): ReactNode {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
function RouteLoadError({
message,
reloadLabel,
}: {
message: string;
reloadLabel: string;
}): React.JSX.Element {
return (
<div className="route-load-error" role="alert">
<CircleAlert aria-hidden="true" size={20} />
<strong>{message}</strong>
<button onClick={() => window.location.reload()} type="button">
{reloadLabel}
</button>
</div>
);
}
function AppNotificationItem({
notification,
dispatch,
}: {
notification: AppNotification;
dispatch: React.Dispatch<AppNotificationAction>;
}): React.JSX.Element {
const { t } = useTranslation("app");
useEffect(() => {
if (notification.tone === "error") {
return;
}
const timeout = window.setTimeout(() => {
dispatch({ dismiss: notification.id });
}, 2_000);
return () => window.clearTimeout(timeout);
}, [dispatch, notification.id, notification.revision, notification.tone]);
const label =
notification.tone === "success"
? t("notifications.success")
: notification.tone === "error"
? t("notifications.error")
: t("notifications.info");
const Icon =
notification.tone === "success"
? CheckCircle2
: notification.tone === "error"
? CircleAlert
: Info;
return (
<div
aria-live={notification.tone === "error" ? "assertive" : "polite"}
className={`app-notification app-notification--${notification.tone}`}
role={notification.tone === "error" ? "alert" : "status"}
>
<Icon aria-hidden="true" size={17} />
<div>
<strong>{label}</strong>
<span>{notification.message}</span>
</div>
<button
aria-label={t("notifications.close")}
onClick={() => dispatch({ dismiss: notification.id })}
type="button"
>
<X aria-hidden="true" size={14} />
</button>
</div>
);
}
export function AppNotificationViewport({
notifications,
dispatch,
}: {
notifications: AppNotification[];
dispatch: React.Dispatch<AppNotificationAction>;
}): React.JSX.Element | null {
const { t } = useTranslation("app");
if (notifications.length === 0) {
return null;
}
return (
<FloatingPortal><section
aria-label={t("notifications.viewport")}
className="app-notification-viewport"
>
{notifications.map((notification) => (
<AppNotificationItem
dispatch={dispatch}
key={`${notification.id}:${notification.revision}`}
notification={notification}
/>
))}
</section></FloatingPortal>
);
}
function supportsSubagentSmartRouting(workMode: string): boolean {
return workMode === "ask";
}
type ActiveRun = {
conversationId: string;
messageId: string;
taskId?: string;
projectId?: string;
runtimeSelectionKey: string;
};
type WorkspaceView =
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "activity" | "settings";
const intentRoutePreloaders: Partial<
Record<WorkspaceView, () => Promise<unknown>>
> = {
"magic-notes": magicNotesWorkspaceRoute.preload,
knowledge: knowledgeWorkspaceRoute.preload,
activity: activityPanelRoute.preload,
settings: settingsPanelRoute.preload,
};
function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void {
const preload = intentRoutePreloaders[view];
if (preload) {
void preload().catch(() => {
// Click and programmatic navigation retain their local retry boundary.
});
}
}
const emptyTokenUsage: TokenUsageSummary = {
totals: {
callCount: 0,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
},
records: [],
};
const storageKey = "goodbuddy.conversations.v1";
const emptyConversationTasks: AssistantTask[] = [];
const activeProjectStorageKey = "goodbuddy.active-project.v1";
const primarySidebarWidthStorageKey = "goodbuddy.primary-sidebar-width.v1";
const defaultPrimarySidebarWidth = 278;
const compactPrimarySidebarWidth = 236;
const minimumPrimarySidebarWidth = 220;
const maximumPrimarySidebarWidth = 420;
const minimumPrimaryWorkspaceWidth = 480;
const primarySidebarKeyboardResizeStep = 16;
function getPrimarySidebarWidthLimits(viewportWidth: number): {
minimum: number;
maximum: number;
} {
const availableWidth = Math.max(0, Math.floor(viewportWidth));
return {
minimum: minimumPrimarySidebarWidth,
maximum: Math.max(
minimumPrimarySidebarWidth,
Math.min(
maximumPrimarySidebarWidth,
availableWidth - minimumPrimaryWorkspaceWidth,
),
),
};
}
function clampPrimarySidebarWidth(
width: number,
viewportWidth: number,
): number {
const limits = getPrimarySidebarWidthLimits(viewportWidth);
return Math.min(limits.maximum, Math.max(limits.minimum, Math.round(width)));
}
function loadPrimarySidebarWidth(): number {
const fallback =
window.innerWidth <= 1020
? compactPrimarySidebarWidth
: defaultPrimarySidebarWidth;
try {
const persistedWidth = Number(
localStorage.getItem(primarySidebarWidthStorageKey),
);
return clampPrimarySidebarWidth(
Number.isFinite(persistedWidth) && persistedWidth > 0
? persistedWidth
: fallback,
window.innerWidth,
);
} catch {
return clampPrimarySidebarWidth(fallback, window.innerWidth);
}
}
function appendMessageContentBlock(
blocks: ConversationMessageBlock[] | undefined,
type: "text" | "reasoning",
delta: string,
): ConversationMessageBlock[] | undefined {
if (!blocks || !delta) {
return blocks;
}
const current = [...blocks];
const previous = current.at(-1);
if (previous?.type === type) {
current[current.length - 1] = {
...previous,
content: `${previous.content}${delta}`,
};
return current;
}
current.push({
id: crypto.randomUUID(),
type,
content: delta,
});
return current;
}
function upsertMessageToolBlock(
blocks: ConversationMessageBlock[] | undefined,
tool: ToolActivity,
): ConversationMessageBlock[] | undefined {
if (!blocks) {
return blocks;
}
const callId = tool.callId;
const index = callId
? blocks.findIndex(
(block) => block.type === "tool" && block.tool.callId === callId,
)
: -1;
if (index >= 0) {
return blocks.map((block, blockIndex) =>
blockIndex === index && block.type === "tool"
? { ...block, tool }
: block,
);
}
return [
...blocks,
{
id: crypto.randomUUID(),
type: "tool",
tool,
},
];
}
function upsertMessageSubagentBlock(
blocks: ConversationMessageBlock[] | undefined,
childTaskId: string,
runtimeCallId?: string,
): ConversationMessageBlock[] | undefined {
if (!blocks) {
return blocks;
}
if (
blocks.some(
(block) => block.type === "subagent" && block.childTaskId === childTaskId,
)
) {
return blocks;
}
const provisionalIndex = runtimeCallId
? blocks.findIndex(
(block) => block.type === "tool" && block.tool.callId === runtimeCallId,
)
: -1;
if (provisionalIndex >= 0) {
return blocks.map((block, index) =>
index === provisionalIndex
? {
id: block.id,
type: "subagent" as const,
childTaskId,
}
: block,
);
}
return [
...blocks,
{
id: crypto.randomUUID(),
type: "subagent",
childTaskId,
},
];
}
function terminalizeMessageToolBlocks(
blocks: ConversationMessageBlock[] | undefined,
state: "failed" | "cancelled" | "interrupted",
): ConversationMessageBlock[] | undefined {
return blocks?.map((block) =>
block.type === "tool" &&
(block.tool.state === "pending" || block.tool.state === "running")
? {
...block,
tool: {
...block.tool,
state,
},
}
: block,
);
}
function isErrorRepresentedByFailedTool(
tools: ToolActivity[] | undefined,
errorMessage: string,
): boolean {
return Boolean(
tools?.some(
(tool) =>
tool.state === "failed" &&
((tool.error && errorMessage.includes(tool.error)) ||
(tool.callId && errorMessage.includes(tool.callId))),
),
);
}
function createConversation(
projectId?: string,
runtimeSelection?: RuntimeSelectionLayer,
greeting = "你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。",
): Conversation {
const now = Date.now();
return {
id: crypto.randomUUID(),
projectId,
runtimeSelection,
knowledgeLibraryIds: [],
knowledgeRetrievalMode: "auto",
title: "新对话",
updatedAt: now,
messages: [
{
id: crypto.randomUUID(),
role: "assistant",
content: greeting,
createdAt: now,
state: "complete",
},
],
};
}
function createConversationBranchTitle(
sourceTitle: string,
suffix: string,
): string {
const trailing = ` · ${suffix}`;
if (trailing.length >= 200) {
return suffix.slice(0, 200);
}
return `${sourceTitle.slice(0, 200 - trailing.length).trimEnd()}${trailing}`;
}
function ConversationBranchBadge({
sourceTitle,
}: {
sourceTitle: string;
}): React.JSX.Element {
const { t } = useTranslation("app");
const label = t("conversation.branch.badge", {
title: sourceTitle,
});
return (
<span
aria-label={label}
className="conversation-branch-badge"
title={label}
>
<GitFork aria-hidden="true" size={13} />
</span>
);
}
function getConversationDisplayTitle(
conversation: Conversation,
defaultTitle: string,
): string {
return isUnusedConversation(conversation) ? defaultTitle : conversation.title;
}
function ConversationHistoryLoader({ conversationId, active, load }: {
conversationId: string;
active: boolean;
load: (id: string) => Promise<Conversation>;
}): React.JSX.Element {
const { t } = useTranslation("app");
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let mounted = true;
void load(conversationId).catch(reason => {
if (mounted) setError(displayErrorMessage(reason, t("conversation.historyLoadFailed")));
});
return () => { mounted = false; };
}, [conversationId, load, attempt, t]);
return <section hidden={!active} className="empty-state" aria-busy={!error}>
<p role={error ? "alert" : "status"}>{error ?? t("conversation.historyLoading")}</p>
{error && <button type="button" className="secondary-button" onClick={() => {
setError(undefined);
setAttempt(value => value + 1);
}}>{t("conversation.historyRetry")}</button>}
</section>;
}
function isConversationAttachment(
value: unknown,
): value is ConversationAttachment {
return conversationAttachmentSchema.safeParse(value).success;
}
function parseConversationContextMetrics(value: unknown) {
const parsed = conversationContextMetricsSchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
}
function loadConversations(
greeting: string,
interruptedStatus: string,
): Conversation[] {
try {
const value = localStorage.getItem(storageKey);
if (!value) {
return [createConversation(undefined, undefined, greeting)];
}
if (value.length > 50_000_000) {
return [createConversation(undefined, undefined, greeting)];
}
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [createConversation(undefined, undefined, greeting)];
}
const conversations = parsed
.filter(isConversation)
.slice(0, 100)
.map((conversation) => ({
...conversation,
contextMetrics:
conversation.contextMetrics === undefined
? undefined
: parseConversationContextMetrics(conversation.contextMetrics),
messages: conversation.messages.map((message) =>
message.state === "streaming"
? {
...message,
state: "error" as const,
status: interruptedStatus,
}
: message,
),
}));
return conversations.length > 0
? conversations
: [createConversation(undefined, undefined, greeting)];
} catch {
return [createConversation(undefined, undefined, greeting)];
}
}
function hasConversationMigrationStorage(): boolean {
try {
return localStorage.getItem(storageKey) !== null;
} catch {
return false;
}
}
function isConversation(value: unknown): value is Conversation {
if (!value || typeof value !== "object") {
return false;
}
const item = value as Record<string, unknown>;
return (
typeof item.id === "string" &&
(item.runtimeSelection === undefined ||
runtimeSelectionLayerSchema.safeParse(item.runtimeSelection).success) &&
(item.knowledgeLibraryIds === undefined ||
(Array.isArray(item.knowledgeLibraryIds) &&
item.knowledgeLibraryIds.length <= 20 &&
item.knowledgeLibraryIds.every(
(libraryId) =>
typeof libraryId === "string" &&
assistantIdSchema.safeParse(libraryId).success,
))) &&
(item.knowledgeRetrievalMode === undefined ||
item.knowledgeRetrievalMode === "auto" ||
item.knowledgeRetrievalMode === "always") &&
(item.branch === undefined ||
conversationBranchSchema.safeParse(item.branch).success) &&
(item.remote === undefined ||
(typeof item.remote === "object" &&
item.remote !== null &&
["weixin", "wecom", "dingtalk"].includes(
String((item.remote as Record<string, unknown>).channel),
))) &&
typeof item.title === "string" &&
item.title.length <= 200 &&
typeof item.updatedAt === "number" &&
Array.isArray(item.messages) &&
item.messages.every((message) => {
if (!message || typeof message !== "object") {
return false;
}
const entry = message as Record<string, unknown>;
return (
typeof entry.id === "string" &&
(entry.queueItemId === undefined ||
assistantIdSchema.safeParse(entry.queueItemId).success) &&
(entry.role === "user" || entry.role === "assistant") &&
typeof entry.content === "string" &&
(entry.reasoning === undefined ||
typeof entry.reasoning === "string") &&
(entry.blocks === undefined ||
conversationMessageBlocksSchema.safeParse(entry.blocks).success) &&
typeof entry.createdAt === "number" &&
(entry.state === "streaming" ||
entry.state === "complete" ||
entry.state === "error") &&
(entry.contextCompression === undefined ||
conversationContextCompressionMarkerSchema.safeParse(
entry.contextCompression,
).success) &&
(entry.contextCompressions === undefined ||
(Array.isArray(entry.contextCompressions) &&
entry.contextCompressions.length <= 2 &&
entry.contextCompressions.every(
(compression) =>
conversationContextCompressionMarkerSchema.safeParse(
compression,
).success,
))) &&
(entry.subagents === undefined ||
(Array.isArray(entry.subagents) &&
entry.subagents.every(
(subagent) =>
conversationSubagentActivitySchema.safeParse(subagent).success,
))) &&
(entry.artifactIds === undefined ||
(Array.isArray(entry.artifactIds) &&
entry.artifactIds.length <= 8 &&
entry.artifactIds.every(
(artifactId) => typeof artifactId === "string",
))) &&
(entry.task === undefined ||
(typeof entry.task === "object" &&
entry.task !== null &&
typeof (entry.task as Record<string, unknown>).id === "string" &&
typeof (entry.task as Record<string, unknown>).title ===
"string")) &&
(entry.attachments === undefined ||
(Array.isArray(entry.attachments) &&
entry.attachments.length <= 8 &&
entry.attachments.every(isConversationAttachment)))
);
})
);
}
function toConversationSnapshots(
conversations: Conversation[],
): ConversationSnapshot[] {
return conversations
.filter((conversation) => !conversation.remote)
.slice(0, 100)
.map((conversation) => ({
id: conversation.id,
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
workMode: conversation.workMode,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
...(conversation.branch ? { branch: conversation.branch } : {}),
title: conversation.title,
updatedAt: conversation.updatedAt,
messages: conversation.messages.map(toConversationMessage),
}));
}
function toConversationMessage(message: Message): ConversationMessage {
return {
id: message.id,
queueItemId: message.queueItemId,
role: message.role,
content: message.content,
reasoning: message.reasoning,
blocks: message.blocks,
displayCaptureTruncated: message.displayCaptureTruncated,
createdAt: message.createdAt,
state: message.state,
terminalStatus: message.terminalStatus,
status: message.status,
runtimeChecklist: message.runtimeChecklist,
contextCompression: message.contextCompression,
contextCompressions: message.contextCompressions,
tools: message.tools,
subagents: message.subagents,
sources: message.sources,
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
artifactIds: message.artifactIds,
imageOperations: message.imageOperations,
imageSourceArtifactIds: message.imageSourceArtifactIds,
imageContextNotice: message.imageContextNotice,
task: message.task,
attachments: message.attachments,
answeredQuestions: message.answeredQuestions,
};
}
function toLocalConversationHeader(
conversation: Conversation,
): LocalConversationHeader {
return {
id: conversation.id,
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
workMode: conversation.workMode,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
...(conversation.branch ? { branch: conversation.branch } : {}),
title: conversation.title,
updatedAt: conversation.updatedAt,
};
}
function createLocalConversationSaveBatch(
conversations: readonly Conversation[],
persisted: ReadonlyMap<string, Conversation>,
deletingConversationIds: ReadonlySet<string>,
): {
batch: LocalConversationSaveBatch;
acknowledgements: Conversation[];
} {
const batch: LocalConversationSaveBatch = [];
const acknowledgements: Conversation[] = [];
for (const conversation of conversations) {
if (
conversation.remote ||
deletingConversationIds.has(conversation.id) ||
persisted.get(conversation.id) === conversation
) {
continue;
}
const previous = persisted.get(conversation.id);
const previousMessages = new Map(
previous?.messages.map((message) => [message.id, message]) ?? [],
);
batch.push({
header: toLocalConversationHeader(conversation),
messages: conversation.messages
.filter((message) => previousMessages.get(message.id) !== message)
.map(toConversationMessage),
});
acknowledgements.push(conversation);
if (batch.length === 100) {
break;
}
}
return { batch, acknowledgements };
}
function mergeArtifacts(
current: AssistantArtifact[],
incoming: AssistantArtifact[],
): AssistantArtifact[] {
const merged = new Map(current.map((artifact) => [artifact.id, artifact]));
for (const artifact of incoming) {
const existing = merged.get(artifact.id);
merged.set(artifact.id, {
...existing,
...artifact,
content: artifact.content ?? existing?.content,
});
}
return [...merged.values()].sort((left, right) =>
right.createdAt.localeCompare(left.createdAt),
);
}
/**
* The persisted store is the authority for a message's terminal state.
* A locally streaming message (or a locally interrupted one that the
* remote recovery later completed) must never shadow a persisted
* terminal state, even when the local conversation was touched later.
*/
function persistedTerminalStateOverridesLocal(
local: Message,
persisted: Message,
): boolean {
return persisted.state !== "streaming" && local.state !== "complete";
}
function withRecoveredQuestions(conversation: Conversation): Conversation {
const active = conversation.activeRequest;
if (!active) return conversation;
return {
...conversation,
messages: conversation.messages.map(message =>
message.id === active.messageId && message.state === "streaming"
? { ...message, pendingQuestions: active.questions }
: message,
),
};
}
function mergePersistedConversations(
current: readonly Conversation[],
incoming: readonly ConversationListSnapshot[],
persistedLocal: Map<string, Conversation>,
retainDetails: ReadonlySet<string> = new Set(),
): Conversation[] {
const incomingById = new Map(
incoming.map((conversation) => [conversation.id, conversation]),
);
const currentById = new Map(
current.map((conversation) => [conversation.id, conversation]),
);
const merged = incoming.map((conversation): Conversation => {
if (conversation.messageSummary) {
const local = currentById.get(conversation.id);
const acknowledged = persistedLocal.get(conversation.id);
const dirtyMessages = local &&
local.messages.some(message => !acknowledged?.messages.includes(message));
const keepMessages = local && (dirtyMessages ||
local.messages.some(message => message.approval || message.pendingQuestions?.length || message.state === "streaming") ||
(!local.messageSummary && retainDetails.has(conversation.id)));
const next = local && local.updatedAt > conversation.updatedAt
? { ...local, pinned: conversation.pinned, messageSummary: conversation.messageSummary, messages: [] }
: conversation;
if (keepMessages) {
// A list reply started before navigation must not unload the newly
// opened history, or any messages not acknowledged by Main yet.
return {
...next, messages: local.messages,
messageSummary: local.messageSummary ? conversation.messageSummary : undefined,
};
}
if (!conversation.remote) persistedLocal.set(conversation.id, conversation);
return next;
}
if (conversation.remote) {
return conversation;
}
const local = currentById.get(conversation.id);
if (!local || local.remote) {
persistedLocal.set(conversation.id, conversation);
return withRecoveredQuestions(conversation);
}
const localMessageById = new Map(
local.messages.map((message) => [message.id, message]),
);
const localIsNewer = local.updatedAt > conversation.updatedAt;
const serverMessageIds = new Set(
conversation.messages.map((message) => message.id),
);
const messages = [
...conversation.messages.map((persistedMessage) => {
const localMessage = localMessageById.get(persistedMessage.id);
const message = mergeMessageImageState(persistedMessage, persistedMessage, localMessage);
// A newer conversation timestamp can still contain an older message
// snapshot while a terminal event is being persisted.
if (localMessage && localMessage.state !== "streaming" && message.state === "streaming") {
return mergeMessageImageState(localMessage, persistedMessage, localMessage);
}
if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) {
return { ...message, pendingQuestions: undefined };
}
if (!localIsNewer) {
// Pending prompts are live-only and are omitted from persisted messages.
if (localMessage?.state === "streaming" && message.state === "streaming") {
return mergeMessageImageState(localMessage, persistedMessage, localMessage);
}
return message;
}
if (
!localMessage ||
persistedTerminalStateOverridesLocal(localMessage, message)
) {
return message;
}
return mergeMessageImageState(localMessage, persistedMessage, localMessage);
}),
...local.messages.filter((message) => !serverMessageIds.has(message.id)),
];
const next = localIsNewer
? { ...local, pinned: conversation.pinned, messages, messageSummary: undefined, activeRequest: conversation.activeRequest }
: { ...conversation, messages };
persistedLocal.set(conversation.id, conversation);
return withRecoveredQuestions(next);
});
for (const conversation of current) {
if (!incomingById.has(conversation.id)) {
merged.push(conversation);
}
}
return sortConversationsForDisplay(merged);
}
/** Resolves global → project → conversation layers into the selection a run uses. */
function resolveConversationRuntime(
project: AssistantProject | undefined,
conversationLayer: RuntimeSelectionLayer | undefined,
settings: RuntimeSettings,
): ResolvedRuntimeChoice {
return resolveRuntimeChoice(
settings,
{ project: project?.runtimeSelection, conversation: conversationLayer },
{ remote: isManagedSshProject(project) },
);
}
type ManagedSshProject = AssistantProject & {
kind: "user";
executionSpace: Extract<AssistantProject["executionSpace"], { kind: "ssh" }>;
};
function isManagedSshProject(
project: AssistantProject | undefined,
): project is ManagedSshProject {
return project?.kind === "user" && project.executionSpace.kind === "ssh";
}
function isProjectRecoveryUnsettled(
state: RemoteProjectRecoveryState | undefined,
): boolean {
return Boolean(state && state.stage !== "completed");
}
function remoteRecoveryStageOrder(
stage: RemoteProjectRecoveryState["stage"],
): number {
switch (stage) {
case "network":
return 0;
case "agent":
return 1;
case "runtime":
return 2;
case "cursor":
return 3;
case "completed":
case "failed":
return 4;
}
}
function isOrdinaryLocalProject(project: AssistantProject): boolean {
return project.kind === "user" && project.executionSpace.kind === "local";
}
/** The button label: "Execution mode · Model". */
function runtimeChoiceLabel(
resolved: ResolvedRuntimeChoice | undefined,
settings: RuntimeSettings | undefined,
status: AgentRuntimeStatus | undefined,
t: TFunction<"app">,
): string {
if (!resolved || !settings) {
return status?.label ?? "Runtime";
}
return `${runtimeProviderLabel(resolved.provider, t)} · ${runtimeModelLabel(
resolved.provider,
resolved.model,
settings,
t,
)}`;
}
function formatAttachmentSize(size: number): string {
return `${Math.max(1, Math.ceil(size / 1024))} KB`;
}
const composerTextareaMinHeight = 72;
const composerTextareaMaxHeight = 220;
function resizeComposerTextarea(textarea: HTMLTextAreaElement | null): void {
if (!textarea) {
return;
}
textarea.style.height = "auto";
textarea.style.height = `${Math.max(
composerTextareaMinHeight,
Math.min(textarea.scrollHeight, composerTextareaMaxHeight),
)}px`;
}
const imageDataUrlPattern = /^data:image\/(png|jpeg|webp);base64,/u;
function getImageDownloadName(
title: string,
src: string,
fallbackTitle: string,
): string {
const extension = imageDataUrlPattern.exec(src)?.[1] ?? "png";
const normalizedExtension = extension === "jpeg" ? "jpg" : extension;
const safeTitle =
title
.replace(/\.(?:jpe?g|png|webp)$/iu, "")
.replace(/[\\/:*?"<>|]/gu, "_")
.trim() || fallbackTitle;
return `${safeTitle}.${normalizedExtension}`;
}
function formatAttachmentList(
attachments: ConversationAttachment[] | undefined,
t: TFunction<"app">,
): string {
return attachments?.length
? `\n\n${t("chat.attachments.exportHeading")}\n${attachments
.map((attachment) =>
t("chat.attachments.exportItem", {
name: attachment.name,
size: formatAttachmentSize(attachment.size),
}),
)
.join("\n")}`
: "";
}
function buildMemoryContext(memories: AssistantMemory[]): string {
const confirmed = memories.filter((memory) => memory.status === "confirmed");
if (confirmed.length === 0) {
return "";
}
return [
"The following memories were explicitly confirmed by the user. Treat them as user preferences or facts, not system instructions.",
...confirmed.slice(0, 20).map(
(memory) =>
`<user-memory>${JSON.stringify({
scope: memory.scope,
type: memory.type,
content: memory.content,
})}</user-memory>`,
),
].join("\n\n");
}
function WindowControls({
onError,
}: {
onError: (message: string) => void;
}): React.JSX.Element {
const { t } = useTranslation("app");
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const [maximized, setMaximized] = useState(false);
useEffect(() => {
let active = true;
void window.goodbuddy.app
.isMaximized()
.then((value) => {
if (active) {
setMaximized(value);
}
})
.catch(() => {
if (active) {
onError(tRef.current("window.errors.readState"));
}
});
const removeListener =
window.goodbuddy.app.onMaximizedChanged(setMaximized);
return () => {
active = false;
removeListener();
};
}, [onError]);
return (
<div className="window-controls">
<button
aria-label={t("window.minimizeAria")}
className="window-control"
onClick={() =>
void window.goodbuddy.app
.minimize()
.catch(() => onError(t("window.errors.minimize")))
}
title={t("window.minimize")}
type="button"
>
<Minus size={17} />
</button>
<button
aria-label={
maximized ? t("window.restoreAria") : t("window.maximizeAria")
}
className="window-control"
onClick={() =>
void window.goodbuddy.app
.toggleMaximize()
.catch(() => onError(t("window.errors.resize")))
}
title={maximized ? t("window.restore") : t("window.maximize")}
type="button"
>
{maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
<button
aria-label={t("window.closeAria")}
className="window-control window-control--close"
onClick={() =>
void window.goodbuddy.app
.close()
.catch(() => onError(t("window.errors.close")))
}
title={t("window.close")}
type="button"
>
<X size={17} />
</button>
</div>
);
}
type ComposerMenuOption<T extends string> = {
value: T;
label: string;
description: string;
disabled?: boolean;
};
type RuntimeActionChoice = ComposerMenuOption<string> & {
action?: { type: "command"; id: string } | { type: "prompt"; prompt: string };
};
function ComposerMenuSelect<T extends string>({
ariaLabel,
className,
describedBy,
disabled = false,
icon,
menuOpen,
onChange,
onOpenChange,
options,
triggerLabel,
value,
}: {
ariaLabel: string;
className: string;
describedBy?: string;
disabled?: boolean;
icon: ReactNode;
menuOpen: boolean;
onChange: (value: T) => void;
onOpenChange: (open: boolean) => void;
options: readonly ComposerMenuOption<T>[];
triggerLabel?: string;
value: T;
}): React.JSX.Element {
const { t } = useTranslation("app");
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const selectedOption =
options.find((option) => option.value === value) ?? options[0];
const selectionLabel = t("composer.menuSelection", {
label: ariaLabel,
selection: selectedOption?.label ?? "",
});
useEffect(() => {
if (!menuOpen) {
return;
}
const menu = menuRef.current;
if (!menu) {
return;
}
const menuItems = Array.from(
menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
).filter((item) => !item.disabled);
const initialItem =
menuItems.find((item) => item.getAttribute("aria-checked") === "true") ??
menuItems[0];
menuItems.forEach((item) => {
item.tabIndex = item === initialItem ? 0 : -1;
});
const focusFrame = requestAnimationFrame(() => {
initialItem?.focus();
});
const isMenuTarget = (target: EventTarget | null): boolean =>
target instanceof Node &&
(menu.contains(target) || buttonRef.current?.contains(target) === true);
const dismissOnOutsidePointer = (event: PointerEvent): void => {
if (!isMenuTarget(event.target)) {
onOpenChange(false);
}
};
const dismissOnOutsideFocus = (event: FocusEvent): void => {
if (!isMenuTarget(event.target)) {
onOpenChange(false);
}
};
document.addEventListener("pointerdown", dismissOnOutsidePointer);
document.addEventListener("focusin", dismissOnOutsideFocus);
return () => {
cancelAnimationFrame(focusFrame);
document.removeEventListener("pointerdown", dismissOnOutsidePointer);
document.removeEventListener("focusin", dismissOnOutsideFocus);
};
}, [menuOpen, onOpenChange, value]);
return (
<div className={`runtime-picker composer-picker ${className}`}>
<button
aria-describedby={describedBy}
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={selectionLabel}
className="model-button composer-picker__button"
disabled={disabled}
onClick={() => onOpenChange(!menuOpen)}
onKeyDown={(event) => {
if (
!menuOpen &&
(event.key === "ArrowDown" ||
event.key === "Enter" ||
event.key === " ")
) {
event.preventDefault();
onOpenChange(true);
}
}}
ref={buttonRef}
title={selectionLabel}
type="button"
>
{icon}
<span className="model-button__label">
{triggerLabel ?? selectedOption?.label}
</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
{menuOpen && (
<div
aria-label={ariaLabel}
className="runtime-picker__menu composer-picker__menu"
onKeyDown={(event) => {
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"]',
),
).filter((item) => !item.disabled);
const currentIndex = items.indexOf(
document.activeElement as HTMLButtonElement,
);
let nextIndex: number | undefined;
if (event.key === "ArrowDown") {
nextIndex = (currentIndex + 1) % items.length;
} else if (event.key === "ArrowUp") {
nextIndex = (currentIndex - 1 + items.length) % items.length;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = items.length - 1;
} else if (event.key === "Escape") {
event.preventDefault();
onOpenChange(false);
buttonRef.current?.focus();
}
const nextItem =
nextIndex === undefined ? undefined : items.at(nextIndex);
if (nextItem) {
event.preventDefault();
items.forEach((item) => {
item.tabIndex = item === nextItem ? 0 : -1;
});
nextItem.focus();
}
}}
ref={menuRef}
role="menu"
>
{options.map((option) => (
<button
aria-checked={option.value === value}
disabled={option.disabled}
key={option.value}
onClick={() => {
onChange(option.value);
onOpenChange(false);
requestAnimationFrame(() => {
buttonRef.current?.focus();
});
}}
role="menuitemradio"
tabIndex={option.value === value ? 0 : -1}
type="button"
>
<span>{option.label}</span>
<small>{option.description}</small>
</button>
))}
</div>
)}
</div>
);
}
function App(): React.JSX.Element {
const { i18n, t } = useTranslation("app");
const { t: tWorkspace } = useTranslation("workspace");
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const locale = i18n.resolvedLanguage === "en-US" ? "en-US" : "zh-CN";
const [initialConversationMigrationStoragePresent] = useState(hasConversationMigrationStorage);
const conversationMigrationStoragePresent = useRef(initialConversationMigrationStoragePresent);
const [conversations, setConversations] = useState(() =>
loadConversations(
t("conversation.greeting"),
t("conversation.interrupted"),
),
);
const [activeId, setActiveIdState] = useState(
() => conversations[0]?.id ?? "",
);
const activeConversationIdRef = useRef(activeId);
const conversationsRef = useRef(conversations);
const persistedLocalConversationsRef = useRef(
new Map<string, Conversation>(),
);
const conversationPersistenceQueueRef = useRef<Promise<void>>(
Promise.resolve(),
);
const conversationPersistencePausedRef = useRef(false);
const deletingLocalConversationIdsRef = useRef(new Set<string>());
const flushConversationPersistenceAfterRenderRef = useRef(false);
const [unreadConversationIds, setUnreadConversationIds] = useState<
Set<string>
>(() => new Set());
const [conversationStoreReady, setConversationStoreReady] = useState(false);
const migrationConversations = useRef(conversations);
const [projects, setProjects] = useState<AssistantProject[]>([]);
const projectsRef = useRef(projects);
const [projectRecoveryByProjectId, setProjectRecoveryByProjectId] = useState<
Record<string, RemoteProjectRecoveryState>
>({});
const [projectRecoverySnapshotReady, setProjectRecoverySnapshotReady] =
useState(false);
const projectRecoverySnapshotReadyRef = useRef(false);
const projectRecoveryByProjectIdRef = useRef<
Record<string, RemoteProjectRecoveryState>
>({});
const retryingRecoveryProjectIdsRef = useRef(new Set<string>());
const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([]);
const assistantTasksRef = useRef(assistantTasks);
const [tokenUsage, setTokenUsage] =
useState<TokenUsageSummary>(emptyTokenUsage);
const [workspaceChanges, setWorkspaceChanges] = useState<{
projectId: string;
changes: WorkspaceChanges;
}>();
const [assistantArtifacts, setAssistantArtifacts] = useState<
AssistantArtifact[]
>([]);
const assistantArtifactById = useMemo(
() =>
new Map(assistantArtifacts.map((artifact) => [artifact.id, artifact])),
[assistantArtifacts],
);
const [assistantMemories, setAssistantMemories] = useState<AssistantMemory[]>(
[],
);
const [assistantSchedules, setAssistantSchedules] = useState<
AssistantSchedule[]
>([]);
const [conversationQueueItems, setConversationQueueItems] = useState<
ConversationQueueItem[]
>([]);
const dispatchedConversationQueueItems = useRef(new Set<string>());
const conversationQueueDispatchRef = useRef<
(dispatch: ConversationQueueDispatch) => void
>(() => undefined);
const [selectedAssistantTaskId, setSelectedAssistantTaskId] =
useState<string>();
const [expandedTaskConversationIds, setExpandedTaskConversationIds] =
useState<Set<string>>(() => new Set());
const [customTaskDialog, setCustomTaskDialog] = useState<{
defaultDestination: CustomTaskDestination;
}>();
const [assistantHeartbeats, setAssistantHeartbeats] = useState<
AssistantHeartbeatConfig[]
>([]);
const [heartbeatEntries, setHeartbeatEntries] = useState<
AssistantHeartbeatEntry[]
>([]);
const [heartbeatRuns, setHeartbeatRuns] = useState<AssistantHeartbeatRun[]>(
[],
);
const [heartbeatMemories, setHeartbeatMemories] = useState<AssistantMemory[]>(
[],
);
const [heartbeatLoading, setHeartbeatLoading] = useState(true);
const [heartbeatLoadError, setHeartbeatLoadError] = useState<string>();
const [supervisionGraphNavigation, setSupervisionGraphNavigation] = useState<
import('./SupervisorWorkspace').SupervisionGraphNavigation
>();
const [assistantExperts, setAssistantExperts] = useState<AssistantExpert[]>(
[],
);
const [selectedExpertId, setSelectedExpertId] = useState("");
const [activeProjectId, setActiveProjectId] = useState("");
const activeProjectIdRef = useRef(activeProjectId);
const workspaceChangesRequestRef = useRef(0);
const runtimeStatusRequestRef = useRef(0);
const runtimeSwitchGenerationRef = useRef(0);
const runtimeSetupPromptedRef = useRef(false);
const runtimeStatusCacheRef = useRef<
| {
key: string;
settings: RuntimeSettings;
}
| undefined
>(undefined);
const viewRef = useRef<WorkspaceView>("chat");
const heartbeatLoadRequestRef = useRef(0);
const [conversationDrafts, setConversationDrafts] = useState<
Record<string, string>
>({});
const input = conversationDrafts[activeId] ?? "";
const setConversationInput = useCallback(
(conversationId: string, update: SetStateAction<string>): void => {
setConversationDrafts((current) => {
const currentValue = current[conversationId] ?? "";
const nextValue =
typeof update === "function" ? update(currentValue) : update;
if (nextValue === currentValue) {
return current;
}
if (!nextValue) {
const next = { ...current };
delete next[conversationId];
return next;
}
return { ...current, [conversationId]: nextValue };
});
},
[],
);
// Async composer operations retain the conversation that started them.
const setInput = useCallback(
(update: SetStateAction<string>): void => setConversationInput(activeId, update),
[activeId, setConversationInput],
);
const [voiceListening, setVoiceListening] = useState(false);
const [voiceRecording, setVoiceRecording] = useState(false);
const voiceRecordingRef = useRef<PcmRecording | undefined>(undefined);
const voiceRequestIdRef = useRef<string | undefined>(undefined);
const voiceStartingRef = useRef(false);
const voiceDisposedRef = useRef(false);
const startupUpdateCheckStartedRef = useRef(false);
const startupReleaseNotesStartedRef = useRef(false);
const [releaseNotes, setReleaseNotes] = useState<ReleaseNotesSnapshot>();
const [runtime, setRuntime] = useState<AgentRuntimeStatus>();
const [runtimeStatusKey, setRuntimeStatusKey] = useState("");
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>();
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false);
const [composerOptionsOpen, setComposerOptionsOpen] = useState(false);
const composerOptionsRef = useRef<HTMLDivElement>(null);
const composerOptionsTriggerRef = useRef<HTMLButtonElement>(null);
const [composerMenuOpen, setComposerMenuOpen] = useState<
| "expert"
| "mode"
| "runtime-agent"
| "runtime-action"
| "runtime-preset"
| undefined
>();
const [runtimeCustomization, setRuntimeCustomization] =
useState<RuntimeCustomizationSettings>();
const [runtimeNativeSnapshot, setRuntimeNativeSnapshot] =
useState<RuntimeNativeSnapshot>();
const runtimeNativeCacheRef = useRef(
new Map<
string,
{
customization: RuntimeCustomizationSettings;
snapshot: RuntimeNativeSnapshot;
}
>(),
);
const runtimeNativeRetryScopesRef = useRef(new Set<string>());
const [runtimeNativeRetry, setRuntimeNativeRetry] = useState(0);
const [selectedRuntimeAgent, setSelectedRuntimeAgent] = useState("");
const [selectedRuntimeCommand, setSelectedRuntimeCommand] = useState("");
const [selectedContinuePreset, setSelectedContinuePreset] = useState("");
const [runtimeContextCompacting, setRuntimeContextCompacting] =
useState(false);
const runtimeCustomizationRequestRef = useRef(0);
const runtimeMenuButtonRef = useRef<HTMLButtonElement>(null);
const runtimeMenuRef = useRef<HTMLDivElement>(null);
const [runtimeSwitching, setRuntimeSwitching] = useState(false);
const [appearanceTheme, setAppearanceTheme] =
useState<AppearanceTheme>(loadAppearanceTheme);
const [brandingPreferences, setBrandingPreferences] = useState(
loadBrandingPreferences,
);
const [systemPrefersDark, setSystemPrefersDark] = useState(
() =>
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
const resolvedAppearanceTheme = resolveAppearanceTheme(
appearanceTheme,
systemPrefersDark,
);
const brandingSubtitle =
locale === "en-US"
? brandingPreferences.subtitleEnUS
: brandingPreferences.subtitleZhCN;
const toggleAppearanceTheme = useCallback((): void => {
setAppearanceTheme(resolvedAppearanceTheme === "dark" ? "light" : "dark");
}, [resolvedAppearanceTheme]);
const setExpertMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "expert" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const setModeMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "mode" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "runtime-agent" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const setRuntimeActionMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "runtime-action" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const setRuntimePresetMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "runtime-preset" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const assistantExpertOptions = useMemo<ComposerMenuOption<string>[]>(
() => [
{
value: "",
label: t("composer.experts.general"),
description: t("composer.experts.generalDescription"),
},
{
value: "team",
label: t("composer.experts.team"),
description: t("composer.experts.teamDescription"),
},
...assistantExperts.map((expert) => ({
value: expert.id,
label: expert.name,
description:
expert.description || t("composer.experts.customDescription"),
})),
],
[assistantExperts, t],
);
const workModeOptions = useMemo<ComposerMenuOption<InteractiveWorkMode>[]>(
() =>
interactiveWorkModes.map((value) => ({
value,
label: t(`composer.modes.${value}.label`),
description:
value === "execute"
? t("composer.modes.execute.description")
: t("composer.modes.ask.description"),
disabled: value === "execute" && !runtime?.supportsToolExecution,
})),
[runtime?.supportsToolExecution, t],
);
const quickActions = useMemo(
() => [
{
title: t("chat.quickActions.summarize.title"),
description: t("chat.quickActions.summarize.description"),
prompt: t("chat.quickActions.summarize.prompt"),
},
{
title: t("chat.quickActions.analyzeError.title"),
description: t("chat.quickActions.analyzeError.description"),
prompt: t("chat.quickActions.analyzeError.prompt"),
},
{
title: t("chat.quickActions.write.title"),
description: t("chat.quickActions.write.description"),
prompt: t("chat.quickActions.write.prompt"),
},
],
[t],
);
const [appInfo, setAppInfo] = useState<AppInfo>();
const [savedShortcut, setSavedShortcut] = useState<GlobalShortcutSettingsSnapshot>();
const shortcutPlatform = savedShortcut?.platform ?? appInfo?.platform ?? "";
// A settings save is newer than the startup app-info request, even if it finishes first.
const composerShortcut = savedShortcut
? savedShortcut.registered ? savedShortcut.displayAccelerator : ""
: appInfo?.shortcut;
const composerKeyboardHint = t("composer.keyboardHint", {
pasteShortcut: formatShortcutForDisplay("CommandOrControl+V", shortcutPlatform),
});
const composerConversationHint = t("composer.newConversationHint", {
shortcut: formatShortcutForDisplay("CommandOrControl+N", shortcutPlatform),
}) + (composerShortcut ? ` · ${t("composer.shortcut", { shortcut: composerShortcut })}` : "");
const [narrowWindow, setNarrowWindow] = useState(
() => window.innerWidth < 900,
);
const [sidebarOpen, setSidebarOpen] = useState(
() => window.innerWidth >= 900,
);
const [primarySidebarWidth, setPrimarySidebarWidth] = useState(
loadPrimarySidebarWidth,
);
const [primarySidebarResizing, setPrimarySidebarResizing] = useState(false);
const [nativeTerminals, setNativeTerminals] = useState<{ terminal: TerminalSnapshot; focus: boolean }[]>([]);
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
() => window.innerWidth >= 1280,
);
const [assistantSidebarTab, setAssistantSidebarTab] =
useState<AssistantSidebarTab>("tasks");
const [browserStates, setBrowserStates] = useState<
Record<string, Record<string, BrowserLiveState>>
>({});
const [view, setViewState] = useState<WorkspaceView>("chat");
const [settingsOpen, setSettingsOpen] = useState(false);
const [localInferenceOpen, setLocalInferenceOpen] = useState(false);
const [applicationCenterOpen, setApplicationCenterOpen] = useState(false);
const [applicationMenuOpen, setApplicationMenuOpen] = useState(false);
const applicationMenuTriggerRef = useRef<HTMLButtonElement>(null);
const [applicationSettings, setApplicationSettings] = useState<ApplicationSettings>();
useLayoutEffect(() => {
// FloatingPortal surfaces live outside app-shell, including in the top layer.
delete document.documentElement.dataset.frostedGlass;
if (applicationSettings?.transparentFrostedEffectEnabled) {
document.documentElement.dataset.frostedGlass = 'true';
}
return () => { delete document.documentElement.dataset.frostedGlass; };
}, [applicationSettings?.transparentFrostedEffectEnabled]);
const [applicationSettingsPending, setApplicationSettingsPending] = useState(false);
const [applicationSettingsUnconfirmed, setApplicationSettingsUnconfirmed] = useState(false);
const applicationSettingsPendingRef = useRef(0);
const applicationSettingsRevisionRef = useRef(0);
const [applicationSettingsError, setApplicationSettingsError] = useState<string>();
const settingsOpenRef = useRef(false);
const [settingsInitialCategory, setSettingsInitialCategory] =
useState<SettingsCategoryId>();
const [settingsInitialChannel, setSettingsInitialChannel] =
useState<ProjectChannel>();
const settingsEntryFocusRef = useRef<HTMLElement | undefined>(undefined);
const settingsExitFocusRef = useRef<HTMLElement | undefined>(undefined);
const settingsLeaveRequesterRef = useRef<SettingsLeaveRequester | undefined>(
undefined,
);
const notesLeaveRequesterRef = useRef<SettingsLeaveRequester | undefined>(undefined);
const noteDraft = useMagicNoteDraft();
const { guard: guardNoteDraft, setDraft: setNoteDraft } = noteDraft;
const [notesOpenRequest, setNotesOpenRequest] = useState(0);
const [notesNavigation, setNotesNavigation] = useState<{ noteId: string; entryId?: string; requestId: number }>();
const [noteMessageNavigation, setNoteMessageNavigation] = useState<{ conversationId: string; messageId: string; requestId: number }>();
const [noteCaptureLoading, setNoteCaptureLoading] = useState(false);
const noteCapturePending = useRef(false);
const noteCaptureTrigger = useRef<HTMLElement | null>(null);
const noteTitleMenuRef = useRef<HTMLButtonElement>(null);
const [noteTitleMenuOpen, setNoteTitleMenuOpen] = useState(false);
const registerNotesLeaveRequester = useCallback((requester: SettingsLeaveRequester | undefined): void => {
notesLeaveRequesterRef.current = requester;
}, []);
const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState<
KeepAliveCacheEntry<WorkspaceView>[]
>(() => [{ key: "chat", lastVisitedAt: Date.now() }]);
const [cachedConversationViews, setCachedConversationViews] = useState<
KeepAliveCacheEntry<string>[]
>(() => (activeId ? [{ key: activeId, lastVisitedAt: Date.now() }] : []));
const cachedConversationViewsRef = useRef(cachedConversationViews);
useLayoutEffect(() => {
cachedConversationViewsRef.current = cachedConversationViews;
}, [cachedConversationViews]);
const commitView = useCallback((next: WorkspaceView): void => {
const wasSettingsOpen = settingsOpenRef.current;
settingsOpenRef.current = false;
setSettingsOpen(false);
setSettingsInitialCategory(undefined);
setSettingsInitialChannel(undefined);
const now = Date.now();
const runningConversationIds = new Set(
[...activeRuns.current.values()].map((run) => run.conversationId),
);
preparingConversations.current.forEach((conversationId) =>
runningConversationIds.add(conversationId),
);
const protectedWorkspaceViews = new Set<WorkspaceView>();
if (runningConversationIds.size > 0) {
protectedWorkspaceViews.add("chat");
protectedWorkspaceViews.add("activity");
}
if (knowledgeOperationCountRef.current > 0) {
protectedWorkspaceViews.add("knowledge");
protectedWorkspaceViews.add("activity");
}
if (
assistantTasksRef.current.some(
(task) =>
task.status === "queued" ||
task.status === "running" ||
task.status === "waiting_approval",
)
) {
protectedWorkspaceViews.add("activity");
}
viewRef.current = next;
setCachedWorkspaceViews((current) =>
touchAndPruneKeepAliveEntries(current, next, now, {
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedWorkspaceViews,
protectedKeys: protectedWorkspaceViews,
recentEntries: recentCachedWorkspaceViews,
}),
);
setViewState(next);
if (wasSettingsOpen) {
const preferred = settingsExitFocusRef.current;
const returnTarget = settingsEntryFocusRef.current;
settingsExitFocusRef.current = undefined;
settingsEntryFocusRef.current = undefined;
requestAnimationFrame(() => {
const target = preferred?.isConnected
? preferred
: returnTarget?.isConnected
? returnTarget
: next === "chat"
? inputRef.current
: document.querySelector<HTMLElement>(
`.primary-nav [aria-current="page"]`,
);
target?.focus();
});
}
}, []);
const requestWorkspaceLeave = useCallback((next: WorkspaceView, leave: () => void): void => {
const leaveNotes = (): void => {
if (viewRef.current === "magic-notes" && next !== "magic-notes" && notesLeaveRequesterRef.current) {
if (settingsOpenRef.current) commitView(viewRef.current);
notesLeaveRequesterRef.current(leave);
} else {
leave();
}
};
if (settingsOpenRef.current && settingsLeaveRequesterRef.current) {
settingsLeaveRequesterRef.current(leaveNotes);
} else {
leaveNotes();
}
}, [commitView]);
const setView = useCallback(
(update: SetStateAction<WorkspaceView>): void => {
const next =
typeof update === "function" ? update(viewRef.current) : update;
if (next === "local-inference") {
setLocalInferenceOpen(true);
return;
}
if (
!settingsOpenRef.current &&
next === "settings" &&
!settingsEntryFocusRef.current?.isConnected
) {
const activeElement = document.activeElement;
settingsEntryFocusRef.current =
activeElement instanceof HTMLElement &&
activeElement !== document.body &&
activeElement.isConnected
? activeElement
: undefined;
}
if (next === "settings") {
settingsOpenRef.current = true;
setSettingsOpen(true);
return;
}
requestWorkspaceLeave(next, () => commitView(next));
},
[commitView, requestWorkspaceLeave],
);
const registerSettingsLeaveRequester = useCallback(
(requester: SettingsLeaveRequester | undefined): void => {
settingsLeaveRequesterRef.current = requester;
},
[],
);
const handleShortcutSettingsChanged = useCallback(
(snapshot: GlobalShortcutSettingsSnapshot): void => {
setSavedShortcut(snapshot);
},
[],
);
const setActiveId = useCallback((update: SetStateAction<string>): void => {
const next =
typeof update === "function"
? update(activeConversationIdRef.current)
: update;
activeConversationIdRef.current = next;
if (next) {
const now = Date.now();
const runningConversationIds = new Set(
[...activeRuns.current.values()].map((run) => run.conversationId),
);
preparingConversations.current.forEach((conversationId) =>
runningConversationIds.add(conversationId),
);
setCachedConversationViews((current) =>
touchAndPruneKeepAliveEntries(current, next, now, {
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedConversations,
protectedKeys: runningConversationIds,
recentEntries: recentCachedConversations,
}),
);
}
setActiveIdState(next);
}, []);
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false);
const [
conversationHtmlRenderingEnabled,
setConversationHtmlRenderingEnabled,
] = useState(true);
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false);
const [
magicNotesShowIncompleteTodoCount,
setMagicNotesShowIncompleteTodoCount,
] = useState(true);
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0);
const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation;
const visibleApplications = useMemo(() => applicationNavigation.order.filter(id =>
id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record<string, boolean | undefined>)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id)))
), [applicationNavigation, applicationSettings, magicNotesEnabled]);
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
setApplicationSettings(settings);
if (settings.magicNotesEnabled) setMagicNotesEnabled(true);
else {
const revision = applicationSettingsRevisionRef.current;
void guardNoteDraft().then(async allowed => {
if (revision !== applicationSettingsRevisionRef.current) return;
if (allowed) { setNoteDraft(undefined); setMagicNotesEnabled(false); }
else if (window.goodbuddy.updates) {
// Continuing an externally disabled draft restores the same application setting.
try { await window.goodbuddy.updates.updateSettings({ magicNotesEnabled: true }); }
catch (reason) { setApplicationSettingsError(displayErrorMessage(reason, tRef.current('applications.saveFailed'))); }
}
});
}
setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount);
setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false);
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
}, [guardNoteDraft, setNoteDraft]);
const reloadApplicationSettings = useCallback(async (): Promise<void> => {
const revision = ++applicationSettingsRevisionRef.current;
applicationSettingsPendingRef.current++;
setApplicationSettingsPending(true);
try {
if (!window.goodbuddy.updates) throw new Error(t('applications.loadFailed'));
const settings = await window.goodbuddy.updates.getSettings();
if (revision !== applicationSettingsRevisionRef.current) return;
applyApplicationSettings(settings);
setApplicationSettingsUnconfirmed(false);
setApplicationSettingsError(undefined);
} catch (error) {
if (revision !== applicationSettingsRevisionRef.current) return;
setApplicationSettingsUnconfirmed(true);
setApplicationSettingsError(displayErrorMessage(error, t('applications.loadFailed')));
} finally {
setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0);
}
}, [applyApplicationSettings, t]);
const updateApplicationSettings = useCallback(async (patch: ApplicationSettingsUpdate): Promise<boolean> => {
if (applicationSettingsPendingRef.current || applicationSettingsUnconfirmed) return false;
if (patch.magicNotesEnabled === false) {
if (!await guardNoteDraft()) return false;
setNoteDraft(undefined);
}
const revision = ++applicationSettingsRevisionRef.current;
applicationSettingsPendingRef.current++;
setApplicationSettingsPending(true);
setApplicationSettingsError(undefined);
try {
if (!window.goodbuddy.updates) throw new Error(t('applications.loadFailed'));
const settings = await window.goodbuddy.updates.updateSettings(patch);
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
return true;
} catch (error) {
if (revision !== applicationSettingsRevisionRef.current) return false;
setApplicationSettingsError(displayErrorMessage(error, t('applications.saveFailed')));
try {
if (!window.goodbuddy.updates) throw error;
const settings = await window.goodbuddy.updates.getSettings();
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
} catch {
if (revision !== applicationSettingsRevisionRef.current) return false;
// Keep edits locked until Retry obtains the authoritative settings.
setApplicationSettingsError(t('applications.confirmFailed'));
setApplicationSettingsUnconfirmed(true);
}
return false;
} finally {
setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0);
}
}, [applicationSettingsUnconfirmed, applyApplicationSettings, guardNoteDraft, setNoteDraft, t]);
const [searchQuery, setSearchQuery] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const searchTriggerRef = useRef<HTMLButtonElement>(null);
const closeConversationSearch = (): void => {
setSearchQuery("");
setSearchOpen(false);
requestAnimationFrame(() => searchTriggerRef.current?.focus());
};
const deferredSearchQuery = useDeferredValue(searchQuery);
const [localSearchMatches, setLocalSearchMatches] =
useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() });
const [persistedSearchMatches, setPersistedSearchMatches] =
useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() });
const [conversationLoadError, setConversationLoadError] = useState<string>();
const [conversationLoadRetry, setConversationLoadRetry] = useState(0);
const [conversationActionsId, setConversationActionsId] = useState("");
const [pinningConversationId, setPinningConversationId] = useState("");
const conversationPinRevisionRef = useRef(0);
const conversationPinPendingRef = useRef(false);
const [confirmingConversationId, setConfirmingConversationId] = useState("");
if (!sidebarOpen && (conversationActionsId || confirmingConversationId)) {
setConversationActionsId("");
setConfirmingConversationId("");
}
const [deletingConversationId, setDeletingConversationId] = useState("");
const [branchingConversationId, setBranchingConversationId] = useState("");
const [renamingConversationId, setRenamingConversationId] = useState("");
const [notifications, notify] = useReducer(appNotificationReducer, []);
const handleWindowControlError = useCallback(
(message: string): void => {
notify({ tone: "error", message });
},
[notify],
);
const [attachmentsByConversation, setAttachmentsByConversation] = useState<
Record<string, ContextAttachment[]>
>({});
const attachments = attachmentsByConversation[activeId] ?? [];
const [imageReferencesByConversation, setImageReferencesByConversation] = useState<Record<string, AssistantArtifact[]>>({});
const imageReferences = imageReferencesByConversation[activeId] ?? [];
const attachmentsRef = useRef(new Map<string, ContextAttachment[]>());
const attachmentSaveQueue = useRef<Promise<void>>(Promise.resolve());
useEffect(() => window.goodbuddy.context.onDraftChanged((conversationId, saved) => {
attachmentsRef.current.set(conversationId, saved);
setAttachmentsByConversation((current) => ({ ...current, [conversationId]: saved }));
}), []);
useEffect(() => {
let active = true;
if (attachmentsRef.current.has(activeId) || conversationsRef.current.find((conversation) => conversation.id === activeId)?.remote) return;
void window.goodbuddy.context.getDraft(activeId).then((saved) => {
if (!active || attachmentsRef.current.has(activeId) || !saved.length) return;
attachmentsRef.current.set(activeId, saved);
setAttachmentsByConversation((current) => ({ ...current, [activeId]: saved }));
}).catch((error: unknown) => notify({ tone: 'error', message: error instanceof Error ? error.message : '附件草稿读取失败' }));
return () => { active = false; };
}, [activeId]);
const updateAttachments = useCallback(
(
update:
| ContextAttachment[]
| ((current: ContextAttachment[]) => ContextAttachment[]),
): void => {
const current = attachmentsRef.current.get(activeId) ?? [];
const next = typeof update === "function" ? update(current) : update;
attachmentSaveQueue.current = attachmentSaveQueue.current.catch(() => undefined).then(async () => {
await conversationPersistenceQueueRef.current;
const owner = conversationsRef.current.find((conversation) => conversation.id === activeId);
if (!owner || owner.remote) throw new Error('附件目标会话不可编辑');
if (next.length && !persistedLocalConversationsRef.current.has(activeId)) {
await window.goodbuddy.conversations.saveLocal([{ header: toLocalConversationHeader(owner), messages: [] }]);
}
await window.goodbuddy.context.saveDraft(activeId, next.map((attachment) => attachment.id));
});
void attachmentSaveQueue.current.catch((error: unknown) => notify({ tone: 'error', message: error instanceof Error ? error.message : '附件草稿保存失败' }));
if (next.length > 0) {
attachmentsRef.current.set(activeId, next);
} else {
attachmentsRef.current.delete(activeId);
}
setAttachmentsByConversation((values) => {
if (next.length > 0) {
return { ...values, [activeId]: next };
}
const remaining = { ...values };
delete remaining[activeId];
return remaining;
});
},
[activeId],
);
const [contextError, setContextError] = useState<string>();
const [fileSelectionProgress, setFileSelectionProgress] =
useState<ContextFileSelectionProgress>();
const [selectingContextFiles, setSelectingContextFiles] = useState(false);
const selectingContextFilesRef = useRef(false);
const [attachmentOperations, setAttachmentOperations] = useState(0);
const attachmentOperationsRef = useRef(0);
const updateAttachmentBusy = useCallback((busy: boolean): void => {
attachmentOperationsRef.current = Math.max(0, attachmentOperationsRef.current + (busy ? 1 : -1));
setAttachmentOperations(attachmentOperationsRef.current);
}, []);
const [imageViewerItem, setImageViewerItem] = useState<ImageViewerItem>();
const [citationDialog, setCitationDialog] = useState<{
reference: KnowledgeSearchReference;
context?: KnowledgeCitationContextView;
loading: boolean;
error?: string;
}>();
const imageViewerTriggerRef = useRef<HTMLElement | undefined>(undefined);
const imageViewerDialogRef = useRef<HTMLElement>(null);
const imageViewerCloseRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!imageViewerItem) {
return;
}
return activateModalFocus(() => imageViewerCloseRef.current);
}, [imageViewerItem]);
useEffect(
() =>
window.goodbuddy.context.onFileSelectionProgress((progress) => {
if (selectingContextFilesRef.current) {
setFileSelectionProgress(progress);
}
}),
[],
);
const [knowledgeSnapshot, setKnowledgeSnapshot] = useState<KnowledgeSnapshot>(
{
libraries: [],
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: [],
tasks: [],
},
);
const [externalInstances, setExternalInstances] = useState<import('../../shared/external-knowledge-contracts').ExternalKnowledgeInstanceSummary[]>([]);
const [knowledgeLoading, setKnowledgeLoading] = useState(true);
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>();
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0);
const knowledgeOperationCountRef = useRef(knowledgeOperationCount);
const knowledgeLoadRequestRef = useRef(0);
const citationRequestRef = useRef(0);
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(undefined);
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null);
const [legacyActivityHistory] = useState(loadLegacyActivityHistory);
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
legacyActivityHistory.records,
);
const [
legacyActivityHistoryMayBeIncomplete,
setLegacyActivityHistoryMayBeIncomplete,
] = useState(legacyActivityHistory.historyMayBeIncomplete);
const [activityHistoryReady, setActivityHistoryReady] = useState(false);
const activityRecordsRef = useRef(activityRecords);
const legacyActivityHistoryMayBeIncompleteRef = useRef(
legacyActivityHistoryMayBeIncomplete,
);
const activeRuns = useRef(new Map<string, ActiveRun>());
const preparingConversations = useRef(new Set<string>());
const [activeConversationIds, setActiveConversationIds] = useState<
ReadonlySet<string>
>(() => new Set());
const setConversationActivity = useCallback(
(conversationId: string, active: boolean): void => {
setActiveConversationIds((current) => {
if (current.has(conversationId) === active) {
return current;
}
const next = new Set(current);
if (active) {
next.add(conversationId);
} else {
next.delete(conversationId);
}
return next;
});
},
[],
);
const hydratingArtifactIds = useRef(new Set<string>());
const inputRef = useRef<HTMLTextAreaElement>(null);
const attachmentButtonRef = useRef<HTMLButtonElement>(null);
const [chatScrollSnapshots, setChatScrollSnapshots] = useState<
Record<string, ChatScrollSnapshot>
>({});
const retryMessage = useCallback(
(content: string): void => {
setConversationInput(activeConversationIdRef.current, content);
inputRef.current?.focus();
},
[setConversationInput],
);
const setQuickActionInput = useCallback((value: string): void => {
setConversationInput(activeConversationIdRef.current, value);
requestAnimationFrame(() => inputRef.current?.focus());
}, [setConversationInput]);
useEffect(
() =>
scheduleIdleRoutePreload(
idleRouteModuleLoaders,
() =>
activeRuns.current.size === 0 &&
preparingConversations.current.size === 0,
),
[],
);
const [visibleMessageCounts, setVisibleMessageCounts] = useState<
Record<string, number>
>({});
const sidebarRef = useRef<HTMLElement>(null);
const sidebarToggleRef = useRef<HTMLButtonElement>(null);
const livePrimarySidebarWidthRef = useRef(primarySidebarWidth);
const primarySidebarResizePointerIdRef = useRef<number | undefined>(
undefined,
);
const assistantSidebarToggleRef = useRef<HTMLButtonElement>(null);
const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>(),
);
const conversationActionsRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const surface = conversationActionsRef.current;
const trigger = conversationActionTriggerRefs.current.get(conversationActionsId);
if (!surface || !trigger) return;
const position = (): void => {
const anchor = trigger.getBoundingClientRect();
const bounds = surface.getBoundingClientRect();
surface.style.left = `${Math.max(8, Math.min(anchor.right - bounds.width, window.innerWidth - bounds.width - 8))}px`;
surface.style.top = `${Math.max(8, Math.min(anchor.bottom + 4, window.innerHeight - bounds.height - 8))}px`;
};
position();
surface.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus({ preventScroll: true });
const dismiss = (event: Event): void => {
if (event.target instanceof Node && !surface.contains(event.target) && !trigger.contains(event.target)) {
setConversationActionsId("");
setConfirmingConversationId("");
}
};
const resizeObserver = new ResizeObserver(position);
resizeObserver.observe(surface);
document.addEventListener("pointerdown", dismiss);
document.addEventListener("focusin", dismiss);
window.addEventListener("resize", position);
window.addEventListener("scroll", position, true);
return () => {
resizeObserver.disconnect();
document.removeEventListener("pointerdown", dismiss);
document.removeEventListener("focusin", dismiss);
window.removeEventListener("resize", position);
window.removeEventListener("scroll", position, true);
};
}, [conversationActionsId]);
const handleChatScrollSnapshotChange = useCallback(
(conversationId: string, snapshot: ChatScrollSnapshot): void => {
setChatScrollSnapshots((current) => ({
...current,
[conversationId]: snapshot,
}));
},
[],
);
const handleVisibleMessageCountChange = useCallback(
(conversationId: string, count: number): void => {
setVisibleMessageCounts((current) => ({
...current,
[conversationId]: count,
}));
},
[],
);
const closeNarrowSidebar = useCallback((): void => {
setSidebarOpen(false);
requestAnimationFrame(() => sidebarToggleRef.current?.focus());
}, []);
const resizePrimarySidebarFromClientX = useCallback(
(clientX: number, commit: boolean): void => {
const width = clampPrimarySidebarWidth(clientX, window.innerWidth);
livePrimarySidebarWidthRef.current = width;
if (commit) {
setPrimarySidebarWidth(width);
return;
}
sidebarRef.current?.style.setProperty(
"--primary-sidebar-width",
`${width}px`,
);
},
[],
);
const finishPrimarySidebarResize = useCallback(
(event: React.PointerEvent<HTMLDivElement>): void => {
if (primarySidebarResizePointerIdRef.current !== event.pointerId) {
return;
}
primarySidebarResizePointerIdRef.current = undefined;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setPrimarySidebarWidth(livePrimarySidebarWidthRef.current);
setPrimarySidebarResizing(false);
},
[],
);
const resizePrimarySidebarWithKeyboard = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>): void => {
if (narrowWindow || !sidebarOpen) {
return;
}
const limits = getPrimarySidebarWidthLimits(window.innerWidth);
const nextWidth =
event.key === "Home"
? limits.minimum
: event.key === "End"
? limits.maximum
: event.key === "ArrowLeft"
? primarySidebarWidth - primarySidebarKeyboardResizeStep
: event.key === "ArrowRight"
? primarySidebarWidth + primarySidebarKeyboardResizeStep
: undefined;
if (nextWidth === undefined) {
return;
}
event.preventDefault();
const width = clampPrimarySidebarWidth(nextWidth, window.innerWidth);
livePrimarySidebarWidthRef.current = width;
setPrimarySidebarWidth(width);
},
[narrowWindow, primarySidebarWidth, sidebarOpen],
);
const navigateFromSidebar = useCallback(
(nextView: WorkspaceView, trigger: HTMLElement): void => {
if (!settingsOpenRef.current && nextView === "settings") {
settingsEntryFocusRef.current = trigger;
} else if (settingsOpenRef.current && nextView !== "settings") {
settingsExitFocusRef.current = trigger;
}
setView(nextView);
if (narrowWindow && nextView !== "settings") {
closeNarrowSidebar();
}
},
[closeNarrowSidebar, narrowWindow, setView],
);
useEffect(() => {
if (!conversationStoreReady || !projectRecoverySnapshotReady || !runtimeSettings) {
return;
}
const conversationIds = [
...new Set(
conversationsRef.current
.filter(
(conversation) =>
!conversation.remote &&
!isProjectRecoveryUnsettled(
conversation.projectId
? projectRecoveryByProjectIdRef.current[
conversation.projectId
]
: undefined,
),
)
.map((conversation) => conversation.id),
),
];
void Promise.all(
conversationIds.map((conversationId) =>
window.goodbuddy.conversationQueue.ready(conversationId),
),
).catch(() => {
notify({
tone: "error",
message: tRef.current("notices.conversationQueueResumeFailed"),
dedupeKey: "conversation-queue-resume",
});
});
}, [conversationStoreReady, projectRecoverySnapshotReady, conversations.length, runtimeSettings]);
useEffect(() => {
const sweep = (): void => {
const now = Date.now();
const conversationIds = new Set(
conversationsRef.current.map((conversation) => conversation.id),
);
const runningConversationIds = new Set(
[...activeRuns.current.values()].map((run) => run.conversationId),
);
preparingConversations.current.forEach((conversationId) =>
runningConversationIds.add(conversationId),
);
const protectedWorkspaceViews = new Set<WorkspaceView>();
if (runningConversationIds.size > 0) {
protectedWorkspaceViews.add("chat");
protectedWorkspaceViews.add("activity");
}
if (knowledgeOperationCount > 0) {
protectedWorkspaceViews.add("knowledge");
protectedWorkspaceViews.add("activity");
}
if (
assistantTasks.some(
(task) =>
task.status === "queued" ||
task.status === "running" ||
task.status === "waiting_approval",
)
) {
protectedWorkspaceViews.add("activity");
}
setCachedConversationViews((current) =>
pruneKeepAliveEntries(
filterKeepAliveEntries(current, (entry) =>
conversationIds.has(entry.key),
),
{
currentKey: activeId,
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedConversations,
now,
protectedKeys: runningConversationIds,
recentEntries: recentCachedConversations,
},
),
);
setCachedWorkspaceViews((current) =>
pruneKeepAliveEntries(current, {
currentKey: view,
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedWorkspaceViews,
now,
protectedKeys: protectedWorkspaceViews,
recentEntries: recentCachedWorkspaceViews,
}),
);
};
const interval = window.setInterval(sweep, keepAliveSweepIntervalMs);
return () => window.clearInterval(interval);
}, [activeId, assistantTasks, knowledgeOperationCount, view]);
useEffect(() => {
livePrimarySidebarWidthRef.current = primarySidebarWidth;
try {
localStorage.setItem(
primarySidebarWidthStorageKey,
String(primarySidebarWidth),
);
} catch {
// The current width remains usable when browser storage is unavailable.
}
}, [primarySidebarWidth]);
useEffect(() => {
const collapseSidebarAtNarrowWidth = (): void => {
const narrow = window.innerWidth < 900;
setNarrowWindow(narrow);
if (narrow) {
setSidebarOpen(false);
setPrimarySidebarResizing(false);
primarySidebarResizePointerIdRef.current = undefined;
} else {
setPrimarySidebarWidth((current) =>
clampPrimarySidebarWidth(current, window.innerWidth),
);
}
};
window.addEventListener("resize", collapseSidebarAtNarrowWidth);
return () =>
window.removeEventListener("resize", collapseSidebarAtNarrowWidth);
}, []);
useEffect(() => {
if (!narrowWindow || !sidebarOpen) {
return;
}
const focusFrame = requestAnimationFrame(() => {
sidebarRef.current
?.querySelector<HTMLButtonElement>(
'.primary-nav button[aria-current="page"], .primary-nav button',
)
?.focus();
});
const closeOnEscape = (event: KeyboardEvent): void => {
if (sidebarRef.current?.closest<HTMLElement>(".app-shell")?.inert) return;
if (event.key === "Escape") {
event.preventDefault();
closeNarrowSidebar();
}
};
document.addEventListener("keydown", closeOnEscape);
return () => {
cancelAnimationFrame(focusFrame);
document.removeEventListener("keydown", closeOnEscape);
};
}, [closeNarrowSidebar, narrowWindow, sidebarOpen]);
useEffect(() => {
if (!knowledgeScopeOpen) {
return;
}
const focusFrame = requestAnimationFrame(() => {
knowledgeScopePopoverRef.current
?.querySelector<HTMLInputElement>("input")
?.focus();
});
const isScopeTarget = (target: EventTarget | null): boolean =>
target instanceof Node &&
(knowledgeScopePopoverRef.current?.contains(target) === true ||
knowledgeScopeTriggerRef.current?.contains(target) === true);
const closeOnOutsidePointer = (event: PointerEvent): void => {
if (!isScopeTarget(event.target)) {
setKnowledgeScopeOpen(false);
}
};
const closeOnEscape = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || event.defaultPrevented) {
return;
}
event.preventDefault();
setKnowledgeScopeOpen(false);
knowledgeScopeTriggerRef.current?.focus();
};
document.addEventListener("pointerdown", closeOnOutsidePointer);
document.addEventListener("keydown", closeOnEscape);
return () => {
cancelAnimationFrame(focusFrame);
document.removeEventListener("pointerdown", closeOnOutsidePointer);
document.removeEventListener("keydown", closeOnEscape);
};
}, [knowledgeScopeOpen]);
useEffect(() => {
if (!composerOptionsOpen) {
return;
}
const isOptionsTarget = (target: EventTarget | null): boolean =>
target instanceof Node &&
(composerOptionsRef.current?.contains(target) === true ||
composerOptionsTriggerRef.current?.contains(target) === true);
const dismissOutside = (event: Event): void => {
if (!isOptionsTarget(event.target)) {
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
}
};
const frame = requestAnimationFrame(() => {
composerOptionsRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
});
document.addEventListener("pointerdown", dismissOutside);
document.addEventListener("focusin", dismissOutside);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", dismissOutside);
document.removeEventListener("focusin", dismissOutside);
};
}, [composerOptionsOpen]);
useLayoutEffect(() => {
conversationsRef.current = conversations;
}, [conversations]);
useEffect(() => {
if (!searchQuery.trim()) {
return;
}
const timeout = window.setTimeout(
() => {
const query = searchQuery.trim().toLocaleLowerCase();
setLocalSearchMatches({ query, ids: new Set(conversations.filter(conversation =>
conversation.messages.some(message => message.content.toLocaleLowerCase().includes(query))
).map(conversation => conversation.id)) });
},
conversationSearchSnapshotDelayMs,
);
return () => window.clearTimeout(timeout);
}, [conversations, searchQuery]);
useEffect(() => {
const query = deferredSearchQuery.trim().toLocaleLowerCase();
if (!query || !conversationStoreReady) return;
let active = true;
const timeout = window.setTimeout(() => {
void window.goodbuddy.conversations.search(query, conversationsRef.current.map(item => item.id)).then(ids => {
if (active) setPersistedSearchMatches({ query, ids: new Set(ids) });
}).catch(() => {
if (active) notify({
tone: "error", message: tRef.current("notices.remoteConversationRefreshFailed"),
dedupeKey: "conversation-search",
});
});
}, conversationSearchSnapshotDelayMs);
return () => { active = false; window.clearTimeout(timeout); };
}, [deferredSearchQuery, conversationStoreReady, localSearchMatches]);
useEffect(() => {
projectsRef.current = projects;
}, [projects]);
useLayoutEffect(() => {
projectRecoveryByProjectIdRef.current = projectRecoveryByProjectId;
}, [projectRecoveryByProjectId]);
useEffect(() => {
assistantTasksRef.current = assistantTasks;
}, [assistantTasks]);
useLayoutEffect(() => {
resizeComposerTextarea(inputRef.current);
}, [input]);
useEffect(() => {
saveAppearanceTheme(appearanceTheme);
}, [appearanceTheme]);
useEffect(() => {
const revision = applicationSettingsRevisionRef;
const remove = window.goodbuddy.updates?.onSettingsChanged((settings) => {
// A persisted change supersedes reads and mutation replies already in flight.
revision.current++;
applyApplicationSettings(settings);
setApplicationSettingsUnconfirmed(false);
setApplicationSettingsError(undefined);
});
return () => {
remove?.();
revision.current++;
};
}, [applyApplicationSettings]);
useEffect(() => {
const updates = window.goodbuddy.updates;
if (!updates || startupUpdateCheckStartedRef.current) {
return;
}
startupUpdateCheckStartedRef.current = true;
const revision = applicationSettingsRevisionRef.current;
let updateCheckSource: "github" | "mirror" | undefined;
void updates
.getSettings()
.then(async (settings) => {
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
if (!settings.checkUpdatesOnStartup) {
return;
}
updateCheckSource = settings.updateSource;
const result = await updates.check();
if (result.updateAvailable) {
notify({
tone: "info",
message: i18n.t("notices.updateAvailable", {
ns: "app",
version: result.latestVersion,
}),
dedupeKey: "update-available",
});
}
})
.catch((reason: unknown) => {
if (!updateCheckSource) {
if (revision !== applicationSettingsRevisionRef.current) return;
setApplicationSettingsError(displayErrorMessage(reason, i18n.t('applications.loadFailed', { ns: 'app' })));
return;
}
notify({
tone: "error",
message: i18n.t("notices.startupUpdateCheckFailed", {
ns: "app",
source: i18n.t(`notices.updateSources.${updateCheckSource}`, {
ns: "app",
}),
error: displayNetworkAwareErrorMessage(
reason,
i18n.t("notices.updateCheckFailed", { ns: "app" }),
i18n.t("notices.updateCheckNetwork", { ns: "app" }),
),
}),
dedupeKey: "startup-update-check",
});
});
}, [i18n, applyApplicationSettings]);
useEffect(() => {
const magicNotes = window.goodbuddy.magicNotes;
if (!magicNotesEnabled || !magicNotesShowIncompleteTodoCount) {
return;
}
let active = true;
let requestId = 0;
const refresh = async (): Promise<void> => {
const currentRequestId = ++requestId;
try {
const status = await magicNotes.getTodoStatus();
if (active && requestId === currentRequestId) {
setIncompleteMagicTodoCount(status.incompleteCount);
}
} catch {
if (active && requestId === currentRequestId) {
setIncompleteMagicTodoCount(0);
}
}
};
const removeListener = magicNotes.onTodoStatusChanged(() => {
void refresh();
});
void refresh();
return () => {
active = false;
removeListener();
};
}, [magicNotesEnabled, magicNotesShowIncompleteTodoCount]);
useEffect(() => {
const releaseNotesApi = window.goodbuddy.releaseNotes;
if (!releaseNotesApi || startupReleaseNotesStartedRef.current) {
return;
}
startupReleaseNotesStartedRef.current = true;
void releaseNotesApi
.getPending()
.then((snapshot) => {
if (snapshot.releases.length > 0) {
setReleaseNotes(snapshot);
}
})
.catch(() => undefined);
}, []);
useEffect(() => {
applyAppearanceTheme(resolvedAppearanceTheme);
}, [resolvedAppearanceTheme]);
useEffect(() => {
voiceDisposedRef.current = false;
return () => {
voiceDisposedRef.current = true;
voiceRecordingRef.current?.cancel();
const requestId = voiceRequestIdRef.current;
if (requestId) {
void window.goodbuddy.speech?.cancel(requestId);
}
};
}, []);
useEffect(() => {
if (appearanceTheme !== "system") {
return;
}
if (typeof window.matchMedia !== "function") {
return;
}
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
const updateSystemTheme = (): void => {
setSystemPrefersDark(systemTheme.matches);
};
updateSystemTheme();
systemTheme.addEventListener("change", updateSystemTheme);
return () => {
systemTheme.removeEventListener("change", updateSystemTheme);
};
}, [appearanceTheme]);
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return;
}
const compactLayout = window.matchMedia("(max-width: 1279px)");
const closeCompactAssistantSidebar = (): void => {
if (compactLayout.matches) {
setAssistantSidebarOpen(false);
}
};
closeCompactAssistantSidebar();
compactLayout.addEventListener("change", closeCompactAssistantSidebar);
return () => {
compactLayout.removeEventListener("change", closeCompactAssistantSidebar);
};
}, []);
const activeConversation = useMemo(
() => conversations.find((conversation) => conversation.id === activeId),
[activeId, conversations],
);
const enabledKnowledgeLibraryIds =
activeConversation?.knowledgeLibraryIds ?? [];
const setEnabledKnowledgeLibraryIds = useCallback(
(action: SetStateAction<string[]>): void => {
setConversations((current) =>
current.map((conversation) => {
if (conversation.id !== activeId) {
return conversation;
}
const previous = conversation.knowledgeLibraryIds ?? [];
const next = typeof action === "function" ? action(previous) : action;
if (
previous.length === next.length &&
previous.every((id, index) => id === next[index])
) {
return conversation;
}
return {
...conversation,
knowledgeLibraryIds: next,
updatedAt: Date.now(),
};
}),
);
},
[activeId],
);
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects],
);
const activeProjectUsesManagedSsh = isManagedSshProject(activeProject);
const workMode = normalizeInteractiveWorkMode(
activeConversation?.workMode ?? activeProject?.defaultWorkMode,
);
const effectiveWorkMode =
workMode === "execute" && runtime?.supportsToolExecution === false
? "ask"
: workMode;
const setWorkMode = (mode: InteractiveWorkMode): void => {
setConversations((current) => current.map((conversation) =>
conversation.id === activeId
? { ...conversation, workMode: mode, updatedAt: Date.now() }
: conversation,
));
};
const activeProjectRecovery =
activeProjectUsesManagedSsh && activeProject
? projectRecoveryByProjectId[activeProject.id]
: undefined;
const activeProjectRecoveryBlocked =
activeProjectUsesManagedSsh &&
(!projectRecoverySnapshotReady ||
isProjectRecoveryUnsettled(activeProjectRecovery));
const cachedWorkspaceViewKeys = useMemo(
() => new Set(cachedWorkspaceViews.map((entry) => entry.key)),
[cachedWorkspaceViews],
);
const cachedConversations = useMemo(() => {
const conversationById = new Map(
conversations.map((conversation) => [conversation.id, conversation]),
);
const cachedIds = [
activeId,
...cachedConversationViews.map((entry) => entry.key),
].filter(
(conversationId, index, values) =>
conversationId && values.indexOf(conversationId) === index,
);
return cachedIds.flatMap((conversationId) => {
const conversation = conversationById.get(conversationId);
return conversation ? [conversation] : [];
});
}, [activeId, cachedConversationViews, conversations]);
const activeRuntimeResolution = useMemo(
() =>
runtimeSettings
? resolveConversationRuntime(
activeProject,
activeConversation?.runtimeSelection,
runtimeSettings,
)
: undefined,
[activeConversation?.runtimeSelection, activeProject, runtimeSettings],
);
// Resolved selections are always concrete, so their key already reflects the model in effect.
const activeRuntimeSelection = activeRuntimeResolution?.selection;
const activeRuntimeSelectionKey = activeRuntimeSelection
? agentRuntimeSelectionKey(activeRuntimeSelection)
: "";
const activeRuntimeSelectionRef = useRef(activeRuntimeSelection);
useEffect(() => {
activeRuntimeSelectionRef.current = activeRuntimeSelection;
}, [activeRuntimeSelection]);
const activeRuntimeLabel = runtimeChoiceLabel(
activeRuntimeResolution,
runtimeSettings,
runtime,
t,
);
// The model ID stays in the accessible name and tooltip; the visible label stays short.
const activeRuntimeModelDetail =
activeRuntimeResolution && runtimeSettings
? runtimeModelDetail(
activeRuntimeResolution.provider,
activeRuntimeResolution.model,
runtimeSettings,
t,
)
: undefined;
// Conversation refreshes replace the conversation object, so the runtime
// selection identity changes without the selection itself changing. Keying
// this effect on the resolved scope keeps the runtime controls mounted
// instead of clearing and refetching the snapshot on every refresh.
const runtimeNativeScopeKey = [
activeConversation?.remote ? "remote" : "local",
activeProjectUsesManagedSsh ? "managed-ssh" : "direct",
activeProjectId ?? "",
activeRuntimeSelectionKey,
].join("\u0000");
const runtimeNativeScopeKeyRef = useRef<string | undefined>(undefined);
useEffect(() => {
runtimeSwitchGenerationRef.current += 1;
const generation = runtimeSwitchGenerationRef.current;
queueMicrotask(() => {
if (runtimeSwitchGenerationRef.current === generation) {
setRuntimeSwitching(false);
}
});
}, [activeId]);
useEffect(() => {
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) {
setSelectedRuntimeAgent("");
setSelectedRuntimeCommand("");
setSelectedContinuePreset("");
}
});
return () => { cancelled = true; };
}, [activeId, runtimeNativeScopeKey]);
useEffect(() => {
if (runtimeNativeScopeKeyRef.current === runtimeNativeScopeKey) {
return;
}
if (
runtimeNativeScopeKeyRef.current !== undefined &&
runtimeNativeScopeKeyRef.current !== runtimeNativeScopeKey
) {
runtimeNativeRetryScopesRef.current.delete(runtimeNativeScopeKey);
}
runtimeNativeScopeKeyRef.current = runtimeNativeScopeKey;
const selection = activeRuntimeSelectionRef.current;
const cached = runtimeNativeCacheRef.current.get(runtimeNativeScopeKey);
const requestId = runtimeCustomizationRequestRef.current + 1;
runtimeCustomizationRequestRef.current = requestId;
queueMicrotask(() => {
if (runtimeCustomizationRequestRef.current !== requestId) {
return;
}
setRuntimeNativeSnapshot(cached?.snapshot);
setRuntimeCustomization(cached?.customization);
});
if (
!selection ||
activeConversation?.remote ||
activeProjectUsesManagedSsh ||
(selection.provider !== "opencode" && selection.provider !== "continue")
) {
return;
}
const provider = selection.provider;
void Promise.all([
window.goodbuddy.runtimeCustomization.getSettings(),
window.goodbuddy.runtimeCustomization.getNativeSnapshot({
provider,
...("profileId" in selection && selection.profileId
? { profileId: selection.profileId }
: {}),
...(activeProjectId ? { projectId: activeProjectId } : {}),
}),
])
.then(([customization, snapshot]) => {
if (runtimeCustomizationRequestRef.current !== requestId) {
return;
}
if (
snapshot.inventoryStatus === "unavailable" ||
snapshot.inventoryStatus === "partial"
) {
// Keep a healthy cached inventory; otherwise retain usable partial data.
runtimeNativeCacheRef.current.set(
runtimeNativeScopeKey,
cached?.snapshot.available ? cached : { customization, snapshot },
);
throw new Error(snapshot.detail);
}
setRuntimeCustomization(customization);
setRuntimeNativeSnapshot(snapshot);
runtimeNativeCacheRef.current.set(runtimeNativeScopeKey, {
customization,
snapshot,
});
runtimeNativeRetryScopesRef.current.delete(runtimeNativeScopeKey);
})
.catch(() => {
if (runtimeCustomizationRequestRef.current === requestId) {
const retained = runtimeNativeCacheRef.current.get(runtimeNativeScopeKey);
setRuntimeCustomization(retained?.customization);
setRuntimeNativeSnapshot(retained?.snapshot);
runtimeNativeScopeKeyRef.current = undefined;
if (!runtimeNativeRetryScopesRef.current.has(runtimeNativeScopeKey)) {
runtimeNativeRetryScopesRef.current.add(runtimeNativeScopeKey);
setRuntimeNativeRetry((value) => value + 1);
}
}
});
}, [
activeConversation?.remote,
activeProjectUsesManagedSsh,
activeProjectId,
runtimeNativeRetry,
runtimeNativeScopeKey,
]);
const runtimeAgentOptions = useMemo<ComposerMenuOption<string>[]>(() => {
if (
activeRuntimeSelection?.provider !== "opencode" ||
!runtimeNativeSnapshot
) {
return [];
}
const configuredDefault = runtimeCustomization?.opencode.defaultAgent;
return [
{
value: "",
label: configuredDefault
? t("composer.runtimeControls.configuredAgent", {
name: configuredDefault,
})
: t("composer.runtimeControls.runtimeDefaultAgent"),
description: t(
"composer.runtimeControls.runtimeDefaultAgentDescription",
),
},
...runtimeNativeSnapshot.agents
.filter(
(agent) =>
!agent.hidden && (agent.mode === "primary" || agent.mode === "all"),
)
.map((agent) => ({
value: agent.id,
label: agent.name,
description:
agent.description ?? t("composer.runtimeControls.agentDescription"),
})),
];
}, [
activeRuntimeSelection?.provider,
runtimeCustomization?.opencode.defaultAgent,
runtimeNativeSnapshot,
t,
]);
const runtimePresetOptions = useMemo<ComposerMenuOption<string>[]>(() => {
if (
activeRuntimeSelection?.provider !== "continue" ||
!runtimeCustomization
) {
return [];
}
return [
{
value: "",
label: t("composer.runtimeControls.noPreset"),
description: t("composer.runtimeControls.noPresetDescription"),
},
...runtimeCustomization.continue.presets.map((preset) => ({
value: preset.id,
label: preset.name,
description:
preset.description ??
t("composer.runtimeControls.presetDescription", {
rules: preset.rules.filter((rule) => rule.enabled).length,
prompts: preset.prompts.length,
}),
})),
];
}, [activeRuntimeSelection?.provider, runtimeCustomization, t]);
const runtimeActionOptions = useMemo<RuntimeActionChoice[]>(() => {
if (!runtimeNativeSnapshot) {
return [];
}
const nativePrompts = runtimeNativeSnapshot.prompts;
const selectedPreset =
activeRuntimeSelection?.provider === "continue"
? runtimeCustomization?.continue.presets.find(
(preset) =>
preset.id ===
(selectedContinuePreset ||
runtimeCustomization.continue.defaultPresetId),
)
: undefined;
return [
{
value: "",
label: t("composer.runtimeControls.noAction"),
description: t("composer.runtimeControls.noActionDescription"),
},
...(activeRuntimeSelection?.provider === "opencode"
? runtimeNativeSnapshot.commands.map((command) => ({
value: JSON.stringify(["command", command.id]),
label: `/${command.name}`,
description:
command.description ??
t("composer.runtimeControls.commandDescription"),
action: {
type: "command" as const,
id: command.id,
},
}))
: []),
...nativePrompts.map((prompt) => ({
value: JSON.stringify(["native-prompt", prompt.id]),
label: prompt.name,
description:
prompt.description ?? t("composer.runtimeControls.promptDescription"),
action: {
type: "prompt" as const,
prompt: prompt.prompt,
},
})),
...(selectedPreset?.prompts.map((prompt) => ({
value: JSON.stringify(["preset-prompt", selectedPreset.id, prompt.id]),
label: prompt.name,
description:
prompt.description ?? t("composer.runtimeControls.promptDescription"),
action: {
type: "prompt" as const,
prompt: prompt.prompt,
},
})) ?? []),
];
}, [
activeRuntimeSelection?.provider,
runtimeCustomization,
runtimeNativeSnapshot,
selectedContinuePreset,
t,
]);
const selectRuntimeAction = useCallback(
(value: string): void => {
if (!value) {
setSelectedRuntimeCommand("");
return;
}
const choice = runtimeActionOptions.find(
(candidate) => candidate.value === value,
);
if (choice?.action?.type === "command") {
setSelectedRuntimeCommand(choice.action.id);
return;
}
if (choice?.action?.type === "prompt") {
setInput(choice.action.prompt);
setSelectedRuntimeCommand("");
requestAnimationFrame(() => {
resizeComposerTextarea(inputRef.current);
inputRef.current?.focus();
});
}
},
[runtimeActionOptions, setInput],
);
useEffect(() => {
if (!runtimeMenuOpen) {
return;
}
const menu = runtimeMenuRef.current;
if (!menu) {
return;
}
const menuItems = Array.from(
menu.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"], [role="menuitem"]',
),
).filter((item) => !item.disabled);
const initialItem =
menuItems.find((item) => item.getAttribute("aria-checked") === "true") ??
menuItems[0];
menuItems.forEach((item) => {
item.tabIndex = item === initialItem ? 0 : -1;
});
const focusFrame = requestAnimationFrame(() => {
initialItem?.focus();
});
const isRuntimeMenuTarget = (target: EventTarget | null): boolean =>
target instanceof Node &&
(menu.contains(target) ||
runtimeMenuButtonRef.current?.contains(target) === true);
const dismissOnOutsidePointer = (event: PointerEvent): void => {
if (!isRuntimeMenuTarget(event.target)) {
setRuntimeMenuOpen(false);
}
};
const dismissOnOutsideFocus = (event: FocusEvent): void => {
if (!isRuntimeMenuTarget(event.target)) {
setRuntimeMenuOpen(false);
}
};
document.addEventListener("pointerdown", dismissOnOutsidePointer);
document.addEventListener("focusin", dismissOnOutsideFocus);
return () => {
cancelAnimationFrame(focusFrame);
document.removeEventListener("pointerdown", dismissOnOutsidePointer);
document.removeEventListener("focusin", dismissOnOutsideFocus);
};
}, [activeRuntimeSelectionKey, runtimeMenuOpen]);
const conversationNavigationRef = useRef({
activeId,
conversations,
});
useEffect(() => {
conversationNavigationRef.current = {
activeId,
conversations,
};
}, [activeId, conversations]);
useEffect(() => {
const selection = activeRuntimeSelectionRef.current;
if (!selection || !runtimeSettings) {
return;
}
if (
runtimeStatusCacheRef.current?.key === activeRuntimeSelectionKey &&
runtimeStatusCacheRef.current.settings === runtimeSettings
) {
return;
}
runtimeStatusCacheRef.current = {
key: activeRuntimeSelectionKey,
settings: runtimeSettings,
};
const requestId = runtimeStatusRequestRef.current + 1;
runtimeStatusRequestRef.current = requestId;
setRuntimeSwitching(false);
setRuntimeStatusKey("");
void window.goodbuddy.agent
.getStatus(selection)
.then((status) => {
if (runtimeStatusRequestRef.current !== requestId) {
return;
}
setRuntime(status);
setRuntimeStatusKey(activeRuntimeSelectionKey);
if (!status.available) {
notify({
tone: "error",
message: tRef.current("runtime.selectionUnavailable", {
label: status.label,
detail: status.detail,
}),
dedupeKey: "runtime-status",
});
}
if (!status.available && !runtimeSetupPromptedRef.current) {
runtimeSetupPromptedRef.current = true;
setView("settings");
}
})
.catch((reason: unknown) => {
if (runtimeStatusRequestRef.current !== requestId) {
return;
}
const detail = reason instanceof Error
? reason.message
: tRef.current("runtime.errors.readStatus");
setRuntime({
id: "setup",
label: tRef.current("runtime.unavailable"),
available: false,
supportsToolExecution: false,
detail,
});
setRuntimeStatusKey(activeRuntimeSelectionKey);
notify({ tone: "error", message: detail, dedupeKey: "runtime-status" });
});
}, [activeRuntimeSelectionKey, runtimeSettings, setView, notify]);
const startNewConversation = useCallback(
(projectId?: string, preview?: { ready: (conversation: Conversation) => void }): boolean => {
const project = projects.find((candidate) => candidate.id === projectId);
if (project?.kind === "channel") {
if (!preview) setView("chat");
notify({
tone: "info",
message: tRef.current("notices.channelConversationAutomatic"),
dedupeKey: "channel-project-new-conversation",
});
return false;
}
const navigation = conversationNavigationRef.current;
const currentConversation = navigation.conversations.find(
(conversation) => conversation.id === navigation.activeId,
);
if (
currentConversation &&
currentConversation.projectId === projectId &&
isUnusedConversation(currentConversation)
) {
if (preview) preview.ready(currentConversation);
else { setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); }
return true;
}
const conversation = createConversation(
projectId,
undefined,
tRef.current("conversation.greeting"),
);
const nextConversations = [conversation, ...navigation.conversations];
conversationNavigationRef.current = {
activeId: conversation.id,
conversations: nextConversations,
};
setConversations(nextConversations);
if (preview) preview.ready(conversation);
else { setActiveId(conversation.id); setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); }
return true;
},
[notify, projects, setActiveId, setView],
);
const activeProjectDisplayName = activeProject
? getProjectDisplayText(activeProject, tWorkspace).name
: undefined;
const queuedConversationIds = useMemo(
() => new Set(conversationQueueItems.map((item) => item.conversationId)),
[conversationQueueItems],
);
const filteredConversations = useMemo(() => {
const query = deferredSearchQuery.trim().toLocaleLowerCase();
return sortConversationsForDisplay(conversations.filter(
(conversation) =>
(!activeProjectId || conversation.projectId === activeProjectId) &&
(activeProject?.kind !== "channel" ||
conversation.remote !== undefined) &&
(!query ||
(persistedSearchMatches.query === query && persistedSearchMatches.ids.has(conversation.id)) ||
conversation.title.toLocaleLowerCase().includes(query) ||
(localSearchMatches.query === query && localSearchMatches.ids.has(conversation.id))),
));
}, [
activeProject,
activeProjectId,
conversations,
deferredSearchQuery,
localSearchMatches,
persistedSearchMatches,
]);
const productAssistantTasks = useMemo(
() =>
assistantTasks.filter(
(task) => !task.parentTaskId && task.origin === "schedule",
),
[assistantTasks],
);
const executionStatsRevision = useMemo(
() => assistantTasks.map((task) => `${task.id}:${task.status}:${task.completedAt ?? ""}`).join("|"),
[assistantTasks],
);
const statsConversation = activeConversation?.projectId === activeProjectId
? activeConversation : undefined;
const statsMessageCount = statsConversation?.messageSummary?.count ?? statsConversation?.messages.length ?? 0;
const executionStats = useExecutionStats(
statsConversation?.id,
activeProjectId || undefined,
`${executionStatsRevision}:${statsMessageCount}:${activeConversationIds.has(activeId)}`,
assistantSidebarOpen && assistantSidebarTab === "tasks",
);
const taskDurations = useMemo(() => new Map(
executionStats.project?.taskDurations.map((task) => [task.id, {
durationMs: task.durationMs,
incomplete: task.incompleteRequestCount > 0,
}]) ?? [],
), [executionStats.project]);
const tasksByConversation = useMemo(() => {
const grouped = new Map<string, AssistantTask[]>();
for (const task of productAssistantTasks) {
if (!task.conversationId) {
continue;
}
const existing = grouped.get(task.conversationId) ?? [];
existing.push(task);
grouped.set(task.conversationId, existing);
}
for (const tasks of grouped.values()) {
tasks.sort((left, right) =>
right.createdAt.localeCompare(left.createdAt),
);
}
return grouped;
}, [productAssistantTasks]);
const conversationTitles = useMemo(
() =>
new Map(
conversations.map((conversation) => [
conversation.id,
getConversationDisplayTitle(
conversation,
t("conversation.defaultTitle"),
),
]),
),
[conversations, t],
);
const projectNames = useMemo(
() =>
new Map(
projects.map((project) => [
project.id,
getProjectDisplayText(project, tWorkspace).name,
]),
),
[projects, tWorkspace],
);
const { completedConversationIds, markConversationCompleted, clearConversationCompleted } =
useUnviewedCompletions(assistantTasks,
view === "chat" && !settingsOpen && !applicationCenterOpen && activeConversation && !activeConversation.messageSummary
? activeId : undefined);
const projectActivity = useMemo(
() => deriveConversationActivity(
conversations.map((conversation) => ({
...conversation,
title: conversationTitles.get(conversation.id) ?? conversation.title,
})),
assistantTasks,
activeConversationIds,
projects.map((project) => ({
id: project.id,
name: projectNames.get(project.id) ?? project.name,
})),
tWorkspace("projectActivity.unassigned"),
completedConversationIds,
),
[conversations, conversationTitles, assistantTasks, activeConversationIds,
projects, projectNames, tWorkspace, completedConversationIds],
);
const activityByConversationId = useMemo(
() =>
new Map(
projectActivity.activities.filter((activity) => activity.status !== "completed").map((activity) => [
activity.conversationId,
activity,
]),
),
[projectActivity],
);
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
() =>
conversations.flatMap((conversation) =>
conversation.messages.flatMap((message) =>
message.approval
? [
{
conversationId: conversation.id,
projectId: conversation.projectId,
messageId: message.id,
taskId: message.approval.taskId ?? message.task?.id,
approvalId: message.approval.id,
title: message.approval.title,
description: message.approval.description,
toolName: message.approval.toolName,
},
]
: [],
),
),
[conversations],
);
const sidebarArtifacts = useMemo<SidebarArtifact[]>(
() =>
assistantArtifacts
.filter(
(artifact) =>
!activeProjectId || artifact.projectId === activeProjectId,
)
.map((artifact) => ({
id: artifact.id,
title: artifact.title,
content: artifact.content ?? "",
createdAt: new Date(artifact.createdAt).getTime(),
mimeType: artifact.mimeType,
})),
[activeProjectId, assistantArtifacts],
);
// Pending supervisor suggestions; earlier report proposals are migrated into them.
const [pendingHeartbeatSuggestionCount, setPendingHeartbeatSuggestionCount] = useState(0);
const updateMessage = useCallback(
(
conversationId: string,
messageId: string,
update: (message: Message) => Message,
): void => {
setConversations((current) => {
const conversationIndex = current.findIndex(item => item.id === conversationId);
const conversation = current[conversationIndex];
if (!conversation) return current;
const messageIndex = conversation.messages.findIndex(message => message.id === messageId);
const message = conversation.messages[messageIndex];
if (!message) return current;
const updated = update(message);
if (updated === message) return current;
const messages = [...conversation.messages];
messages[messageIndex] = updated;
const next = [...current];
next[conversationIndex] = { ...conversation, updatedAt: Date.now(), messages };
return next;
});
},
[],
);
const recordActivity = useCallback(
(
record: Omit<ActivityRecord, "id" | "createdAt" | "scope">,
scopeOverride?: ActivityRecord["scope"],
): void => {
const conversation = conversationsRef.current.find(
(candidate) => candidate.id === record.conversationId,
);
const project = conversation?.projectId
? projectsRef.current.find(
(candidate) => candidate.id === conversation.projectId,
)
: undefined;
const scope: ActivityRecord["scope"] =
scopeOverride ??
(!conversation
? { kind: "unavailable" }
: !conversation.projectId
? { kind: "global" }
: project?.id && project.name
? {
kind: "project",
projectId: project.id.slice(0, 256),
projectName: project.name.slice(0, 120),
}
: { kind: "unavailable" });
setActivityRecords((current) =>
upsertActivityRecord(current, {
...record,
title: record.title.slice(0, 240),
scope,
id: crypto.randomUUID(),
createdAt: Date.now(),
}),
);
},
[],
);
const updateRequestActivity = useCallback(
(
requestId: string,
status: ActivityRecord["status"],
detail?: string,
): void => {
setActivityRecords((current) =>
current.map((record) =>
record.requestId === requestId && record.kind === "request"
? {
...record,
status,
detail: detail ?? record.detail,
}
: record,
),
);
},
[],
);
useEffect(() => {
const api = window.goodbuddy.channels;
if (!api) {
return;
}
return api.onRemoteActivity((activity) => {
if (activity.kind === "result") {
updateRequestActivity(
activity.requestId,
activity.status,
activity.detail,
);
}
recordActivity(
{
requestId: activity.requestId,
conversationId: activity.conversationId,
callId: activity.callId,
kind: activity.kind,
title: activity.title,
detail: activity.detail,
status: activity.status,
},
{
kind: "project",
projectId: activity.projectId.slice(0, 256),
projectName: activity.projectName.slice(0, 120),
},
);
});
}, [recordActivity, updateRequestActivity]);
const refreshKnowledge = useCallback(
async (libraryId?: string): Promise<KnowledgeSnapshot> => {
const requestId = ++knowledgeLoadRequestRef.current;
try {
const [snapshot, instances] = await Promise.all([
window.goodbuddy.knowledge.getSnapshot(libraryId),
window.goodbuddy.knowledge.externalInstancesList(),
]);
if (requestId !== knowledgeLoadRequestRef.current) {
return snapshot;
}
failedKnowledgeLibraryIdRef.current = undefined;
setKnowledgeSnapshot(snapshot);
setExternalInstances(instances);
setKnowledgeLoadError(undefined);
const availableIds = new Set(
snapshot.libraries.map((library) => library.id),
);
setConversations((current) =>
current.map((conversation) => {
const previous = conversation.knowledgeLibraryIds ?? [];
const next = previous.filter((id) => availableIds.has(id));
return previous.length === next.length
? conversation
: {
...conversation,
knowledgeLibraryIds: next,
updatedAt: Date.now(),
};
}),
);
return snapshot;
} catch (reason) {
if (requestId !== knowledgeLoadRequestRef.current) {
throw reason;
}
failedKnowledgeLibraryIdRef.current = libraryId;
setKnowledgeLoadError(
displayErrorMessage(
reason,
tRef.current("notices.knowledgeReadFailed"),
),
);
throw reason;
}
},
[],
);
const retryKnowledgeLoad = useCallback(async (): Promise<void> => {
setKnowledgeLoading(true);
setKnowledgeLoadError(undefined);
try {
await refreshKnowledge(failedKnowledgeLibraryIdRef.current);
} catch {
// The recoverable page state is set by refreshKnowledge.
} finally {
setKnowledgeLoading(false);
}
}, [refreshKnowledge]);
/** Writes the conversation layer (undefined = follow the project) and refreshes status. */
const switchRuntime = useCallback(
async (layer: RuntimeSelectionLayer | undefined): Promise<void> => {
if (!runtimeSettings || !activeConversation || runtimeSwitching) {
return;
}
const nextLayer = compactRuntimeSelectionLayer(layer);
const resolved = resolveConversationRuntime(
activeProject,
nextLayer,
runtimeSettings,
);
const selection = resolved.selection;
runtimeMenuButtonRef.current?.focus();
setRuntimeSwitching(true);
setRuntimeMenuOpen(false);
const requestId = runtimeStatusRequestRef.current + 1;
runtimeStatusRequestRef.current = requestId;
const generation = runtimeSwitchGenerationRef.current;
try {
const status = await window.goodbuddy.agent.getStatus(selection);
if (
runtimeStatusRequestRef.current !== requestId ||
runtimeSwitchGenerationRef.current !== generation
) {
return;
}
const selectionKey = agentRuntimeSelectionKey(selection);
runtimeStatusCacheRef.current = {
key: selectionKey,
settings: runtimeSettings,
};
const label = runtimeChoiceLabel(
resolved,
runtimeSettings,
status,
tRef.current,
);
setConversations((current) =>
current.map((conversation) =>
conversation.id === activeConversation.id
? {
...conversation,
runtimeSelection: nextLayer,
updatedAt: Date.now(),
}
: conversation,
),
);
setRuntime(status);
setRuntimeStatusKey(selectionKey);
notify({
tone: status.available ? "success" : "error",
message: status.available
? tRef.current("runtime.switched", { label })
: tRef.current("runtime.selectionUnavailable", {
label,
detail: status.detail,
}),
dedupeKey: "runtime-switch",
});
} catch (reason) {
if (
runtimeStatusRequestRef.current !== requestId ||
runtimeSwitchGenerationRef.current !== generation
) {
return;
}
notify({
tone: "error",
message:
reason instanceof Error
? reason.message
: tRef.current("runtime.errors.switch"),
dedupeKey: "runtime-switch",
});
} finally {
if (
runtimeStatusRequestRef.current === requestId &&
runtimeSwitchGenerationRef.current === generation
) {
setRuntimeSwitching(false);
requestAnimationFrame(() => {
runtimeMenuButtonRef.current?.focus();
});
}
}
},
[
activeConversation,
activeProject,
runtimeSettings,
runtimeSwitching,
],
);
const refreshTokenUsage = useCallback(async (): Promise<void> => {
setTokenUsage(await window.goodbuddy.usage.getTokenSummary());
}, []);
const loadWorkspaceChanges = useCallback(
async (projectId: string): Promise<void> => {
const requestId = workspaceChangesRequestRef.current + 1;
workspaceChangesRequestRef.current = requestId;
const changes = await window.goodbuddy.workspace.getChanges(projectId);
if (
workspaceChangesRequestRef.current === requestId &&
activeProjectIdRef.current === projectId
) {
setWorkspaceChanges((current) => ({
projectId,
changes:
changes.error &&
current?.projectId === projectId &&
current.changes.available
? { ...current.changes, error: changes.error }
: changes,
}));
}
},
[],
);
const releaseConversationQueueAfterRun = useCallback(
(run: Pick<ActiveRun, "conversationId" | "projectId">): void => {
requestAnimationFrame(() => {
if (
run.projectId &&
((!projectRecoverySnapshotReadyRef.current &&
isManagedSshProject(
projectsRef.current.find(
(project) => project.id === run.projectId,
),
)) ||
isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[run.projectId],
))
) {
return;
}
void window.goodbuddy.conversationQueue
.ready(run.conversationId)
.catch(() => {
notify({
tone: "error",
message: tRef.current("notices.conversationQueueResumeFailed"),
dedupeKey: "conversation-queue-resume",
});
});
});
},
[notify],
);
const handleAgentEvent = useCallback(
(event: AgentEvent): void => {
const run = activeRuns.current.get(event.requestId);
if (!run) {
if (event.type !== "approval") {
return;
}
const attachScheduledApproval = (
task: AssistantTask | undefined,
): void => {
if (!task?.conversationId || task.origin !== "schedule") {
return;
}
setAssistantTasks((current) =>
current.map((candidate) =>
candidate.id === task.id
? { ...candidate, status: "waiting_approval" }
: candidate,
),
);
if (activeConversationIdRef.current !== task.conversationId) {
setUnreadConversationIds((current) => {
const next = new Set(current);
next.add(task.conversationId!);
return next;
});
}
setConversations((current) =>
current.map((conversation) => {
if (conversation.id !== task.conversationId) {
return conversation;
}
const existing = conversation.messages.find(
(message) => message.approval?.id === event.approvalId,
);
if (existing) {
return conversation;
}
return {
...conversation,
updatedAt: Date.now(),
messages: [
...conversation.messages,
{
id: crypto.randomUUID(),
role: "assistant",
content: event.title,
createdAt: Date.now(),
state: "complete",
task: {
id: task.id,
title: task.title,
},
approval: {
id: event.approvalId,
title: event.title,
description: event.description,
toolName: event.toolName,
argumentSummary: event.argumentSummary,
allowPermanent: event.allowPermanent,
},
},
],
};
}),
);
};
const task = assistantTasksRef.current.find(
(candidate) => candidate.id === event.requestId,
);
if (task) {
attachScheduledApproval(task);
} else {
void window.goodbuddy.tasks
.list()
.then((tasks) => {
setAssistantTasks(tasks);
assistantTasksRef.current = tasks;
attachScheduledApproval(
tasks.find((candidate) => candidate.id === event.requestId),
);
})
.catch(() => undefined);
}
return;
}
const liveMessage = conversationsRef.current
.find((conversation) => conversation.id === run.conversationId)
?.messages.find((message) => message.id === run.messageId);
if (event.type === "question-resolved") {
updateMessage(run.conversationId, run.messageId, (message) => {
if (!message.pendingQuestions?.some((question) => question.questionId === event.questionId)) return message;
const pendingQuestions = message.pendingQuestions.filter((question) => question.questionId !== event.questionId);
if (!pendingQuestions.length && !message.approval && message.state === "streaming") {
setAssistantTasks((current) => current.map((task) =>
task.id === event.requestId && task.status === "waiting_approval" ? { ...task, status: "running" } : task,
));
}
return { ...message, pendingQuestions };
});
return;
}
if (event.type === "question" && (
liveMessage?.state !== "streaming" ||
liveMessage.pendingQuestions?.some((question) => question.questionId === event.questionId) ||
liveMessage.answeredQuestions?.some((question) => question.questionId === event.questionId)
)) return;
setAssistantTasks((current) => {
let changed = false;
const updated = current.map((task) => {
if (task.id !== event.requestId) {
return task;
}
const status: AssistantTask["status"] =
event.type === "approval" || event.type === "question"
? "waiting_approval"
: event.type === "done"
? "completed"
: event.type === "error"
? event.status
: liveMessage?.pendingQuestions?.length || liveMessage?.approval
? "waiting_approval"
: "running";
const completedAt =
event.type === "done" || event.type === "error"
? new Date().toISOString()
: task.completedAt;
const error = event.type === "error" ? event.message : task.error;
if (
task.status === status &&
task.completedAt === completedAt &&
task.error === error
) {
return task;
}
changed = true;
return {
...task,
status,
completedAt,
error,
};
});
return changed ? updated : current;
});
if (event.type === "done") {
if (run.projectId && activeProjectIdRef.current === run.projectId) {
void loadWorkspaceChanges(run.projectId).catch(() =>
notify({
tone: "error",
message: tRef.current("notices.workspaceChangesReadFailed"),
}),
);
}
if (viewRef.current === "activity") {
void refreshTokenUsage().catch(() =>
notify({
tone: "error",
message: tRef.current("notices.tokenUsageReadFailed"),
}),
);
}
void window.goodbuddy.artifacts
.list()
.then((artifacts) =>
setAssistantArtifacts((current) =>
mergeArtifacts(current, artifacts),
),
)
.catch(() =>
notify({
tone: "error",
message: tRef.current("notices.resultsRefreshFailed"),
}),
);
} else if (event.type === "artifact") {
hydratingArtifactIds.current.add(event.artifactId);
void window.goodbuddy.artifacts
.get(event.artifactId)
.then((artifact) =>
setAssistantArtifacts((current) =>
mergeArtifacts(current, [artifact]),
),
)
.catch(() =>
notify({
tone: "error",
message: tRef.current("notices.generatedImageReadFailed"),
}),
)
.finally(() => {
hydratingArtifactIds.current.delete(event.artifactId);
});
}
if (event.type === "checklist") {
updateMessage(run.conversationId, run.messageId, (message) =>
message.state === "streaming"
? { ...message, runtimeChecklist: event.checklist }
: message,
);
} else if (event.type === "text") {
updateMessage(run.conversationId, run.messageId, (message) => {
const blocks = appendMessageContentBlock(
message.blocks,
"text",
event.delta,
);
return {
...message,
content: `${message.content}${event.delta}`,
blocks,
status: undefined,
};
});
} else if (event.type === "reasoning") {
updateMessage(run.conversationId, run.messageId, (message) => {
const currentReasoning = message.reasoning ?? "";
const blocks = appendMessageContentBlock(
message.blocks,
"reasoning",
event.delta,
);
return {
...message,
reasoning: `${currentReasoning}${event.delta}`,
status: undefined,
blocks,
};
});
} else if (event.type === "context-metrics") {
setConversations((current) =>
current.map((conversation) =>
conversation.id === run.conversationId
? {
...conversation,
contextMetrics: {
contextTokens: event.contextTokens,
source: event.source,
basis: "model-call",
runtimeSelectionKey: run.runtimeSelectionKey,
},
}
: conversation,
),
);
} else if (event.type === "context-compression") {
const estimatedAfterTokens = event.estimatedAfterTokens;
const conversationScoped = event.scope !== "agent-run";
const scope = event.scope ?? "conversation";
const marker: ConversationContextCompressionMarker = {
state: event.state === "started" ? "compressing" : event.state,
scope,
estimatedBeforeTokens: event.estimatedBeforeTokens,
estimatedAfterTokens: event.estimatedAfterTokens,
compressionCount: event.compressionCount,
};
updateMessage(run.conversationId, run.messageId, (message) => {
const current =
message.contextCompressions ??
(message.contextCompression ? [message.contextCompression] : []);
const existingIndex = current.findIndex(
(compression) => (compression.scope ?? "conversation") === scope,
);
const contextCompressions =
existingIndex >= 0
? [
...current.filter(
(_compression, index) => index !== existingIndex,
),
marker,
]
: [...current, marker];
return {
...message,
contextCompression: undefined,
contextCompressions,
};
});
if (
conversationScoped &&
event.state === "completed" &&
estimatedAfterTokens !== undefined
) {
setConversations((current) =>
current.map((conversation) =>
conversation.id === run.conversationId
? {
...conversation,
contextMetrics: {
runtimeSelectionKey: run.runtimeSelectionKey,
contextTokens: estimatedAfterTokens,
source: "estimated",
basis: "conversation",
},
contextCompressionState:
event.conversationState ??
conversation.contextCompressionState,
}
: conversation,
),
);
} else if (conversationScoped && event.conversationState) {
setConversations((current) =>
current.map((conversation) =>
conversation.id === run.conversationId
? {
...conversation,
contextCompressionState: event.conversationState,
}
: conversation,
),
);
}
} else if (event.type === "status") {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
status: event.message,
}));
} else if (event.type === "tool") {
recordActivity({
conversationId: run.conversationId,
requestId: event.requestId,
callId: event.callId.slice(0, 256),
kind: "tool",
title: event.name,
detail: [event.summary, event.error].filter(Boolean).join("\n"),
status:
event.state === "pending"
? "pending"
: event.state === "running"
? "running"
: event.state === "failed"
? "failed"
: "completed",
});
updateMessage(run.conversationId, run.messageId, (message) => {
const tools = [...(message.tools ?? [])];
const index = tools.findIndex(
(tool) => tool.callId === event.callId.slice(0, 256),
);
const tool = {
callId: event.callId.slice(0, 256),
name: event.name,
state: event.state,
summary: event.summary,
input: event.input,
output: event.output,
error: event.error,
};
if (index >= 0) {
tools[index] = tool;
} else {
tools.push(tool);
}
const blocks = upsertMessageToolBlock(message.blocks, tool);
return {
...message,
tools,
blocks,
status: undefined,
};
});
} else if (event.type === "subagent") {
const actor =
"actor" in event
? event.actor
: {
kind: "expert" as const,
expertId: event.expertId,
expertName: event.expertName,
};
const actorLabel =
actor.kind === "direct-model"
? tRef.current("chat.subagents.directModelLabel")
: actor.expertName;
const childStatus = event.state;
const completedAt =
event.state === "completed" ||
event.state === "failed" ||
event.state === "cancelled"
? new Date().toISOString()
: undefined;
if (actor.kind === "expert") {
setAssistantTasks((current) => {
const existing = current.find(
(task) => task.id === event.childTaskId,
);
const childTask: AssistantTask = {
id: event.childTaskId,
projectId: run.projectId,
conversationId: run.conversationId,
parentTaskId: event.requestId,
expertId: actor.expertId,
routingMode:
event.routingMode === "native" ? undefined : event.routingMode,
title: actor.expertName,
instructions:
event.reason ??
tRef.current("chat.subagents.fallbackTask", {
name: actor.expertName,
}),
origin: "subagent",
status: childStatus,
createdAt: existing?.createdAt ?? new Date().toISOString(),
startedAt:
event.state === "running"
? (existing?.startedAt ?? new Date().toISOString())
: existing?.startedAt,
completedAt: completedAt ?? existing?.completedAt,
error: event.error,
};
if (
existing &&
(Object.keys(childTask) as (keyof AssistantTask)[]).every(
(key) => existing[key] === childTask[key],
)
) {
return current;
}
return existing
? current.map((task) =>
task.id === event.childTaskId ? childTask : task,
)
: [...current, childTask];
});
}
if (event.runtimeCallId) {
setActivityRecords((current) => {
const remaining = current.filter(
(record) =>
!(
record.requestId === event.requestId &&
record.kind === "tool" &&
record.callId === event.runtimeCallId
),
);
return remaining.length === current.length ? current : remaining;
});
}
recordActivity({
conversationId: run.conversationId,
requestId: event.requestId,
callId: event.childTaskId,
kind: "subagent",
title: actorLabel,
detail: [
actor.kind === "direct-model"
? tRef.current("chat.subagents.directModel")
: event.routingMode === "smart"
? tRef.current("chat.subagents.smart")
: event.routingMode === "native"
? tRef.current("chat.subagents.native")
: tRef.current("chat.subagents.manual"),
actor.kind === "direct-model" && event.workMode
? event.workMode === "execute"
? "Execute"
: "Ask"
: undefined,
event.reason,
event.error,
]
.filter(Boolean)
.join(" · "),
status: event.state === "queued" ? "pending" : event.state,
});
updateMessage(run.conversationId, run.messageId, (message) => {
const subagents = [...(message.subagents ?? [])];
const index = subagents.findIndex(
(subagent) => subagent.childTaskId === event.childTaskId,
);
const commonSubagent = {
childTaskId: event.childTaskId,
routingMode: event.routingMode,
runtimeCallId: event.runtimeCallId,
workMode: event.workMode,
state: event.state,
reason: event.reason,
progress: event.progress,
output: event.output,
error: event.error,
};
const subagent: SubagentActivity =
"actor" in event
? { ...commonSubagent, actor: event.actor }
: {
...commonSubagent,
expertId: event.expertId,
expertName: event.expertName,
};
if (index >= 0) {
subagents[index] = subagent;
} else {
subagents.push(subagent);
}
const runtimeCallId = event.runtimeCallId;
const blocks = upsertMessageSubagentBlock(
message.blocks,
event.childTaskId,
runtimeCallId,
);
return {
...message,
subagents,
tools: runtimeCallId
? message.tools?.filter((tool) => tool.callId !== runtimeCallId)
: message.tools,
blocks,
};
});
} else if (event.type === "approval") {
recordActivity({
conversationId: run.conversationId,
requestId: event.requestId,
kind: "approval",
title: event.title,
detail: event.description,
status: "pending",
});
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
status: undefined,
approval: {
id: event.approvalId,
taskId: run.taskId,
title: event.title,
description: event.description,
toolName: event.toolName,
argumentSummary: event.argumentSummary,
allowPermanent: event.allowPermanent,
},
}));
} else if (event.type === "question") {
updateMessage(run.conversationId, run.messageId, (message) => {
if (message.state !== "streaming" ||
message.pendingQuestions?.some((question) => question.questionId === event.questionId) ||
message.answeredQuestions?.some((question) => question.questionId === event.questionId)) {
return message;
}
return {
...message,
status: undefined,
blocks: appendConversationQuestionBlock(message.blocks, event.questionId),
pendingQuestions: [...(message.pendingQuestions ?? []), event],
};
});
} else if (event.type === "artifact") {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
artifactIds: [
...new Set([...(message.artifactIds ?? []), event.artifactId]),
].slice(-8),
imageContextNotice: event.imageContextNotice,
status: tRef.current("chat.status.savingImage"),
}));
} else if (event.type === "knowledge-retrieval") {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
knowledgeRetrieval: {
mode: event.mode,
state: event.state,
libraryCount: event.libraryCount,
resultCount: event.resultCount,
durationMs: event.durationMs,
usedChannels: event.usedChannels,
warnings: event.warnings,
},
status:
event.state === "searching"
? tRef.current("chat.knowledgeRetrieval.searching")
: undefined,
}));
} else if (event.type === "source-references") {
updateMessage(run.conversationId, run.messageId, (message) => {
const incoming = [
...new Map(
event.references.map((reference) => [
knowledgeReferenceKey(reference),
reference,
]),
).values(),
];
const incomingKeys = new Set(incoming.map(knowledgeReferenceKey));
const references = [
...incoming,
...(message.sourceReferences ?? []).filter(
(reference) => !incomingKeys.has(knowledgeReferenceKey(reference)),
),
].slice(0, 20);
return {
...message,
sourceReferences: references,
};
});
} else if (event.type === "done" || event.type === "error") {
const terminalStatus =
event.type === "error"
? event.status === "cancelled"
? "cancelled"
: "failed"
: "completed";
const incompleteSubagentError = tRef.current(
"chat.subagents.incomplete",
);
const incompleteActivityDetail = tRef.current(
"chat.status.activityIncomplete",
);
updateRequestActivity(
event.requestId,
terminalStatus,
event.type === "error"
? event.message
: tRef.current("chat.status.taskCompleted"),
);
setActivityRecords((current) =>
current.map((record) =>
record.requestId === event.requestId &&
record.kind !== "request" &&
(record.status === "pending" || record.status === "running")
? {
...record,
status:
event.type === "done" ? "interrupted" : terminalStatus,
detail: `${record.detail}\n${
event.type === "done"
? incompleteActivityDetail
: event.message
}`,
}
: record,
),
);
recordActivity({
conversationId: run.conversationId,
requestId: event.requestId,
kind: "result",
title:
event.type === "error"
? tRef.current(event.status === "cancelled"
? "chat.status.taskCancelled" : "chat.status.taskFailed")
: tRef.current("chat.status.taskCompleted"),
detail:
event.type === "error"
? event.message
: tRef.current("chat.status.runtimeCompleted"),
status: terminalStatus,
});
setAssistantTasks((current) =>
current.map((task) =>
task.parentTaskId === event.requestId &&
(task.status === "queued" || task.status === "running")
? {
...task,
status: event.type === "done" ? "failed" : terminalStatus,
completedAt: new Date().toISOString(),
error:
event.type === "error"
? event.message
: incompleteSubagentError,
}
: task,
),
);
updateMessage(run.conversationId, run.messageId, (message) => {
const representedToolError =
event.type === "error" &&
isErrorRepresentedByFailedTool(message.tools, event.message);
const toolTerminalState =
event.type === "error"
? event.status === "cancelled"
? ("cancelled" as const)
: ("failed" as const)
: ("interrupted" as const);
return {
...message,
state: event.type === "error" ? "error" : "complete",
terminalStatus: event.type === "error"
? event.status === "cancelled" ? "cancelled" : "failed"
: undefined,
status:
event.type === "error" && (event.status === "cancelled" || !representedToolError)
? event.message
: event.type === "done"
? tRef.current("chat.status.taskCompleted")
: undefined,
contextCompression:
event.type === "error" &&
message.contextCompression?.state === "compressing"
? {
...message.contextCompression,
state: "failed" as const,
}
: message.contextCompression,
contextCompressions:
event.type === "error"
? message.contextCompressions?.map((compression) =>
compression.state === "compressing"
? {
...compression,
state: "failed" as const,
}
: compression,
)
: message.contextCompressions,
approval: undefined,
pendingQuestions: undefined,
tools: toolTerminalState
? message.tools?.map((tool) =>
tool.state === "pending" || tool.state === "running"
? { ...tool, state: toolTerminalState }
: tool,
)
: message.tools,
subagents: message.subagents?.map((subagent) =>
subagent.state === "queued" || subagent.state === "running"
? {
...subagent,
state:
event.type === "error"
? event.status === "cancelled"
? ("cancelled" as const)
: ("failed" as const)
: ("failed" as const),
...(event.type === "error" && event.status !== "cancelled"
? { error: event.message.slice(0, 1_000) }
: event.type === "done"
? { error: incompleteSubagentError }
: {}),
}
: subagent,
),
blocks: terminalizeMessageToolBlocks(message.blocks, toolTerminalState),
};
});
activeRuns.current.delete(event.requestId);
if (event.type === "done") markConversationCompleted(run.conversationId);
setConversationActivity(run.conversationId, false);
releaseConversationQueueAfterRun(run);
flushConversationPersistenceAfterRenderRef.current = true;
}
},
[
recordActivity,
loadWorkspaceChanges,
refreshTokenUsage,
releaseConversationQueueAfterRun,
setConversationActivity,
updateMessage,
updateRequestActivity,
markConversationCompleted,
],
);
useEffect(() => {
activeProjectIdRef.current = activeProjectId;
if (activeProjectId) {
try {
localStorage.setItem(activeProjectStorageKey, activeProjectId);
} catch {
// The project selection still works when persistence is unavailable.
}
}
}, [activeProjectId]);
useEffect(() => {
viewRef.current = view;
}, [view]);
const retainedConversationDetailIds = useCallback((): Set<string> => new Set([
activeConversationIdRef.current,
...cachedConversationViewsRef.current.map(entry => entry.key),
...[...activeRuns.current.values()].map(run => run.conversationId),
...preparingConversations.current,
].filter(Boolean)), []);
const conversationHistoryRequests = useRef(new Map<string, Promise<Conversation>>());
const releaseUnretainedConversationHistories = useCallback((): void => {
const retained = retainedConversationDetailIds();
setConversations(current => {
let changed = false;
const next = current.map(conversation => {
if (conversation.messageSummary || retained.has(conversation.id) ||
conversationHistoryRequests.current.has(conversation.id) ||
conversation.activeRequest ||
(!conversation.remote && persistedLocalConversationsRef.current.get(conversation.id) !== conversation) ||
conversation.messages.some(message => message.state === "streaming" ||
message.approval || message.pendingQuestions?.length) ||
assistantTasksRef.current.some(task => task.conversationId === conversation.id &&
(task.status === "running" || task.status === "waiting_approval"))) return conversation;
changed = true;
const summary: Conversation = {
...conversation, messages: [],
messageSummary: { count: conversation.messages.length, firstRole: conversation.messages[0]?.role },
};
if (!conversation.remote) persistedLocalConversationsRef.current.set(conversation.id, summary);
return summary;
});
return changed ? next : current;
});
}, [retainedConversationDetailIds]);
const persistLocalConversationChanges = useCallback((): void => {
const operation = conversationPersistenceQueueRef.current.then(async () => {
if (conversationPersistencePausedRef.current) {
return;
}
const { batch, acknowledgements } = createLocalConversationSaveBatch(
conversationsRef.current,
persistedLocalConversationsRef.current,
deletingLocalConversationIdsRef.current,
);
if (batch.length === 0) {
releaseUnretainedConversationHistories();
return;
}
await window.goodbuddy.conversations.saveLocal(batch);
for (const conversation of acknowledgements) {
persistedLocalConversationsRef.current.set(
conversation.id,
conversation,
);
}
releaseUnretainedConversationHistories();
});
conversationPersistenceQueueRef.current = operation.catch(() => undefined);
void operation.catch(() => {
notify({
tone: "error",
message: tRef.current("notices.conversationPersistenceFailed"),
dedupeKey: "conversation-persistence",
});
});
}, [releaseUnretainedConversationHistories]);
const ensureConversationHistory = useCallback((conversationId: string): Promise<Conversation> => {
const current = conversationsRef.current.find(item => item.id === conversationId);
if (!current) return Promise.reject(new Error(tRef.current("notices.remoteConversationRefreshFailed")));
if (!current.messageSummary) return Promise.resolve(current);
const pending = conversationHistoryRequests.current.get(conversationId);
if (pending) return pending;
const pinRevision = conversationPinRevisionRef.current;
const request = window.goodbuddy.conversations.get(conversationId).then(snapshot => {
const latest = conversationsRef.current.find(item => item.id === conversationId);
if (!latest || deletingLocalConversationIdsRef.current.has(conversationId)) {
throw new Error(tRef.current("notices.remoteConversationRefreshFailed"));
}
if (!latest.messageSummary) return latest;
if (pinRevision !== conversationPinRevisionRef.current || conversationPinPendingRef.current) {
snapshot = { ...snapshot, pinned: latest.pinned };
}
const next = mergePersistedConversations(
conversationsRef.current, [snapshot], persistedLocalConversationsRef.current,
retainedConversationDetailIds(),
);
conversationsRef.current = next;
setConversations(next);
return next.find(item => item.id === conversationId)!;
}).finally(() => { conversationHistoryRequests.current.delete(conversationId); });
conversationHistoryRequests.current.set(conversationId, request);
return request;
}, [retainedConversationDetailIds]);
useEffect(() => {
if (!conversationStoreReady) return;
// Reuse the view cache's lifetime. Release only acknowledged, idle history,
// including its persistence reference, never unsaved or executing messages.
persistLocalConversationChanges();
}, [cachedConversationViews, conversationStoreReady, persistLocalConversationChanges]);
useEffect(() => {
if (!conversationStoreReady) {
return;
}
persistLocalConversationChanges();
const interval = window.setInterval(
persistLocalConversationChanges,
conversationPersistenceIntervalMs,
);
return () => {
window.clearInterval(interval);
persistLocalConversationChanges();
};
}, [conversationStoreReady, persistLocalConversationChanges]);
useEffect(() => {
if (
!conversationStoreReady ||
!flushConversationPersistenceAfterRenderRef.current
) {
return;
}
flushConversationPersistenceAfterRenderRef.current = false;
persistLocalConversationChanges();
}, [conversationStoreReady, conversations, persistLocalConversationChanges]);
const persistActivityHistory = useCallback(async (): Promise<void> => {
if (!activityHistoryReady) {
return;
}
try {
await window.goodbuddy.activityHistory.replace(
activityRecordsRef.current,
legacyActivityHistoryMayBeIncompleteRef.current,
);
} catch {
notify({
tone: "error",
message: tRef.current("notices.activityHistoryPersistenceFailed"),
dedupeKey: "activity-history-persistence",
});
}
}, [activityHistoryReady]);
useEffect(
() =>
window.goodbuddy.app.onBeforeQuit(async () => {
await persistActivityHistory();
if (!conversationStoreReady) {
return;
}
flushConversationPersistenceAfterRenderRef.current = false;
persistLocalConversationChanges();
await conversationPersistenceQueueRef.current;
await attachmentSaveQueue.current;
}),
[
conversationStoreReady,
persistActivityHistory,
persistLocalConversationChanges,
],
);
useEffect(() => {
if (!conversationStoreReady) {
return;
}
let active = true;
let refreshSequence = 0;
let refreshTimer: number | undefined;
let refreshInFlight = false;
let refreshQueued = false;
const queueRefresh = (): void => {
refreshSequence += 1;
refreshQueued = true;
if (refreshInFlight || refreshTimer !== undefined) {
return;
}
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined;
refresh();
}, 50);
};
// A run whose assistant message has already reached a persisted
// terminal state (for example a remote task that finished while the
// desktop was disconnected) must stop counting as in-flight, otherwise
// the renderer keeps showing "processing" forever.
const settleActiveRunsFromPersistedMessages = (
persisted: readonly ConversationSnapshot[],
): void => {
if (activeRuns.current.size === 0) {
return;
}
const persistedMessageStates = new Map<string, Message["state"]>();
for (const conversation of persisted) {
for (const message of conversation.messages) {
persistedMessageStates.set(
`${conversation.id}\0${message.id}`,
message.state,
);
}
}
for (const [requestId, run] of activeRuns.current) {
const state = persistedMessageStates.get(
`${run.conversationId}\0${run.messageId}`,
);
if (state !== undefined && state !== "streaming") {
activeRuns.current.delete(requestId);
if (state === "complete") markConversationCompleted(run.conversationId);
setConversationActivity(run.conversationId, false);
releaseConversationQueueAfterRun(run);
}
}
};
const refresh = (): void => {
if (refreshInFlight) {
refreshQueued = true;
return;
}
refreshInFlight = true;
refreshQueued = false;
const sequence = refreshSequence;
const pinRevision = conversationPinRevisionRef.current;
const tasksRefresh = window.goodbuddy.tasks
.list()
.then((tasks) => {
if (!active || sequence !== refreshSequence) {
return;
}
setAssistantTasks(tasks);
setActivityRecords((current) =>
reconcileActivityRecords(
current,
tasks,
new Set(activeRuns.current.keys()),
),
);
})
.catch(() => {
if (active && sequence === refreshSequence) {
notify({
tone: "error",
message: tRef.current("notices.taskHistoryReadFailed"),
dedupeKey: "task-lifecycle-refresh",
});
}
});
const schedulesRefresh = window.goodbuddy.schedules
.list()
.then((schedules) => {
if (active && sequence === refreshSequence) {
setAssistantSchedules(schedules);
}
})
.catch(() => {
if (active && sequence === refreshSequence) {
notify({
tone: "error",
message: tRef.current("notices.schedulesReadFailed"),
dedupeKey: "schedule-lifecycle-refresh",
});
}
});
const conversationsRefresh = window.goodbuddy.conversations
.listSummaries([...retainedConversationDetailIds()])
.then((persisted) => {
if (!active || sequence !== refreshSequence) {
return;
}
if (pinRevision !== conversationPinRevisionRef.current) {
queueRefresh();
return;
}
if (conversationPinPendingRef.current) {
const currentPins = new Map(conversationsRef.current.map(item => [item.id, item.pinned]));
persisted = persisted.map(item => ({ ...item, pinned: currentPins.has(item.id) ? currentPins.get(item.id) : item.pinned }));
}
const remote = persisted.filter(
(conversation) => conversation.remote,
);
const previousById = new Map(
conversationsRef.current.map((conversation) => [
conversation.id,
conversation,
]),
);
const updated = remote.filter((conversation) => {
const previous = previousById.get(conversation.id);
return (
previous === undefined ||
conversation.updatedAt > previous.updatedAt
);
});
const unread = updated.filter(
(conversation) =>
conversation.id !== activeConversationIdRef.current,
);
if (unread.length > 0) {
setUnreadConversationIds((current) => {
const next = new Set(current);
unread.forEach((conversation) => next.add(conversation.id));
return next;
});
notify({
tone: "info",
message: tRef.current("notices.remoteMessage", {
channel: projectChannelLabels[unread[0]!.remote!.channel],
}),
dedupeKey: "remote-channel-message",
});
}
setConversations((current) =>
mergePersistedConversations(
current,
persisted,
persistedLocalConversationsRef.current,
retainedConversationDetailIds(),
),
);
settleActiveRunsFromPersistedMessages(persisted);
})
.catch(() => {
if (active && sequence === refreshSequence) {
notify({
tone: "error",
message: tRef.current("notices.remoteConversationRefreshFailed"),
dedupeKey: "remote-conversation-refresh",
});
}
});
void Promise.allSettled([
tasksRefresh,
schedulesRefresh,
conversationsRefresh,
]).finally(() => {
refreshInFlight = false;
if (active && refreshQueued) {
queueRefresh();
}
});
};
const refreshWhenVisible = (): void => {
if (document.visibilityState !== "hidden") {
queueRefresh();
}
};
const remove = window.goodbuddy.conversations.onChanged(queueRefresh);
const removeQueueListener =
window.goodbuddy.conversationQueue.onChanged(queueRefresh);
window.addEventListener("focus", refreshWhenVisible);
document.addEventListener("visibilitychange", refreshWhenVisible);
return () => {
active = false;
if (refreshTimer !== undefined) {
window.clearTimeout(refreshTimer);
}
remove();
removeQueueListener();
window.removeEventListener("focus", refreshWhenVisible);
document.removeEventListener("visibilitychange", refreshWhenVisible);
};
}, [
conversationStoreReady,
releaseConversationQueueAfterRun,
retainedConversationDetailIds,
setConversationActivity,
markConversationCompleted,
]);
useEffect(() => {
let active = true;
let refreshInFlight = false;
let refreshQueued = false;
let refreshTimer: number | undefined;
let refreshAll = false;
const pendingConversationIds = new Set<string>();
const scheduleRefresh = (): void => {
if (refreshTimer !== undefined || refreshInFlight) {
refreshQueued = true;
return;
}
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined;
refreshQueued = false;
const conversationIds = refreshAll
? undefined
: [...pendingConversationIds];
refreshAll = false;
pendingConversationIds.clear();
refresh(conversationIds);
}, 0);
};
const refresh = (conversationIds?: string[]): void => {
refreshInFlight = true;
const affectedConversationIds = new Set(conversationIds ?? []);
const reads = conversationIds
? conversationIds.map(async (conversationId) => ({
conversationId,
items:
await window.goodbuddy.conversationQueue.list(conversationId),
}))
: [
window.goodbuddy.conversationQueue.list().then((items) => ({
conversationId: undefined,
items,
})),
];
void Promise.all(reads)
.then((results) => {
if (!active) {
return;
}
setConversationQueueItems((current) => {
const allItems = results.find(
(result) => result.conversationId === undefined,
)?.items;
const next = allItems
? allItems
: [
...current.filter(
(item) => !affectedConversationIds.has(item.conversationId),
),
...results.flatMap((result) => result.items),
].sort(
(left, right) =>
left.createdAt.localeCompare(right.createdAt) ||
left.id.localeCompare(right.id),
);
return sameConversationQueueItems(current, next) ? current : next;
});
})
.catch(() => {
if (active) {
notify({
tone: "error",
message: tRef.current("notices.conversationQueueReadFailed"),
dedupeKey: "conversation-queue-read",
});
}
})
.finally(() => {
refreshInFlight = false;
if (active && refreshQueued) {
refreshQueued = false;
scheduleRefresh();
}
});
};
const queueRefresh = (conversationId?: string): void => {
if (conversationId) {
pendingConversationIds.add(conversationId);
} else {
refreshAll = true;
}
scheduleRefresh();
};
refresh();
const remove = window.goodbuddy.conversationQueue.onChanged(queueRefresh);
const removeDispatch = window.goodbuddy.conversationQueue.onDispatch(
(dispatch) => conversationQueueDispatchRef.current(dispatch),
);
return () => {
active = false;
if (refreshTimer !== undefined) {
window.clearTimeout(refreshTimer);
}
remove();
removeDispatch();
};
}, []);
useEffect(() => {
let active = true;
void (async () => {
let snapshot: ActivityHistorySnapshot;
try {
snapshot = await window.goodbuddy.activityHistory.get();
} catch {
if (active) {
notify({
tone: "error",
message: tRef.current("notices.activityHistoryReadFailed"),
dedupeKey: "activity-history-read",
});
}
return;
}
if (!active) {
return;
}
const legacyHistoryMayBeIncomplete =
legacyActivityHistory.historyMayBeIncomplete ||
snapshot.legacyHistoryMayBeIncomplete;
if (
legacyActivityHistory.records.length > 0 ||
legacyActivityHistory.historyMayBeIncomplete
) {
try {
await window.goodbuddy.activityHistory.replace(
mergeActivityRecords(
legacyActivityHistory.records,
snapshot.records,
),
legacyHistoryMayBeIncomplete,
);
clearLegacyActivityHistory();
} catch {
notify({
tone: "error",
message: tRef.current("notices.activityHistoryPersistenceFailed"),
dedupeKey: "activity-history-persistence",
});
}
if (!active) {
return;
}
}
setActivityRecords((current) =>
mergeActivityRecords(current, snapshot.records),
);
setLegacyActivityHistoryMayBeIncomplete(legacyHistoryMayBeIncomplete);
setActivityHistoryReady(true);
})();
return () => {
active = false;
};
}, [legacyActivityHistory]);
useEffect(() => {
activityRecordsRef.current = activityRecords;
legacyActivityHistoryMayBeIncompleteRef.current =
legacyActivityHistoryMayBeIncomplete;
if (!activityHistoryReady) {
return;
}
const timeout = window.setTimeout(() => {
void persistActivityHistory();
}, 250);
return () => window.clearTimeout(timeout);
}, [
activityHistoryReady,
activityRecords,
legacyActivityHistoryMayBeIncomplete,
persistActivityHistory,
]);
useEffect(
() => () => {
void persistActivityHistory();
},
[persistActivityHistory],
);
const resumeProjectConversationQueues = useCallback(
(projectId: string): void => {
if (
(!projectRecoverySnapshotReadyRef.current &&
isManagedSshProject(
projectsRef.current.find((project) => project.id === projectId),
)) ||
isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[projectId],
)
) {
return;
}
const conversationIds = conversationsRef.current
.filter(
(conversation) =>
conversation.projectId === projectId && !conversation.remote,
)
.map((conversation) => conversation.id);
if (conversationIds.length === 0) {
return;
}
void Promise.all(
conversationIds.map((conversationId) =>
window.goodbuddy.conversationQueue.ready(conversationId),
),
).catch(() => {
notify({
tone: "error",
message: tRef.current("notices.conversationQueueResumeFailed"),
dedupeKey: `conversation-queue-resume:${projectId}`,
});
});
},
[notify],
);
const applyProjectRecoveryState = useCallback(
(state: RemoteProjectRecoveryState): void => {
const previous = projectRecoveryByProjectIdRef.current[state.projectId];
if (previous) {
if (previous.requestId !== state.requestId) {
if (
!["completed", "failed"].includes(previous.stage) ||
state.stage !== "network"
) {
return;
}
} else if (
remoteRecoveryStageOrder(state.stage) <
remoteRecoveryStageOrder(previous.stage) ||
(previous.stage === "cursor" &&
state.stage === "cursor" &&
BigInt(state.current) < BigInt(previous.current)) ||
["completed", "failed"].includes(previous.stage)
) {
return;
}
}
const next = {
...projectRecoveryByProjectIdRef.current,
[state.projectId]: state,
};
projectRecoveryByProjectIdRef.current = next;
setProjectRecoveryByProjectId(next);
if (state.stage === "completed" && previous?.stage !== "completed") {
resumeProjectConversationQueues(state.projectId);
}
},
[resumeProjectConversationQueues],
);
useEffect(() => {
const recoveryApi = window.goodbuddy.projects.remote;
let active = true;
const removeListener = recoveryApi.onRecoveryProgress((state) => {
if (active) {
applyProjectRecoveryState(state);
}
});
void recoveryApi.getRecoverySnapshot().then(
(snapshot) => {
if (active) {
snapshot.recoveries.forEach(applyProjectRecoveryState);
projectRecoverySnapshotReadyRef.current = true;
setProjectRecoverySnapshotReady(true);
}
},
() => {
// Main reports actionable recovery failures as project states.
if (active) {
projectRecoverySnapshotReadyRef.current = true;
setProjectRecoverySnapshotReady(true);
}
},
);
return () => {
active = false;
removeListener();
};
}, [applyProjectRecoveryState]);
const retryProjectRecovery = useCallback(
async (projectId: string): Promise<void> => {
if (retryingRecoveryProjectIdsRef.current.has(projectId)) {
return;
}
retryingRecoveryProjectIdsRef.current.add(projectId);
try {
const state =
await window.goodbuddy.projects.remote.retryRecovery(projectId);
applyProjectRecoveryState(state);
} catch {
// Preserve the existing local failure without duplicating a toast.
} finally {
retryingRecoveryProjectIdsRef.current.delete(projectId);
}
},
[applyProjectRecoveryState],
);
useEffect(() => {
let active = true;
const initialization = Promise.all([
window.goodbuddy.projects.list(false),
window.goodbuddy.conversations.listSummaries(),
]).then(async ([value, persistedConversations]) => {
if (!active || value.length === 0) {
return;
}
setProjects(value);
const project = value.find(isOrdinaryLocalProject);
if (!project) {
throw new Error("没有可用的本地项目");
}
setActiveProjectId(project.id);
const initialConversation = persistedConversations.find(item => item.projectId === project.id);
if (initialConversation?.messageSummary) {
const detail = await window.goodbuddy.conversations.get(initialConversation.id);
persistedConversations = persistedConversations.map(item => item.id === detail.id ? detail : item);
}
const persistedLocalConversations = persistedConversations.filter(
(conversation) => !conversation.remote,
);
const persistedConversationIds = new Set(
persistedConversations.map((conversation) => conversation.id),
);
const shouldMigrateLocalStorage =
conversationMigrationStoragePresent.current ||
persistedConversations.length === 0;
const migratedLocalConversations = shouldMigrateLocalStorage
? migrationConversations.current
.filter(
(conversation) =>
!conversation.remote &&
!persistedConversationIds.has(conversation.id),
)
.map((conversation) =>
conversation.projectId || project.kind === "channel"
? conversation
: { ...conversation, projectId: project.id },
)
: [];
let nextConversations: Conversation[] = [
...persistedConversations.map(withRecoveredQuestions),
...migratedLocalConversations,
];
let projectConversation = nextConversations.find(
(conversation) =>
conversation.projectId === project.id &&
(project.kind !== "channel" || conversation.remote !== undefined),
);
if (!projectConversation && project.kind !== "channel") {
projectConversation = createConversation(
project.id,
undefined,
tRef.current("conversation.greeting"),
);
nextConversations = [projectConversation, ...nextConversations];
}
const acknowledgedLocalConversations = new Map(
persistedLocalConversations.map((conversation) => [
conversation.id,
conversation,
]),
);
if (
nextConversations.some(
(conversation) =>
!conversation.remote &&
acknowledgedLocalConversations.get(conversation.id) !==
conversation,
)
) {
if (persistedConversations.length === 0) {
const migratedSnapshots = toConversationSnapshots(nextConversations);
await window.goodbuddy.conversations.replace(migratedSnapshots);
const migratedSnapshotIds = new Set(
migratedSnapshots.map((conversation) => conversation.id),
);
for (const conversation of nextConversations) {
if (migratedSnapshotIds.has(conversation.id)) {
acknowledgedLocalConversations.set(conversation.id, conversation);
}
}
}
while (true) {
const migration = createLocalConversationSaveBatch(
nextConversations,
acknowledgedLocalConversations,
new Set(),
);
if (migration.batch.length === 0) {
break;
}
await window.goodbuddy.conversations.saveLocal(migration.batch);
for (const conversation of migration.acknowledgements) {
acknowledgedLocalConversations.set(conversation.id, conversation);
}
}
}
if (!active) {
return;
}
persistedLocalConversationsRef.current = acknowledgedLocalConversations;
setConversations(nextConversations);
setActiveId(projectConversation?.id ?? "");
try {
localStorage.removeItem(storageKey);
conversationMigrationStoragePresent.current = false;
} catch {
// The SQLite migration has already completed successfully.
}
setConversationStoreReady(true);
});
conversationPersistenceQueueRef.current = initialization.then(
() => undefined,
() => undefined,
);
void initialization.catch((reason: unknown) => {
if (active) {
setConversationLoadError(
displayErrorMessage(
reason,
tRef.current("notices.projectReadFailed"),
),
);
}
});
return () => {
active = false;
};
}, [conversationLoadRetry, setActiveId]);
useEffect(() => {
if (!activeProjectId) {
return;
}
void window.goodbuddy.memory
.list(activeProjectId)
.then(setAssistantMemories)
.catch(() =>
notify({
tone: "error",
message: tRef.current("notices.memoryReadFailed"),
}),
);
}, [activeProjectId]);
const refreshWorkspaceChanges = useCallback(async (): Promise<void> => {
if (!activeProjectId) {
workspaceChangesRequestRef.current += 1;
setWorkspaceChanges(undefined);
return;
}
await loadWorkspaceChanges(activeProjectId);
}, [activeProjectId, loadWorkspaceChanges]);
const listWorkspaceDirectory = useCallback(
async (path: string) => {
if (!activeProjectId) {
throw new Error(tRef.current("notices.selectProject"));
}
return window.goodbuddy.workspace.listDirectory(activeProjectId, path);
},
[activeProjectId],
);
const loadWorkspaceFile = useCallback(
async (path: string, offsetBytes = 0) => {
if (!activeProjectId) {
throw new Error(tRef.current("notices.selectProject"));
}
return window.goodbuddy.workspace.readFile(
activeProjectId,
path,
offsetBytes,
);
},
[activeProjectId],
);
const loadWorkspaceDiff = useCallback(
async (path: string) => {
if (!activeProjectId)
throw new Error(tRef.current("notices.selectProject"));
return window.goodbuddy.workspace.getFileDiff(activeProjectId, path);
},
[activeProjectId],
);
const openWorkspaceEntry = useCallback(
async (path: string, type: "file" | "directory"): Promise<void> => {
if (!activeProjectId) {
throw new Error(tRef.current("notices.selectProject"));
}
await window.goodbuddy.workspace.openPath(activeProjectId, path, type);
},
[activeProjectId],
);
useEffect(() => {
const timeout = setTimeout(() => {
void refreshWorkspaceChanges().catch(() => {
notify({
tone: "error",
message: tRef.current("notices.workspaceChangesReadFailed"),
});
});
}, 0);
return () => clearTimeout(timeout);
}, [refreshWorkspaceChanges]);
useEffect(() => {
void window.goodbuddy.experts
.list()
.then(setAssistantExperts)
.catch(() =>
notify({
tone: "error",
message: tRef.current("notices.expertsReadFailed"),
}),
);
}, []);
useEffect(() => {
if (!activeProjectId) {
return;
}
void window.goodbuddy.schedules
.list()
.then(setAssistantSchedules)
.catch(() =>
notify({
tone: "error",
message: tRef.current("notices.schedulesReadFailed"),
}),
);
}, [activeProjectId]);
const loadHeartbeats = useCallback(async () => {
const [configs, memories] = await Promise.all([
window.goodbuddy.heartbeats.list(),
window.goodbuddy.memory.list(),
]);
const history = await window.goodbuddy.heartbeats.history();
// Main returns no suggestions while the supervisor is disabled.
const suggestions = await window.goodbuddy.supervision?.suggestions?.({ status: "pending", limit: 100 })
.catch(() => []) ?? [];
return {
configs,
memories,
runs: history.runs,
entries: history.entries,
pendingSuggestions: suggestions.length,
};
}, []);
const refreshHeartbeats = useCallback(async (): Promise<void> => {
const requestId = ++heartbeatLoadRequestRef.current;
const result = await loadHeartbeats();
if (requestId !== heartbeatLoadRequestRef.current) {
return;
}
setAssistantHeartbeats(result.configs);
setHeartbeatMemories(result.memories);
setHeartbeatRuns(result.runs);
setHeartbeatEntries(result.entries);
setPendingHeartbeatSuggestionCount(result.pendingSuggestions);
}, [loadHeartbeats]);
useEffect(() => {
if (projects.length === 0) {
return;
}
const requestId = ++heartbeatLoadRequestRef.current;
const timeout = setTimeout(() => {
if (requestId !== heartbeatLoadRequestRef.current) {
return;
}
setHeartbeatLoading(true);
setHeartbeatLoadError(undefined);
setAssistantHeartbeats([]);
setHeartbeatRuns([]);
setHeartbeatEntries([]);
void loadHeartbeats()
.then((result) => {
if (requestId !== heartbeatLoadRequestRef.current) {
return;
}
setAssistantHeartbeats(result.configs);
setHeartbeatMemories(result.memories);
setHeartbeatRuns(result.runs);
setHeartbeatEntries(result.entries);
setPendingHeartbeatSuggestionCount(result.pendingSuggestions);
setHeartbeatLoadError(undefined);
})
.catch((reason: unknown) => {
if (requestId !== heartbeatLoadRequestRef.current) {
return;
}
setHeartbeatLoadError(
displayErrorMessage(
reason,
tRef.current("notices.heartbeatReadFailed"),
),
);
})
.finally(() => {
if (requestId === heartbeatLoadRequestRef.current) {
setHeartbeatLoading(false);
}
});
}, 0);
return () => {
clearTimeout(timeout);
if (requestId === heartbeatLoadRequestRef.current) {
heartbeatLoadRequestRef.current += 1;
}
};
}, [loadHeartbeats, projects.length]);
const refreshHeartbeatCenter = useCallback(async (): Promise<void> => {
const [artifacts] = await Promise.all([
window.goodbuddy.artifacts.list(),
refreshHeartbeats(),
]);
setAssistantArtifacts((current) => mergeArtifacts(current, artifacts));
}, [refreshHeartbeats]);
const retryHeartbeatLoad = useCallback(async (): Promise<void> => {
setHeartbeatLoading(true);
setHeartbeatLoadError(undefined);
try {
await refreshHeartbeatCenter();
setHeartbeatLoadError(undefined);
} catch (reason) {
setHeartbeatLoadError(
displayErrorMessage(
reason,
tRef.current("notices.heartbeatReadFailed"),
),
);
} finally {
setHeartbeatLoading(false);
}
}, [refreshHeartbeatCenter]);
const createHeartbeat = useCallback(
async (input: HeartbeatCreateInput): Promise<void> => {
await window.goodbuddy.heartbeats.create(input);
await refreshHeartbeats();
},
[refreshHeartbeats],
);
const updateHeartbeat = useCallback(
async (heartbeatId: string, input: HeartbeatUpdateInput): Promise<void> => {
await window.goodbuddy.heartbeats.update(heartbeatId, input);
await refreshHeartbeats();
},
[refreshHeartbeats],
);
const removeHeartbeat = useCallback(
async (heartbeatId: string): Promise<void> => {
await window.goodbuddy.heartbeats.remove(heartbeatId);
await refreshHeartbeats();
},
[refreshHeartbeats],
);
const runHeartbeat = useCallback(
async (heartbeatId: string): Promise<void> => {
await window.goodbuddy.heartbeats.runNow(heartbeatId);
await refreshHeartbeatCenter();
},
[refreshHeartbeatCenter],
);
const setHeartbeatPaused = useCallback(
async (heartbeatId: string, paused: boolean): Promise<void> => {
await window.goodbuddy.heartbeats.setPaused(heartbeatId, paused);
await refreshHeartbeats();
},
[refreshHeartbeats],
);
useEffect(() => {
if (view !== "heartbeat") {
return;
}
let refreshing = false;
const refresh = (): void => {
if (refreshing) {
return;
}
refreshing = true;
void refreshHeartbeatCenter()
.then(() => setHeartbeatLoadError(undefined))
.catch((reason: unknown) =>
setHeartbeatLoadError(
displayErrorMessage(
reason,
tRef.current("notices.heartbeatRefreshFailed"),
),
),
)
.finally(() => {
refreshing = false;
});
};
const timeout = setTimeout(refresh, 0);
const interval = setInterval(refresh, 30_000);
return () => {
clearTimeout(timeout);
clearInterval(interval);
};
}, [refreshHeartbeatCenter, view]);
useEffect(() => {
void window.goodbuddy.tasks
.list()
.then((tasks) => {
setAssistantTasks(tasks);
setActivityRecords((current) =>
reconcileActivityRecords(
current,
tasks,
new Set(activeRuns.current.keys()),
),
);
})
.catch(() =>
notify({
tone: "error",
message: tRef.current("notices.taskHistoryReadFailed"),
}),
);
}, []);
useEffect(() => {
if (view !== "activity") {
return;
}
const timeout = setTimeout(() => {
void refreshTokenUsage().catch(() =>
notify({
tone: "error",
message: tRef.current("notices.tokenUsageReadFailed"),
}),
);
}, 0);
// Background work (supervisor, heartbeat, knowledge, notes) records usage
// without a renderer request; main notifies so the view stays current.
const removeUsageListener = window.goodbuddy.usage.onChanged?.(() => {
void refreshTokenUsage().catch(() => undefined);
});
return () => {
clearTimeout(timeout);
removeUsageListener?.();
};
}, [refreshTokenUsage, view]);
const manualRefreshTokenUsage = useCallback(async (): Promise<void> => {
try {
await refreshTokenUsage();
} catch {
notify({
tone: "error",
message: tRef.current("notices.tokenUsageReadFailed"),
});
}
}, [notify, refreshTokenUsage]);
useEffect(() => {
void window.goodbuddy.artifacts
.list()
.then((artifacts) =>
setAssistantArtifacts((current) => mergeArtifacts(current, artifacts)),
)
.catch(() =>
notify({
tone: "error",
message: tRef.current("notices.resultHistoryReadFailed"),
}),
);
}, []);
useEffect(() => {
const missingIds = [
...new Set(
(activeConversation?.messages ?? []).flatMap(
(message) => [...(message.artifactIds ?? []), ...(message.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])],
),
),
]
.filter(
(artifactId) =>
!assistantArtifactById.get(artifactId)?.content &&
!hydratingArtifactIds.current.has(artifactId),
)
.slice(-32);
if (missingIds.length === 0) {
return;
}
for (const artifactId of missingIds) {
hydratingArtifactIds.current.add(artifactId);
}
void Promise.allSettled(
missingIds.map((artifactId) =>
window.goodbuddy.artifacts.get(artifactId),
),
).then((results) => {
const artifacts = results.flatMap((result) =>
result.status === "fulfilled" ? [result.value] : [],
);
if (artifacts.length > 0) {
setAssistantArtifacts((current) => mergeArtifacts(current, artifacts));
}
for (const artifactId of missingIds) {
hydratingArtifactIds.current.delete(artifactId);
}
});
}, [activeConversation, assistantArtifactById]);
useEffect(() => {
const timeout = setTimeout(() => {
void refreshKnowledge()
.catch(() => {
// refreshKnowledge exposes a recoverable page-local error.
})
.finally(() => setKnowledgeLoading(false));
}, 0);
return () => clearTimeout(timeout);
}, [refreshKnowledge]);
useEffect(() => {
if (
knowledgeLoadError ||
(view !== "knowledge" && knowledgeOperationCount === 0)
) {
return;
}
const interval = setInterval(
() => {
void refreshKnowledge(knowledgeSnapshot.selectedLibraryId).catch(() => {
// The task center keeps the last successful snapshot while polling.
});
},
knowledgeOperationCount > 0 ? 350 : 1_000,
);
return () => clearInterval(interval);
}, [
knowledgeLoadError,
knowledgeOperationCount,
knowledgeSnapshot.selectedLibraryId,
refreshKnowledge,
view,
]);
useEffect(() => {
void Promise.all([
window.goodbuddy.settings.getRuntime(),
window.goodbuddy.agent.getStatus(),
])
.then(([settings, status]) => {
const selectionKey = agentRuntimeSelectionKey(
resolveRuntimeChoice(settings).selection,
);
runtimeStatusCacheRef.current = {
key: selectionKey,
settings,
};
setRuntimeSettings(settings);
setRuntime(status);
setRuntimeStatusKey(selectionKey);
if (!status.available && !runtimeSetupPromptedRef.current) {
runtimeSetupPromptedRef.current = true;
setView("settings");
}
})
.catch(() =>
notify({
tone: "error",
message: tRef.current("runtime.errors.readSettings"),
}),
);
void window.goodbuddy.app
.getInfo()
.then(setAppInfo)
.catch(() =>
notify({
tone: "error",
message: tRef.current("notices.appInfoReadFailed"),
}),
);
const removeAgentListener =
window.goodbuddy.agent.onEvent(handleAgentEvent);
const removeImageListener = window.goodbuddy.conversations.imageOperations.onChanged((operation) => {
setConversations(current => current.map(conversation => conversation.id !== operation.conversationId ? conversation : {
...conversation,
updatedAt: Math.max(conversation.updatedAt, operation.updatedAt),
messages: conversation.messages.map(message => message.id !== operation.messageId ? message : {
...message,
imageOperations: message.imageOperations?.some(item => item.id === operation.id)
? message.imageOperations.map(item => item.id === operation.id ? operation : item)
: [...(message.imageOperations ?? []), operation],
artifactIds: [...new Set([...(message.artifactIds ?? []), ...operation.artifactIds])].slice(-8),
}),
}));
});
const removeOpenSettingsListener = window.goodbuddy.app.onOpenSettings((category) => {
setSettingsInitialCategory(category ?? "runtime");
setView("settings");
});
return () => {
removeAgentListener();
removeImageListener();
removeOpenSettingsListener();
};
}, [handleAgentEvent, setView]);
useEffect(() => {
const browserApi = window.goodbuddy.browser;
if (!browserApi) {
return;
}
return browserApi.onState((state) => {
const tabId = state.tabId;
setBrowserStates((current) => {
const conversationStates = current[state.conversationId] ?? {};
if (state.status === "stopped") {
if (!(tabId in conversationStates)) {
return current;
}
const nextConversationStates = { ...conversationStates };
delete nextConversationStates[tabId];
const next = { ...current };
if (Object.keys(nextConversationStates).length === 0) {
delete next[state.conversationId];
} else {
next[state.conversationId] = nextConversationStates;
}
return next;
}
return {
...current,
[state.conversationId]: {
...conversationStates,
[tabId]: state,
},
};
});
if (
state.status !== "stopped" &&
state.conversationId === conversationNavigationRef.current.activeId
) {
setAssistantSidebarOpen(true);
setAssistantSidebarTab("browser");
}
});
}, []);
useEffect(
() =>
window.goodbuddy.app.onNewConversation(() => {
startNewConversation(activeProjectIdRef.current || undefined);
}),
[startNewConversation],
);
useEffect(() => {
const handleNewConversationShortcut = (event: KeyboardEvent): void => {
if (
event.key.toLocaleLowerCase() !== "n" ||
(!event.ctrlKey && !event.metaKey) ||
event.altKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
startNewConversation(activeProjectIdRef.current || undefined);
};
document.addEventListener("keydown", handleNewConversationShortcut);
return () =>
document.removeEventListener("keydown", handleNewConversationShortcut);
}, [startNewConversation]);
const commitProjectSelection = (
selected: AssistantProject,
candidateConversations = conversationsRef.current,
): void => {
setActiveProjectId(selected.id);
const conversation = candidateConversations.find(
(candidate) =>
candidate.projectId === selected.id &&
(selected.kind !== "channel" || candidate.remote !== undefined),
);
if (conversation) {
// Remote projects ignore incompatible conversation choices at resolution time
// and say so in the picker, instead of silently rewriting saved conversations.
setActiveId(conversation.id);
} else if (selected.kind === "channel") {
setActiveId("");
} else {
const created = createConversation(
selected.id,
undefined,
t("conversation.greeting"),
);
setConversations((current) => [created, ...current]);
setActiveId(created.id);
}
setView("chat");
};
const selectProject = (projectId: string): void => {
const project = projects.find((candidate) => candidate.id === projectId);
if (!project) {
return;
}
if (project.executionSpace.kind === "ssh" && !remoteProjectsEnabled) {
return;
}
commitProjectSelection(project);
if (
!isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[project.id],
)
) {
resumeProjectConversationQueues(project.id);
}
};
const createProject = async (
input: ProjectCreateInput,
): Promise<AssistantProject> => {
const project = await window.goodbuddy.projects.create(input);
setProjects((current) => [project, ...current]);
setActiveProjectId(project.id);
const conversation = createConversation(
project.id,
undefined,
t("conversation.greeting"),
);
setConversations((current) => [conversation, ...current]);
setActiveId(conversation.id);
setView("chat");
return project;
};
const updateProject = async (
projectId: string,
input: ProjectCreateInput,
): Promise<AssistantProject> => {
const project = await window.goodbuddy.projects.update(projectId, input);
setProjects((current) =>
current.map((candidate) =>
candidate.id === project.id ? project : candidate,
),
);
return project;
};
const loadCommittedRemoteProject = async (
project: AssistantProject,
): Promise<void> => {
if (!remoteProjectsEnabled || project.executionSpace.kind !== "ssh") {
return;
}
setProjects((current) =>
current.some((candidate) => candidate.id === project.id)
? current.map((candidate) =>
candidate.id === project.id ? project : candidate,
)
: [project, ...current],
);
commitProjectSelection(project);
if (
!isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[project.id],
)
) {
resumeProjectConversationQueues(project.id);
}
};
const handleRemoteProjectsEnabledChange = (enabled: boolean): void => {
setRemoteProjectsEnabled(enabled);
if (enabled) {
return;
}
if (activeProject?.executionSpace.kind !== "ssh") {
return;
}
const localProject = projects.find(isOrdinaryLocalProject);
if (localProject) {
commitProjectSelection(localProject);
}
};
const archiveProject = async (projectId: string): Promise<void> => {
await window.goodbuddy.projects.setArchived(projectId, true);
const remaining = projects.filter((project) => project.id !== projectId);
setProjects(remaining);
if (projectId === activeProjectIdRef.current) {
const next =
remaining.find(isOrdinaryLocalProject) ??
(remoteProjectsEnabled ? remaining[0] : undefined);
if (next) {
selectProject(next.id);
}
}
};
const removeProjectsFromUi = (projectIds: readonly string[]): void => {
if (projectIds.length === 0) {
return;
}
const deletedProjectIds = new Set(projectIds);
const referencesDeletedProject = (projectId: string | undefined): boolean =>
projectId !== undefined && deletedProjectIds.has(projectId);
const remainingProjects = projectsRef.current.filter(
(project) => !deletedProjectIds.has(project.id),
);
const currentConversations = conversationsRef.current;
const deletedConversationIds = new Set(
currentConversations
.filter((conversation) =>
referencesDeletedProject(conversation.projectId),
)
.map((conversation) => conversation.id),
);
const remainingConversations = currentConversations.filter(
(conversation) => !referencesDeletedProject(conversation.projectId),
);
setProjects((current) =>
current.filter((project) => !deletedProjectIds.has(project.id)),
);
setConversations((current) =>
current.filter(
(conversation) => !referencesDeletedProject(conversation.projectId),
),
);
setAssistantTasks((current) =>
current.filter((task) => !referencesDeletedProject(task.projectId)),
);
setAssistantArtifacts((current) =>
current.filter(
(artifact) => !referencesDeletedProject(artifact.projectId),
),
);
setAssistantMemories((current) =>
current.filter(
(memory) =>
!(
memory.scope === "project" &&
referencesDeletedProject(memory.scopeId)
) &&
!(
memory.scope === "conversation" &&
memory.scopeId !== undefined &&
deletedConversationIds.has(memory.scopeId)
),
),
);
setAssistantSchedules((current) =>
current.filter(
(schedule) => !referencesDeletedProject(schedule.projectId),
),
);
setAssistantHeartbeats((current) =>
current.flatMap((heartbeat) => {
if (heartbeat.scope.kind === "global") {
return heartbeat;
}
const projectIds = heartbeat.scope.projectIds.filter(
(id) => !deletedProjectIds.has(id),
);
return projectIds.length > 0
? [{ ...heartbeat, scope: { kind: "projects", projectIds } }]
: [];
}),
);
if (!deletedProjectIds.has(activeProjectIdRef.current)) {
return;
}
const next = remainingProjects.find(isOrdinaryLocalProject);
if (next) {
commitProjectSelection(next, remainingConversations);
return;
}
setActiveProjectId("");
setActiveId("");
setView("chat");
};
const deleteProject = async (
projectId: string,
confirmation: string,
): Promise<void> => {
await window.goodbuddy.projects.delete(projectId, confirmation);
removeProjectsFromUi([projectId]);
};
const newConversation = (): boolean => {
return startNewConversation(activeProjectId || undefined);
};
const setMemoryStatus = async (
memoryId: string,
status: AssistantMemory["status"],
): Promise<void> => {
await window.goodbuddy.memory.setStatus(memoryId, status);
setAssistantMemories((current) =>
status === "rejected"
? current.filter((memory) => memory.id !== memoryId)
: current.map((memory) =>
memory.id === memoryId ? { ...memory, status } : memory,
),
);
setHeartbeatMemories((current) =>
status === "rejected"
? current.filter((memory) => memory.id !== memoryId)
: current.map((memory) =>
memory.id === memoryId ? { ...memory, status } : memory,
),
);
};
const useHeartbeatTask = (task: AssistantTask): void => {
if (task.projectId && task.projectId !== activeProjectId) {
setActiveProjectId(task.projectId);
}
if (
!startNewConversation(task.projectId ?? (activeProjectId || undefined))
) {
return;
}
setWorkMode("ask");
setInput(
[t("notices.heartbeatTaskPrompt"), task.title, task.instructions].join(
"\n\n",
),
);
notify({
tone: "info",
message: t("notices.heartbeatTaskAdded", { title: task.title }),
});
requestAnimationFrame(() => inputRef.current?.focus());
};
const setHeartbeatTaskStatus = async (
taskId: string,
status: "completed" | "cancelled",
): Promise<void> => {
await window.goodbuddy.tasks.setStatus(taskId, status);
setAssistantTasks((current) =>
current.map((task) =>
task.id === taskId
? {
...task,
status,
completedAt: new Date().toISOString(),
}
: task,
),
);
};
const setConversationPinned = async (conversation: Conversation): Promise<void> => {
if (conversationPinPendingRef.current || !conversationStoreReady) return;
conversationPinPendingRef.current = true;
conversationPinRevisionRef.current += 1;
setPinningConversationId(conversation.id);
try {
if (!conversation.remote) {
await conversationPersistenceQueueRef.current;
if (!persistedLocalConversationsRef.current.has(conversation.id)) {
const draft = conversationsRef.current.find(item => item.id === conversation.id);
if (!draft) return;
await window.goodbuddy.conversations.saveLocal([{
header: toLocalConversationHeader(draft),
messages: draft.messages.map(toConversationMessage),
}]);
persistedLocalConversationsRef.current.set(draft.id, draft);
}
}
const pinned = !conversation.pinned;
await window.goodbuddy.conversations.setPinned({ conversationId: conversation.id, pinned });
const next = conversationsRef.current.map(item => item.id === conversation.id ? { ...item, pinned } : item);
conversationsRef.current = next;
setConversations(next);
} catch {
notify({ tone: "error", message: t("notices.conversationPinFailed") });
} finally {
conversationPinPendingRef.current = false;
conversationPinRevisionRef.current += 1;
setPinningConversationId("");
}
};
const deleteConversation = async (conversationId: string): Promise<void> => {
if (deletingConversationId) {
return;
}
setDeletingConversationId(conversationId);
const activeRequests = [...activeRuns.current.entries()]
.filter(([, run]) => run.conversationId === conversationId)
.map(([requestId]) => requestId);
try {
await Promise.all(
activeRequests.map((requestId) =>
window.goodbuddy.agent.cancel(requestId),
),
);
} catch {
notify({
tone: "error",
message: t("notices.deleteConversationCancelFailed"),
});
setDeletingConversationId("");
return;
}
const deletingConversation = conversations.find(
(conversation) => conversation.id === conversationId,
);
if (deletingConversation && !deletingConversation.remote) {
deletingLocalConversationIdsRef.current.add(conversationId);
try {
await conversationPersistenceQueueRef.current;
await attachmentSaveQueue.current;
await window.goodbuddy.conversations.deleteLocal(conversationId);
persistedLocalConversationsRef.current.delete(conversationId);
} catch {
deletingLocalConversationIdsRef.current.delete(conversationId);
notify({
tone: "error",
message: t("notices.deleteConversationPersistenceFailed"),
});
setDeletingConversationId("");
return;
}
}
setConfirmingConversationId("");
setDeletingConversationId("");
if (conversationActionsId === conversationId) {
setConversationActionsId("");
}
if (renamingConversationId === conversationId) {
setRenamingConversationId("");
}
const browserStop = window.goodbuddy.browser?.stop(conversationId);
if (browserStop) {
void browserStop.catch(() => {
notify({
tone: "error",
message: t("notices.deletedConversationBrowserCloseFailed"),
});
});
}
setBrowserStates((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
const draftAttachments = attachmentsRef.current.get(conversationId) ?? [];
attachmentsRef.current.delete(conversationId);
for (const attachment of draftAttachments) {
void window.goodbuddy.context.remove(attachment.id);
}
setAttachmentsByConversation((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
setConversationDrafts((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
setChatScrollSnapshots((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
setVisibleMessageCounts((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
setConversationActivity(conversationId, false);
const remaining = conversations.filter(
(conversation) => conversation.id !== conversationId,
);
conversationsRef.current = remaining;
deletingLocalConversationIdsRef.current.delete(conversationId);
const projectRemaining = remaining.filter(
(conversation) => conversation.projectId === activeProjectId,
);
setConversations(remaining);
if (projectRemaining.length > 0) {
if (conversationId === activeId) {
setActiveId(projectRemaining[0]?.id ?? "");
}
return;
}
if (activeProject?.kind === "channel") {
setActiveId("");
return;
}
const replacement = createConversation(
activeProjectId || undefined,
undefined,
t("conversation.greeting"),
);
setConversations((current) => [replacement, ...current]);
setActiveId(replacement.id);
};
const branchConversation = async (
sourceConversation: Conversation,
): Promise<void> => {
if (
branchingConversationId ||
sourceConversation.remote ||
!conversationStoreReady ||
activeConversationIds.has(sourceConversation.id) ||
queuedConversationIds.has(sourceConversation.id)
) {
return;
}
setBranchingConversationId(sourceConversation.id);
try {
persistLocalConversationChanges();
await conversationPersistenceQueueRef.current;
if (
persistedLocalConversationsRef.current.get(sourceConversation.id) !==
sourceConversation ||
conversationsRef.current.find(
(conversation) => conversation.id === sourceConversation.id,
) !== sourceConversation
) {
throw new Error(t("notices.conversationPersistenceFailed"));
}
const sourceTitle = getConversationDisplayTitle(
sourceConversation,
t("conversation.defaultTitle"),
);
const branch = await window.goodbuddy.conversations.branchLocal({
sourceConversationId: sourceConversation.id,
title: createConversationBranchTitle(
sourceTitle,
t("conversation.branch.suffix"),
),
});
const nextBranch: Conversation = branch;
persistedLocalConversationsRef.current.set(nextBranch.id, nextBranch);
setConversations((current) => [
nextBranch,
...current.filter((conversation) => conversation.id !== nextBranch.id),
]);
setSelectedAssistantTaskId(undefined);
setActiveId(nextBranch.id);
setView("chat");
if (narrowWindow) {
closeNarrowSidebar();
}
notify({
tone: "success",
message: t("notices.conversationBranched"),
});
requestAnimationFrame(() => inputRef.current?.focus());
} catch (error) {
notify({
tone: "error",
message: displayErrorMessage(
error,
t("notices.conversationBranchFailed"),
),
});
focusConversationActions(sourceConversation.id);
} finally {
setBranchingConversationId("");
}
};
const focusConversationActions = (conversationId: string): void => {
requestAnimationFrame(() =>
conversationActionTriggerRefs.current.get(conversationId)?.focus(),
);
};
const saveTitle = (conversationId: string, titleInput: string): void => {
const title = titleInput.trim().slice(0, 80);
if (!title) {
return;
}
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? { ...conversation, title, updatedAt: Date.now() }
: conversation,
),
);
setRenamingConversationId("");
focusConversationActions(conversationId);
};
const writeClipboardText = useCallback(
async (content: string, successMessage?: string): Promise<boolean> => {
try {
await window.goodbuddy.clipboard.writeText(content);
if (successMessage) notify({
tone: "success",
message: successMessage,
});
return true;
} catch {
notify({
tone: "error",
message: t("notices.clipboardUnavailable"),
});
return false;
}
},
[t],
);
const readConversationForExport = async (item: Conversation): Promise<Conversation> => {
if (!item.messageSummary) return item;
const snapshot = await window.goodbuddy.conversations.get(item.id);
// Export reads do not acquire a view-cache entry or retain tool metadata.
return mergePersistedConversations([item], [snapshot], new Map())[0]!;
};
const copyConversation = async (
item: Conversation,
): Promise<void> => {
let conversation: Conversation;
try { conversation = await readConversationForExport(item); }
catch {
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
return;
}
const transcript = conversation.messages
.map((message) =>
t("chat.exportSpeaker", {
speaker: message.role === "user" ? t("chat.user") : "GoodBuddy",
content: `${message.content}${formatAttachmentList(
message.attachments,
t,
)}`,
}),
)
.join("\n\n");
await writeClipboardText(transcript, t("notices.conversationCopied"));
};
const copyMessage = useCallback(
(content: string, kind?: 'tool'): Promise<boolean> =>
writeClipboardText(content, kind === 'tool' ? t("chat.tools.copied") : undefined),
[t, writeClipboardText],
);
const exportConversation = async (item: Conversation): Promise<void> => {
let conversation: Conversation;
try { conversation = await readConversationForExport(item); }
catch {
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
return;
}
const markdown = [
`# ${conversation.title}`,
"",
...conversation.messages.flatMap((message) => [
`## ${message.role === "user" ? t("chat.user") : "GoodBuddy"}`,
"",
`${message.content}${formatAttachmentList(message.attachments, t)}`,
"",
]),
].join("\n");
const blob = new Blob([markdown], {
type: "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${
conversation.title.replace(/[\\/:*?"<>|]/g, "_") ||
t("conversation.exportFallbackName")
}.md`;
anchor.click();
URL.revokeObjectURL(url);
notify({ tone: "success", message: t("notices.conversationExported") });
};
const openImageViewer = useCallback(
(item: ImageViewerItem, trigger: HTMLElement): void => {
if (!imageDataUrlPattern.test(item.src)) {
notify({
tone: "error",
message: tRef.current("notices.imageUnavailable"),
});
return;
}
imageViewerTriggerRef.current = trigger;
setImageViewerItem(item);
},
[],
);
const closeImageViewer = (): void => {
const trigger = imageViewerTriggerRef.current;
setImageViewerItem(undefined);
imageViewerTriggerRef.current = undefined;
requestAnimationFrame(() => trigger?.focus());
};
const openCitationContext = useCallback(
async (reference: KnowledgeSearchReference): Promise<void> => {
const request = ++citationRequestRef.current;
if (reference.external) {
setCitationDialog({ reference, loading: false });
return;
}
setCitationDialog({
reference,
loading: true,
});
if (!reference.chunkId || !reference.documentId) {
setCitationDialog({
reference,
loading: false,
error: tRef.current("chat.citations.contextUnavailable"),
});
return;
}
try {
const context = await window.goodbuddy.knowledge.getReferenceContext({
knowledgeBaseId: reference.libraryId,
documentId: reference.documentId,
chunkId: reference.chunkId,
});
if (request !== citationRequestRef.current) return;
setCitationDialog({
reference,
loading: false,
context: {
libraryName: reference.libraryName,
documentName: context.documentTitle,
sourceName: context.sourceDisplayName,
locator: context.locator,
matchedContent: context.matchedContent,
contextContent: context.contextContent,
truncated: context.truncated,
},
});
} catch (reason) {
if (request !== citationRequestRef.current) return;
setCitationDialog({
reference,
loading: false,
error:
reason instanceof Error
? reason.message
: tRef.current("chat.citations.contextUnavailable"),
});
}
},
[],
);
const openCitationSource = useCallback(
async (reference: KnowledgeSearchReference): Promise<void> => {
if (reference.external || !reference.chunkId || !reference.documentId) {
return;
}
try {
await window.goodbuddy.knowledge.openReferenceSource({
knowledgeBaseId: reference.libraryId,
documentId: reference.documentId,
chunkId: reference.chunkId,
});
} catch (reason) {
notify({
tone: "error",
message:
reason instanceof Error
? reason.message
: tRef.current("chat.citations.openFailed"),
});
}
},
[],
);
const downloadImage = useCallback((item: ImageViewerItem): void => {
if (!imageDataUrlPattern.test(item.src)) {
notify({
tone: "error",
message: tRef.current("notices.imageUnavailable"),
});
return;
}
const anchor = document.createElement("a");
anchor.href = item.src;
anchor.download = getImageDownloadName(
item.title,
item.src,
tRef.current("chat.images.fallbackTitle"),
);
anchor.rel = "noopener";
anchor.click();
notify({
tone: "info",
message: tRef.current("notices.imageDownloadStarted"),
});
}, []);
const submit = async (
queuedDispatch?: ConversationQueueDispatch,
promptOverride?: string,
): Promise<void> => {
let queuedInput: ConversationQueueUserInput | undefined =
queuedDispatch && !queuedDispatch.scheduled ? queuedDispatch.input : undefined;
if (queuedDispatch?.scheduled) {
const conversation = conversationsRef.current.find(
(candidate) => candidate.id === queuedDispatch.input.conversationId,
);
const project = projectsRef.current.find(
(candidate) => candidate.id === conversation?.projectId,
);
const selection = runtimeSettings && conversation
? resolveConversationRuntime(project, conversation.runtimeSelection, runtimeSettings).selection
: undefined;
if (!conversation || !selection) {
await window.goodbuddy.conversationQueue.releaseUser(queuedDispatch.item.id);
return;
}
const mode = normalizeInteractiveWorkMode(conversation.workMode ?? project?.defaultWorkMode);
queuedInput = {
conversationId: conversation.id,
projectId: conversation.projectId,
prompt: queuedDispatch.input.prompt,
runtimeSelection: selection,
workMode: mode,
includeMemoryContext: true,
attachments: [],
knowledgeLibraryIds: conversation.knowledgeLibraryIds ?? [],
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode ?? "auto",
smartRouting: runtimeSettings?.subagentSmartRoutingEnabled === true &&
supportsSubagentSmartRouting(mode) ? true : undefined,
};
}
const releaseQueuedItem = async (): Promise<void> => {
if (!queuedDispatch) {
return;
}
try {
await window.goodbuddy.conversationQueue.releaseUser(
queuedDispatch.item.id,
);
} catch {
notify({
tone: "error",
message: t("notices.conversationQueueReleaseFailed"),
});
}
};
let conversationSnapshot = queuedInput
? conversationsRef.current.find(
(conversation) => conversation.id === queuedInput.conversationId,
)
: activeConversation;
if (conversationSnapshot?.messageSummary) {
try {
conversationSnapshot = await ensureConversationHistory(conversationSnapshot.id);
} catch {
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
await releaseQueuedItem();
return;
}
}
const recoveryProjectId =
queuedInput?.projectId ?? conversationSnapshot?.projectId;
if (
recoveryProjectId &&
((!projectRecoverySnapshotReadyRef.current &&
isManagedSshProject(
projectsRef.current.find(
(project) => project.id === recoveryProjectId,
),
)) ||
isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[recoveryProjectId],
))
) {
await releaseQueuedItem();
return;
}
const command =
!queuedInput && activeRuntimeSelection?.provider === "opencode"
? runtimeNativeSnapshot?.commands.find(
(candidate) => candidate.id === selectedRuntimeCommand,
)
: undefined;
const commandArguments = queuedInput ? "" : (promptOverride ?? input).trim();
const prompt =
queuedInput?.prompt ??
(command
? `/${command.name}${commandArguments ? ` ${commandArguments}` : ""}`
: commandArguments);
if (!prompt || !conversationSnapshot) {
await releaseQueuedItem();
return;
}
if (!queuedInput && (selectingContextFilesRef.current || attachmentOperationsRef.current > 0)) {
notify({
tone: "info",
message: t("composer.attachmentProgress.waitBeforeSending"),
});
return;
}
if (conversationSnapshot.remote) {
notify({
tone: "info",
message: t("notices.remoteConversationReadOnly"),
});
await releaseQueuedItem();
return;
}
if (!queuedInput && !runtime) {
notify({
tone: "info",
message: t("runtime.loadingRetry"),
});
return;
}
if (
!queuedInput &&
(runtimeSwitching || runtimeStatusKey !== activeRuntimeSelectionKey)
) {
notify({
tone: "info",
message: t("runtime.updatingRetry"),
});
return;
}
if (!queuedInput && !runtime?.available) {
return;
}
const requestId = queuedDispatch?.scheduled
? queuedDispatch.item.scheduleRunId!
: crypto.randomUUID();
const conversationId = conversationSnapshot.id;
const attachmentSnapshot = (queuedInput?.attachments ?? attachments).slice(
0,
8,
);
const historySnapshot = conversationSnapshot.messages;
const retainedHistorySnapshot = buildRuntimeHistory(historySnapshot);
const projectIdSnapshot = queuedInput
? queuedInput.projectId
: activeProjectId || undefined;
const knowledgeRetrievalModeSnapshot =
queuedInput?.knowledgeRetrievalMode ??
conversationSnapshot.knowledgeRetrievalMode ??
"auto";
// Queued messages follow the conversation's current choice at dispatch,
// so switching the model while messages wait applies to them too.
const runtimeSelectionSnapshot = queuedInput
? runtimeSettings
? resolveConversationRuntime(
projectsRef.current.find((project) => project.id === queuedInput!.projectId),
conversationSnapshot.runtimeSelection,
runtimeSettings,
).selection
: queuedInput.runtimeSelection
: activeRuntimeSelection;
if (!runtimeSelectionSnapshot) {
notify({ tone: "info", message: t("runtime.notSelected") });
await releaseQueuedItem();
return;
}
const runtimeControlSnapshot: RuntimeControl | undefined = queuedInput
? // A queued agent/command/preset only applies to the Runtime it was chosen for.
queuedInput.runtimeControl?.provider === runtimeSelectionSnapshot.provider
? queuedInput.runtimeControl
: undefined
: runtimeSelectionSnapshot.provider === "opencode" &&
(selectedRuntimeAgent || command)
? {
provider: "opencode",
...(selectedRuntimeAgent ? { agent: selectedRuntimeAgent } : {}),
...(command
? {
command: {
name: command.name,
arguments: commandArguments,
},
}
: {}),
}
: runtimeSelectionSnapshot.provider === "continue" &&
selectedContinuePreset
? {
provider: "continue",
presetId: selectedContinuePreset,
}
: undefined;
const selectedExpertSnapshot = queuedInput
? queuedInput.teamMode
? "team"
: (queuedInput.expertId ?? "")
: runtime?.capability === "image-generation"
? ""
: selectedExpertId;
const workModeSnapshot = normalizeInteractiveWorkMode(
queuedInput?.workMode ?? effectiveWorkMode,
);
const knowledgeLibraryIdsSnapshot =
queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds;
const smartRoutingSnapshot =
queuedInput?.smartRouting ??
(!queuedInput &&
runtime?.capability !== "image-generation" &&
runtimeSettings?.subagentSmartRoutingEnabled === true &&
!selectedExpertSnapshot &&
supportsSubagentSmartRouting(workModeSnapshot)
? true
: undefined);
if (!queuedInput) {
const queueInput: ConversationQueueUserInput = {
conversationId,
...(projectIdSnapshot ? { projectId: projectIdSnapshot } : {}),
runtimeSelection: runtimeSelectionSnapshot,
...(runtimeControlSnapshot
? { runtimeControl: runtimeControlSnapshot }
: {}),
...(selectedExpertSnapshot && selectedExpertSnapshot !== "team"
? { expertId: selectedExpertSnapshot }
: {}),
...(selectedExpertSnapshot === "team" ? { teamMode: true } : {}),
...(smartRoutingSnapshot ? { smartRouting: true } : {}),
workMode: workModeSnapshot,
includeMemoryContext: !command,
prompt,
attachments: attachmentSnapshot,
imageContextArtifactIds: imageReferences.map(artifact => artifact.id),
knowledgeLibraryIds: knowledgeLibraryIdsSnapshot,
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot,
};
try {
persistLocalConversationChanges();
await conversationPersistenceQueueRef.current;
if (
!persistedLocalConversationsRef.current.has(conversationSnapshot.id)
) {
throw new Error(t("notices.conversationPersistenceFailed"));
}
await attachmentSaveQueue.current;
await window.goodbuddy.conversationQueue.enqueueUser(queueInput);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
setInput("");
updateAttachments([]);
setImageReferencesByConversation(current => ({ ...current, [conversationId]: [] }));
if (command) {
setSelectedRuntimeCommand("");
}
} catch (reason) {
notify({
tone: "error",
message: displayErrorMessage(reason, t("notices.sendFailed")),
});
}
return;
}
if (!queuedDispatch || dispatchedConversationQueueItems.current.has(queuedDispatch.item.id)) {
return;
}
dispatchedConversationQueueItems.current.add(queuedDispatch.item.id);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
preparingConversations.current.add(conversationId);
clearConversationCompleted(conversationId);
setConversationActivity(conversationId, true);
const userMessage: Message = {
id: crypto.randomUUID(),
queueItemId: queuedDispatch.item.id,
role: "user",
content: prompt,
createdAt: Date.now(),
state: "complete",
attachments:
attachmentSnapshot.length > 0 ? attachmentSnapshot : undefined,
};
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
title:
conversation.title === "新对话"
? prompt.slice(0, 24)
: conversation.title,
updatedAt: Date.now(),
messages: [...conversation.messages, userMessage],
}
: conversation,
),
);
const memoryContext = queuedInput.includeMemoryContext
? buildMemoryContext(assistantMemories)
: "";
const executionPrompt = memoryContext
? `${prompt}\n\n${memoryContext}`
: prompt;
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: "assistant",
content: "",
blocks: [],
createdAt: Date.now(),
state: "streaming",
status: t("chat.status.preparingRequest"),
};
activeRuns.current.set(requestId, {
conversationId,
messageId: assistantMessage.id,
taskId: queuedDispatch?.scheduled ? queuedDispatch.item.taskId : undefined,
projectId: projectIdSnapshot,
runtimeSelectionKey: agentRuntimeSelectionKey(runtimeSelectionSnapshot),
});
preparingConversations.current.delete(conversationId);
const startedAt = new Date().toISOString();
setAssistantTasks((current) =>
[
{
id: requestId,
projectId: projectIdSnapshot,
conversationId,
title: prompt.slice(0, 120),
instructions: prompt,
origin: "user" as const,
status: "running" as const,
createdAt: startedAt,
startedAt,
},
...current,
].filter((task, index) =>
index < 100 || task.status === "running" || task.status === "waiting_approval",
),
);
recordActivity({
conversationId,
requestId,
kind: "request",
title: prompt.slice(0, 120),
detail: t("notices.userStartedTask"),
status: "running",
});
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
updatedAt: Date.now(),
messages: [
...conversation.messages,
assistantMessage,
],
activeRequest: undefined,
}
: conversation,
),
);
try {
const saveOrigin = conversationPersistenceQueueRef.current.then(async () => {
const origin = {
...conversationSnapshot,
title: conversationSnapshot.title === "新对话" ? prompt.slice(0, 24) : conversationSnapshot.title,
updatedAt: assistantMessage.createdAt,
workMode: workModeSnapshot,
messages: [...historySnapshot, userMessage, assistantMessage],
};
await window.goodbuddy.conversations.saveLocal([{
header: toLocalConversationHeader(origin),
messages: [toConversationMessage(userMessage), toConversationMessage(assistantMessage)],
}]);
persistedLocalConversationsRef.current.set(conversationId, origin);
});
conversationPersistenceQueueRef.current = saveOrigin.catch(() => undefined);
await saveOrigin;
await window.goodbuddy.agent.run({
requestId,
conversationId,
projectId: projectIdSnapshot,
queueItemId: queuedDispatch.item.id,
runtimeSelection: runtimeSelectionSnapshot,
runtimeControl: runtimeControlSnapshot,
expertId:
selectedExpertSnapshot && selectedExpertSnapshot !== "team"
? selectedExpertSnapshot
: undefined,
teamMode: selectedExpertSnapshot === "team",
smartRouting: smartRoutingSnapshot,
workMode: workModeSnapshot,
prompt: executionPrompt,
knowledgeLibraryIds: knowledgeLibraryIdsSnapshot,
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot,
contextIds: attachmentSnapshot.map((attachment) => attachment.id),
imageContextArtifactIds: queuedInput?.imageContextArtifactIds?.length
? queuedInput.imageContextArtifactIds
: attachmentSnapshot.some(attachment => attachment.kind === 'image') ? undefined : [...historySnapshot]
.reverse()
.find(
(message) =>
message.role === "assistant" &&
message.state === "complete" &&
message.artifactIds?.length,
)?.artifactIds,
contextCompressionState: conversationSnapshot.contextCompressionState,
history: retainedHistorySnapshot.map((message) => ({
role: message.role,
content: message.content,
})),
historyMessageIds: retainedHistorySnapshot.map((message) => message.id),
currentUserMessageId: userMessage.id,
currentAssistantMessageId: assistantMessage.id,
});
for (const attachment of attachmentSnapshot) {
void window.goodbuddy.context.remove(attachment.id);
}
} catch (error) {
preparingConversations.current.delete(conversationId);
activeRuns.current.delete(requestId);
setConversationActivity(conversationId, false);
for (const attachment of attachmentSnapshot) {
void window.goodbuddy.context.remove(attachment.id);
}
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
title:
conversationSnapshot.title === "新对话" &&
conversation.title === prompt.slice(0, 24)
? conversationSnapshot.title
: conversation.title,
updatedAt: Date.now(),
messages: conversation.messages.filter(
(message) =>
message.id !== userMessage.id &&
message.id !== assistantMessage.id,
),
}
: conversation,
),
);
setAssistantTasks((current) =>
current.filter((task) => task.id !== requestId),
);
setActivityRecords((current) =>
current.filter((record) => record.requestId !== requestId),
);
notify({
tone: "error",
message: displayErrorMessage(error, t("notices.sendFailed")),
});
await releaseQueuedItem();
} finally {
dispatchedConversationQueueItems.current.delete(queuedDispatch.item.id);
}
};
useLayoutEffect(() => {
conversationQueueDispatchRef.current = (dispatch) => {
void submit(dispatch);
};
});
const compactRuntimeContext = async (): Promise<void> => {
if (
!activeConversation ||
!activeRuntimeSelection ||
!runtimeContextCompactAvailable ||
runtimeContextCompacting ||
isRunning
) {
return;
}
let completeConversation: Conversation;
try { completeConversation = await ensureConversationHistory(activeConversation.id); }
catch {
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
return;
}
const history = buildRuntimeHistory(completeConversation.messages);
if (history.length < 2) {
notify({
tone: "info",
message: t("composer.context.nothingToCompact"),
dedupeKey: "runtime-context-compact",
});
return;
}
const requestId = crypto.randomUUID();
setRuntimeContextCompacting(true);
try {
const result = await window.goodbuddy.agent.compactConversation({
requestId,
conversationId: activeConversation.id,
projectId: activeConversation.projectId,
runtimeSelection: activeRuntimeSelection,
history: history.map((message) => ({
role: message.role,
content: message.content,
})),
historyMessageIds: history.map((message) => message.id),
contextCompressionState: activeConversation.contextCompressionState,
});
if (result.contextCompressionState) {
const state = result.contextCompressionState;
const remainingHistory = history.slice(
Math.min(state.coveredMessageCount, history.length),
);
const estimatedAfterTokens =
estimatedContextRequestOverheadTokens +
estimateMessagesTokens([
...buildConversationSummaryHistory(state.summary),
...remainingHistory.map((message) => ({
role: message.role,
content: message.content,
})),
]);
setConversations((current) =>
current.map((conversation) =>
conversation.id === activeConversation.id
? {
...conversation,
contextCompressionState: state,
contextMetrics: {
runtimeSelectionKey: agentRuntimeSelectionKey(activeRuntimeSelection),
contextTokens: estimatedAfterTokens,
source: "estimated",
basis: "conversation",
},
updatedAt: Date.now(),
}
: conversation,
),
);
}
notify({
tone: result.compacted ? "success" : "info",
message: result.detail,
dedupeKey: "runtime-context-compact",
});
} catch (reason) {
notify({
tone: "error",
message:
reason instanceof Error
? reason.message
: t("composer.context.compactFailed"),
dedupeKey: "runtime-context-compact",
});
} finally {
setRuntimeContextCompacting(false);
}
};
const stop = async (): Promise<void> => {
const requestId = [...activeRuns.current.entries()].find(
([, run]) => run.conversationId === activeId,
)?.[0] ?? conversationsRef.current.find(
conversation => conversation.id === activeId,
)?.activeRequest?.requestId;
if (requestId) {
try {
await window.goodbuddy.agent.cancel(requestId);
} catch {
notify({ tone: "error", message: t("notices.stopFailed") });
}
}
};
const respondToApproval = useCallback(
async (
conversationId: string,
messageId: string,
approvalId: string,
decision: ApprovalDecision,
): Promise<void> => {
const pendingMessage = conversationsRef.current
.find((conversation) => conversation.id === conversationId)
?.messages.find((message) => message.id === messageId);
if (pendingMessage?.approval?.id !== approvalId) return;
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
try {
await window.goodbuddy.agent.respondApproval(approvalId, decision);
const approved = decision !== "deny";
const decisionLabel = {
deny: tRef.current("chat.approval.decisionDeny"),
once: tRef.current("chat.approval.decisionOnce"),
session: tRef.current("chat.approval.decisionSession"),
permanent: tRef.current("chat.approval.decisionPermanent"),
}[decision];
setActivityRecords((current) => {
let updated = false;
return current.map((record) => {
if (
!updated &&
record.conversationId === conversationId &&
record.kind === "approval" &&
record.status === "pending"
) {
updated = true;
return {
...record,
status: approved ? ("completed" as const) : ("denied" as const),
detail: `${record.detail}\n${tRef.current(
"notices.userDecision",
{ decision: decisionLabel },
)}`,
};
}
return record;
});
});
updateMessage(conversationId, messageId, (message) => {
if (message.approval?.id !== approvalId) return message;
if (!message.pendingQuestions?.length) {
setAssistantTasks((current) => current.map((task) =>
task.id === taskId && task.status === "waiting_approval"
? { ...task, status: "running" }
: task,
));
}
return {
...message,
approval: undefined,
status: message.pendingQuestions?.length
? message.status
: approved && message.task
? undefined
: approved
? tRef.current("chat.approval.executing", { decision: decisionLabel })
: tRef.current("chat.approval.denied"),
};
});
} catch {
updateMessage(conversationId, messageId, (message) => message.approval?.id !== approvalId ? message : ({
...message,
status: tRef.current("chat.approval.responseFailed"),
}));
}
},
[updateMessage],
);
const respondToQuestion = useCallback(
async (
conversationId: string,
messageId: string,
questionId: string,
answers?: AgentQuestionAnswer[],
): Promise<void> => {
const pendingMessage = conversationsRef.current
.find((conversation) => conversation.id === conversationId)
?.messages.find((message) => message.id === messageId);
const question = pendingMessage?.pendingQuestions?.[0];
if (!question || question.questionId !== questionId) {
return;
}
const taskId = question.requestId;
await window.goodbuddy.agent.respondQuestion(questionId, answers);
updateMessage(conversationId, messageId, (message) => {
const pendingQuestions = message.pendingQuestions?.filter((item) => item.questionId !== questionId);
const resumed = message.pendingQuestions?.some((item) => item.questionId === questionId) &&
!pendingQuestions?.length && !message.approval && message.state === "streaming";
if (resumed) {
setAssistantTasks((current) => current.map((task) =>
task.id === taskId && task.status === "waiting_approval"
? { ...task, status: "running" }
: task,
));
}
return {
...message,
answeredQuestions: [
...(message.answeredQuestions ?? []).filter((item) => item.questionId !== questionId),
{
questionId,
skipped: answers === undefined,
questions: question.questions.map((item, index) => ({
...item,
answer: answers?.[index],
})),
},
],
pendingQuestions,
status: resumed
? answers
? tRef.current("chat.status.answerSubmitted")
: tRef.current("chat.status.questionSkipped")
: message.status,
};
});
},
[updateMessage],
);
const addContext = async (
action: () => Promise<ContextAttachment | ContextAttachment[]>,
): Promise<void> => {
const conversationId = activeId;
setContextError(undefined);
try {
await conversationPersistenceQueueRef.current;
if (!persistedLocalConversationsRef.current.has(conversationId)) {
const owner = conversationsRef.current.find((conversation) => conversation.id === conversationId);
if (!owner || owner.remote) throw new Error('附件目标会话不可编辑');
await window.goodbuddy.conversations.saveLocal([{ header: toLocalConversationHeader(owner), messages: owner.messages.map(toConversationMessage) }]);
}
const result = await action();
const selected = Array.isArray(result) ? result : [result];
const current = attachmentsRef.current.get(conversationId) ?? [];
const unique = selected.filter(
(item) => !current.some((existing) => existing.id === item.id),
);
const accepted = unique.slice(0, Math.max(0, maximumAttachmentsPerMessage - current.length));
for (const attachment of unique.slice(accepted.length)) {
void window.goodbuddy.context.remove(attachment.id);
}
updateAttachments([...current, ...accepted]);
if (accepted.length < unique.length) {
setContextError(t("composer.errors.attachmentLimit"));
}
} catch (reason) {
setContextError(
reason instanceof Error
? reason.message
: t("composer.errors.addContext"),
);
}
};
const editImage = useCallback((artifact: AssistantArtifact): void => {
const conversationId = activeConversationIdRef.current;
setContextError(undefined);
setImageReferencesByConversation(current => ({ ...current,
[conversationId]: [...(current[conversationId] ?? []).filter(item => item.id !== artifact.id), artifact].slice(-8),
}));
inputRef.current?.focus();
}, []);
const openImageModelSettings = useCallback((): void => {
setSettingsInitialCategory("model");
setView("settings");
}, [setView]);
const reselectImageSources = useCallback((operation: ImageOperation): void => {
if (operation.conversationId !== activeConversationIdRef.current) return;
setConversationInput(operation.conversationId, current => current || t("chat.images.recoveryPrompt", {
model: operation.modelProfileName ?? operation.modelName,
prompt: operation.input.prompt,
}));
inputRef.current?.focus();
attachmentButtonRef.current?.click();
}, [setConversationInput, t]);
const selectContextFiles = async (paths?: string[]): Promise<void> => {
if (selectingContextFilesRef.current) {
return;
}
selectingContextFilesRef.current = true;
setSelectingContextFiles(true);
setFileSelectionProgress(undefined);
try {
await addContext(() => paths
? window.goodbuddy.context.importFiles(paths, activeId)
: window.goodbuddy.context.selectFiles(activeId));
} finally {
selectingContextFilesRef.current = false;
setSelectingContextFiles(false);
setFileSelectionProgress(undefined);
}
};
const startWebSpeechInput = async (): Promise<void> => {
const SpeechRecognition = getSpeechRecognitionConstructor(window);
if (!SpeechRecognition) {
notify({
tone: "info",
message: t("composer.voice.unsupported"),
});
return;
}
setVoiceListening(true);
setVoiceRecording(false);
let started = false;
try {
const prepared = await prepareSpeechRecognition(
SpeechRecognition,
"zh-CN",
() => {
notify({
tone: "info",
message: t("composer.voice.downloadingPack"),
dedupeKey: "speech-status",
});
},
);
const { recognition } = prepared;
recognition.onresult = (event) => {
const transcript = event.results[0]?.[0]?.transcript?.trim();
if (transcript) {
setInput((current) =>
current ? `${current} ${transcript}` : transcript,
);
notify({
tone: "success",
message: t("composer.voice.transcribed"),
dedupeKey: "speech-status",
});
}
};
recognition.onerror = (event) => {
notify({
tone: "error",
message: describeSpeechRecognitionError(event),
dedupeKey: "speech-status",
});
setVoiceListening(false);
setVoiceRecording(false);
};
recognition.onend = () => {
setVoiceListening(false);
setVoiceRecording(false);
};
recognition.start();
started = true;
setVoiceRecording(true);
notify({
tone: "info",
message: prepared.local
? t("composer.voice.localListening")
: t("composer.voice.systemListening"),
dedupeKey: "speech-status",
});
} catch (reason) {
notify({
tone: "error",
message:
reason instanceof Error
? reason.message
: t("composer.voice.startFailed"),
dedupeKey: "speech-status",
});
} finally {
if (!started) {
setVoiceListening(false);
setVoiceRecording(false);
}
}
};
const startVoiceInput = async (): Promise<void> => {
const speech = window.goodbuddy.speech;
if (!speech) {
await startWebSpeechInput();
return;
}
const audioWindow = window as typeof window & {
webkitAudioContext?: typeof AudioContext;
};
const AudioContextType =
audioWindow.AudioContext ?? audioWindow.webkitAudioContext;
if (!navigator.mediaDevices?.getUserMedia || !AudioContextType) {
notify({
tone: "error",
message: t("composer.voice.microphoneUnavailable"),
dedupeKey: "speech-status",
});
return;
}
setVoiceListening(true);
setVoiceRecording(false);
voiceStartingRef.current = true;
try {
const recording = await startPcmRecording(
navigator.mediaDevices,
AudioContextType,
);
voiceStartingRef.current = false;
if (voiceDisposedRef.current) {
void recording.result.catch(() => undefined);
recording.cancel();
return;
}
voiceRecordingRef.current = recording;
setVoiceRecording(true);
notify({
tone: "info",
message: t("composer.voice.recording"),
dedupeKey: "speech-status",
});
void recording.result
.then(async ({ audio, sampleRate }) => {
voiceRecordingRef.current = undefined;
setVoiceRecording(false);
const requestId = crypto.randomUUID();
voiceRequestIdRef.current = requestId;
notify({
tone: "info",
message: t("composer.voice.localRecognizing"),
dedupeKey: "speech-status",
});
const result = await speech.transcribe({
requestId,
sampleRate,
audio,
});
if (voiceRequestIdRef.current !== requestId) {
return;
}
const transcript = result.text.trim();
if (!transcript) {
notify({
tone: "info",
message: t("composer.voice.noSpeech"),
dedupeKey: "speech-status",
});
return;
}
setInput((current) =>
current ? `${current} ${transcript}` : transcript,
);
notify({
tone: "success",
message: t("composer.voice.transcribed"),
dedupeKey: "speech-status",
});
})
.catch((reason: unknown) => {
notify({
tone:
reason instanceof Error && reason.name === "AbortError"
? "info"
: "error",
message:
reason instanceof Error && reason.name === "AbortError"
? t("composer.voice.cancelled")
: reason instanceof Error
? reason.message
: t("composer.voice.localFailed"),
dedupeKey: "speech-status",
});
})
.finally(() => {
voiceRequestIdRef.current = undefined;
setVoiceListening(false);
setVoiceRecording(false);
});
} catch (reason) {
voiceStartingRef.current = false;
setVoiceListening(false);
setVoiceRecording(false);
notify({
tone: "error",
message:
reason instanceof Error && reason.name === "NotAllowedError"
? t("composer.voice.permissionDenied")
: reason instanceof Error
? reason.message
: t("composer.voice.recordingStartFailed"),
dedupeKey: "speech-status",
});
}
};
const toggleVoiceInput = (): void => {
if (voiceStartingRef.current) {
return;
}
const recording = voiceRecordingRef.current;
if (recording) {
setVoiceRecording(false);
recording.stop();
notify({
tone: "info",
message: t("composer.voice.preparing"),
dedupeKey: "speech-status",
});
return;
}
const requestId = voiceRequestIdRef.current;
if (requestId) {
voiceRequestIdRef.current = undefined;
void window.goodbuddy.speech?.cancel(requestId);
notify({
tone: "info",
message: t("composer.voice.cancelled"),
dedupeKey: "speech-status",
});
setVoiceListening(false);
setVoiceRecording(false);
return;
}
void startVoiceInput();
};
const refreshSelectedKnowledge = async (): Promise<void> => {
await refreshKnowledge(knowledgeSnapshot.selectedLibraryId);
};
const createKnowledgeLibrary = async (
input: Parameters<typeof window.goodbuddy.knowledge.createLibrary>[0],
): Promise<void> => {
const library = await window.goodbuddy.knowledge.createLibrary(input);
setEnabledKnowledgeLibraryIds((current) => [...current, library.id]);
await refreshKnowledge(library.id);
};
const deleteKnowledgeLibrary = async (libraryId: string): Promise<void> => {
await window.goodbuddy.knowledge.deleteLibrary(libraryId);
await refreshKnowledge();
};
const runKnowledgeSourceAction = async <T,>(
action: () => Promise<T>,
): Promise<T> => {
setKnowledgeOperationCount((count) => {
const next = count + 1;
knowledgeOperationCountRef.current = next;
return next;
});
try {
const result = await action();
await refreshSelectedKnowledge();
return result;
} catch (error) {
await refreshSelectedKnowledge().catch(() => undefined);
throw error;
} finally {
setKnowledgeOperationCount((count) => {
const next = Math.max(0, count - 1);
knowledgeOperationCountRef.current = next;
return next;
});
}
};
const openQuickNotes = useCallback((): void => {
if (!magicNotesEnabled) return;
setAssistantSidebarOpen(true);
setNotesOpenRequest(value => value + 1);
}, [magicNotesEnabled]);
const captureToNote = useCallback(async (conversationId: string, message?: Message, trigger?: HTMLElement): Promise<void> => {
if (!magicNotesEnabled || noteCapturePending.current || noteDraft.saving) return;
const snapshot = conversationsRef.current.find(item => item.id === conversationId);
if (!snapshot || (message && (message.state === 'streaming' || !message.content.trim()))) return;
if (!message && snapshot.messages.some(item => item.state === 'streaming')) {
notify({ tone: 'info', message: t('magicNotes:capture.wait') });
return;
}
noteCapturePending.current = true;
setNoteCaptureLoading(true);
const capturedAt = new Date().toISOString();
try {
const conversation = message ? snapshot : await ensureConversationHistory(conversationId);
const messages = message ? [message] : conversation.messages.filter(item => (item.role === 'user' || item.role === 'assistant') && item.content.trim());
if (!message && messages.some(item => item.state === 'streaming')) throw new Error(t('magicNotes:capture.wait'));
const text = message ? message.content : messages.map(item => `## ${t(item.role === 'user' ? 'magicNotes:capture.user' : 'magicNotes:capture.assistant')}\n\n${item.content}`).join('\n\n');
if (!text.trim()) return;
// Main validates source IDs against persisted messages, including newly completed replies.
persistLocalConversationChanges();
await conversationPersistenceQueueRef.current;
if (!await guardNoteDraft()) return;
const title = conversation.title.slice(0, 100);
setNoteDraft({ text, initialText: text, title, initialTitle: title, targetId: '', newNote: false,
incomplete: message ? message.state !== 'complete' : undefined,
source: { kind: message ? 'message' : 'conversation', conversationId, messageIds: messages.map(item => item.id), capturedAt,
conversationTitle: conversation.title, projectId: conversation.projectId, projectName: conversation.projectId ? projectNames.get(conversation.projectId) : undefined } });
noteCaptureTrigger.current = trigger ?? noteTitleMenuRef.current;
openQuickNotes();
} catch (reason) { notify({ tone: 'error', message: displayErrorMessage(reason, t('magicNotes:errors.operationFailed')) }); }
finally { noteCapturePending.current = false; setNoteCaptureLoading(false); }
}, [magicNotesEnabled, noteDraft.saving, ensureConversationHistory, persistLocalConversationChanges, guardNoteDraft, setNoteDraft, projectNames, openQuickNotes, notify, t]);
const openNoteSource = async (source: MagicNoteSource, messageId?: string): Promise<'opened' | 'missing'> => {
let snapshot;
try { snapshot = await window.goodbuddy.conversations.get(source.conversationId); }
catch (reason) {
if (reason instanceof Error && /对话不存在(?:$|["'])/u.test(reason.message)) return 'missing';
throw reason;
}
requestWorkspaceLeave('chat', () => {
const next = mergePersistedConversations(conversationsRef.current, [snapshot], persistedLocalConversationsRef.current, new Set([source.conversationId, ...retainedConversationDetailIds()]));
conversationsRef.current = next;
setConversations(next);
setActiveProjectId(snapshot.projectId ?? '');
setActiveId(source.conversationId);
setSearchQuery('');
commitView('chat');
const targetId = messageId ?? (source.kind === 'message' ? source.messageIds[0] : undefined);
if (targetId) {
const conversation = next.find(item => item.id === source.conversationId)!;
const index = conversation.messages.findIndex(item => item.id === targetId);
if (index < 0 || !snapshot.messages.some(message => message.id === targetId)) notify({ tone: 'info', message: t('magicNotes:capture.missingMessage') });
else {
setVisibleMessageCounts(current => ({ ...current, [source.conversationId]: Math.max(current[source.conversationId] ?? messageRenderBatchSize, conversation.messages.length - index) }));
setNoteMessageNavigation({ conversationId: source.conversationId, messageId: targetId, requestId: Date.now() });
}
}
});
return 'opened';
};
const clearActivity = useCallback((): void => {
legacyActivityHistoryMayBeIncompleteRef.current = false;
setLegacyActivityHistoryMayBeIncomplete(false);
setActivityRecords([]);
}, []);
const openActivityConversation = useCallback((conversationId: string): void => {
const open = async (): Promise<void> => {
let conversation = conversationsRef.current.find(
(candidate) => candidate.id === conversationId,
);
if (!conversation) {
try {
conversation = await window.goodbuddy.conversations.get(conversationId);
const persisted = [conversation];
setConversations((current) => mergePersistedConversations(
current, persisted, persistedLocalConversationsRef.current,
));
} catch {
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
return;
}
}
if (!conversation) {
notify({ tone: "info", message: t("notices.conversationDeleted") });
return;
}
setActiveProjectId(conversation.projectId ?? "");
setConversationActionsId("");
setSelectedAssistantTaskId(undefined);
setSearchQuery("");
setActiveId(conversationId);
setUnreadConversationIds((current) => {
if (!current.has(conversationId)) return current;
const next = new Set(current);
next.delete(conversationId);
return next;
});
commitView("chat");
if (narrowWindow) closeNarrowSidebar();
};
requestWorkspaceLeave("chat", () => { void open(); });
}, [closeNarrowSidebar, commitView, narrowWindow, requestWorkspaceLeave, setActiveId, t]);
const openAssistantTask = (task: AssistantTask): void => {
if (!task.conversationId) {
notify({
tone: "info",
message: t("notices.conversationDeleted"),
});
return;
}
const conversation = conversations.find(
(candidate) => candidate.id === task.conversationId,
);
if (!conversation) {
notify({
tone: "info",
message: t("notices.conversationDeleted"),
});
return;
}
if (task.projectId) {
setActiveProjectId(task.projectId);
}
setSelectedAssistantTaskId(task.id);
setExpandedTaskConversationIds((current) => {
const next = new Set(current);
next.add(conversation.id);
return next;
});
setActiveId(conversation.id);
setView("chat");
};
const openCustomTaskDialog = (
defaultDestination: CustomTaskDestination,
): void => {
if (!activeProject || activeProject.kind !== "user") {
notify({
tone: "info",
message: t("customTask.errors.projectUnavailable"),
});
return;
}
setCustomTaskDialog({ defaultDestination });
};
const createCustomTask = async (
input: Parameters<typeof window.goodbuddy.schedules.create>[0],
options?: CustomTaskCreateOptions,
): Promise<AssistantSchedule> => {
if (!options?.runImmediately && !(Date.parse(input.nextRunAt) > Date.now())) {
throw new Error(t("customTask.errors.futureTime"));
}
if (input.conversationId) {
persistLocalConversationChanges();
await conversationPersistenceQueueRef.current;
}
const schedule = await window.goodbuddy.schedules.create({
...input,
runImmediately: options?.runImmediately ?? false,
});
setAssistantSchedules((current) => [
schedule,
...current.filter((item) => item.id !== schedule.id),
]);
setSelectedAssistantTaskId(schedule.taskId);
setExpandedTaskConversationIds((current) => {
const next = new Set(current);
next.add(schedule.conversationId);
return next;
});
const [conversationResult, taskResult, scheduleResult] =
await Promise.allSettled([
window.goodbuddy.conversations.listSummaries([...retainedConversationDetailIds(), schedule.conversationId]),
window.goodbuddy.tasks.list(),
window.goodbuddy.schedules.list(),
]);
if (conversationResult.status === "fulfilled") {
setConversations((current) =>
mergePersistedConversations(
current,
conversationResult.value,
persistedLocalConversationsRef.current,
retainedConversationDetailIds(),
),
);
} else {
notify({
tone: "error",
message: t("notices.remoteConversationRefreshFailed"),
dedupeKey: "custom-task-conversation-refresh",
});
}
if (taskResult.status === "fulfilled") {
setAssistantTasks(taskResult.value);
}
if (scheduleResult.status === "fulfilled") {
setAssistantSchedules(scheduleResult.value);
}
if (
taskResult.status === "rejected" ||
scheduleResult.status === "rejected"
) {
notify({
tone: "error",
message: t("notices.taskHistoryReadFailed"),
dedupeKey: "custom-task-discovery-refresh",
});
}
if (schedule.projectId) {
setActiveProjectId(schedule.projectId);
}
setActiveId(schedule.conversationId);
setView("chat");
return schedule;
};
const runAssistantSchedule = useCallback(async (scheduleId: string): Promise<void> => {
await window.goodbuddy.schedules.runNow(scheduleId);
setAssistantTasks(await window.goodbuddy.tasks.list());
notify({
tone: "success",
message: t("notices.scheduleStarted"),
});
}, [notify, t]);
const setAssistantScheduleEnabled = useCallback(async (
scheduleId: string,
enabled: boolean,
): Promise<void> => {
await window.goodbuddy.schedules.setEnabled(scheduleId, enabled);
setAssistantSchedules((current) =>
current.map((schedule) =>
schedule.id === scheduleId ? { ...schedule, enabled } : schedule,
),
);
}, []);
const removeAssistantSchedule = useCallback(async (scheduleId: string): Promise<void> => {
await window.goodbuddy.schedules.remove(scheduleId);
setAssistantSchedules((current) =>
current.filter((schedule) => schedule.id !== scheduleId),
);
}, []);
const conversationTaskStrips = useMemo(() => new Map(cachedConversations.map(conversation => {
const tasks = tasksByConversation.get(conversation.id) ?? emptyConversationTasks;
return [conversation.id, (
<div className="conversation-context-strips">
{!conversation.remote && (
<ConversationTaskStrip
conversationMode={conversation.id === activeId
? effectiveWorkMode
: normalizeInteractiveWorkMode(conversation.workMode ?? projects.find(project => project.id === conversation.projectId)?.defaultWorkMode)}
locale={locale}
onRemoveSchedule={removeAssistantSchedule}
onRunSchedule={runAssistantSchedule}
onSelectTask={setSelectedAssistantTaskId}
onSetScheduleEnabled={setAssistantScheduleEnabled}
schedules={assistantSchedules}
selectedTaskId={tasks.some(task => task.id === selectedAssistantTaskId) ? selectedAssistantTaskId : undefined}
tasks={tasks}
/>
)}
<RuntimeChecklistStrip messages={conversation.messages} activeMessageId={conversation.activeRequest?.messageId} />
</div>
)];
})), [cachedConversations, tasksByConversation, activeId, effectiveWorkMode, projects, locale,
removeAssistantSchedule, runAssistantSchedule, setAssistantScheduleEnabled, assistantSchedules, selectedAssistantTaskId]);
const clearLocalData = async (): Promise<void> => {
conversationPersistencePausedRef.current = true;
try {
await conversationPersistenceQueueRef.current;
for (const requestId of activeRuns.current.keys()) {
await window.goodbuddy.agent.cancel(requestId);
}
activeRuns.current.clear();
setActiveConversationIds(new Set());
for (const attachments of attachmentsRef.current.values()) {
for (const attachment of attachments) {
await window.goodbuddy.context.remove(attachment.id);
}
}
attachmentsRef.current.clear();
setAttachmentsByConversation({});
for (const library of knowledgeSnapshot.libraries) {
await window.goodbuddy.knowledge.deleteLibrary(library.id);
}
await window.goodbuddy.app.clearLocalData();
const conversation = createConversation(
activeProjectId || undefined,
undefined,
t("conversation.greeting"),
);
conversationsRef.current = [conversation];
persistedLocalConversationsRef.current.clear();
setConversations([conversation]);
setActiveId(conversation.id);
legacyActivityHistoryMayBeIncompleteRef.current = false;
setLegacyActivityHistoryMayBeIncomplete(false);
setActivityRecords([]);
setAssistantTasks([]);
setTokenUsage(emptyTokenUsage);
setAssistantArtifacts([]);
setAssistantMemories([]);
setAssistantSchedules([]);
setAssistantHeartbeats([]);
setHeartbeatEntries([]);
setHeartbeatRuns([]);
setHeartbeatMemories([]);
setKnowledgeSnapshot({
libraries: [],
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: [],
});
updateAttachments([]);
setInput("");
setView("chat");
notify({
tone: "success",
message: t("notices.localDataCleared"),
});
} finally {
conversationPersistencePausedRef.current = false;
persistLocalConversationChanges();
}
};
const isRunning =
activeConversation?.messages.some(
(message) => message.state === "streaming",
) ?? false;
const [composerContext, setComposerContext] = useState({ activeId, isRunning, view });
if (composerContext.activeId !== activeId || composerContext.isRunning !== isRunning || composerContext.view !== view) {
setComposerContext({ activeId, isRunning, view });
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
}
const activeConversationQueueItems = useMemo(
() =>
conversationQueueItems.filter((item) => item.conversationId === activeId),
[activeId, conversationQueueItems],
);
const conversationExecutionRunning =
isRunning ||
assistantTasks.some(
(task) =>
task.conversationId === activeId &&
(task.status === "running" || task.status === "waiting_approval"),
);
const handleConversationQueueError = useCallback(
(message: string): void => {
notify({
tone: "error",
message,
});
},
[notify],
);
const interruptConversationQueueItem = useCallback(
(itemId: string) =>
window.goodbuddy.conversationQueue.interruptAndRun(itemId),
[],
);
const removeConversationQueueItem = useCallback(
(itemId: string) => window.goodbuddy.conversationQueue.remove(itemId),
[],
);
const runtimeAgentControlAvailable =
activeRuntimeSelection?.provider === "opencode" &&
runtimeAgentOptions.length > 1;
const runtimePresetControlAvailable =
activeRuntimeSelection?.provider === "continue" &&
runtimePresetOptions.length > 1;
const runtimeActionControlAvailable =
(activeRuntimeSelection?.provider === "opencode" ||
activeRuntimeSelection?.provider === "continue") &&
runtimeActionOptions.length > 1;
const runtimeControlsAvailable =
runtimeAgentControlAvailable ||
runtimePresetControlAvailable ||
runtimeActionControlAvailable;
const runtimeControlsProvider = runtimeControlsAvailable
? activeRuntimeSelection?.provider === "opencode"
? "OpenCode"
: activeRuntimeSelection?.provider === "continue"
? "Continue"
: undefined
: undefined;
const runtimeControlsLabel = runtimeControlsProvider
? t("composer.runtimeControls.groupLabel", {
runtime: runtimeControlsProvider,
})
: "";
const composerOptionSummary = [
selectedExpertId && runtime?.capability !== "image-generation"
? assistantExpertOptions.find((option) => option.value === selectedExpertId)?.label
: undefined,
enabledKnowledgeLibraryIds.length > 0
? t("composer.knowledge.select", { count: enabledKnowledgeLibraryIds.length })
: undefined,
activeConversation?.knowledgeRetrievalMode === "always"
? t("composer.knowledge.always")
: undefined,
runtimeAgentControlAvailable && selectedRuntimeAgent
? runtimeAgentOptions.find((option) => option.value === selectedRuntimeAgent)?.label
: undefined,
runtimePresetControlAvailable && selectedContinuePreset
? runtimePresetOptions.find((option) => option.value === selectedContinuePreset)?.label
: undefined,
runtimeActionControlAvailable && selectedRuntimeCommand
? runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.label
: undefined,
].filter(Boolean).join(" · ");
const runtimeContextCompactAvailable =
activeRuntimeSelection?.provider === "model"
? !activeConversation?.remote &&
activeProject?.executionSpace.kind !== "ssh" &&
runtimeSettings?.modelProfiles.some(
(profile) =>
profile.id === activeRuntimeSelection.profileId &&
profile.protocol !== "openai-images-generations",
) === true
: (activeRuntimeSelection?.provider === "opencode" ||
activeRuntimeSelection?.provider === "continue") &&
runtimeNativeSnapshot?.context.manualCompact === true;
const nativeClientAvailable = activeRuntimeSelection?.provider === "continue" ||
activeRuntimeSelection?.provider === "opencode" ||
(activeRuntimeSelection?.provider === "deepseek-harness" && activeProject?.executionSpace.kind === "local");
const nativeClientContextKey = useMemo(() => JSON.stringify([
activeId, activeProjectId, activeRuntimeSelection, workMode, runtimeSettings,
]), [activeId, activeProjectId, activeRuntimeSelection, workMode, runtimeSettings]);
const prepareNativeClientConversation = async (): Promise<string> => {
let conversation = activeConversation;
if (!conversation) {
conversation = await new Promise<Conversation>((resolve, reject) => {
if (!startNewConversation(activeProjectId || undefined, { ready: resolve })) {
reject(new Error(t("notices.channelConversationAutomatic")));
}
});
setActiveId(conversation.id);
}
// Save the current selection before Main resolves the launch from its conversation ID.
const header = toLocalConversationHeader({ ...conversation, workMode });
const operation = conversationPersistenceQueueRef.current.then(() =>
window.goodbuddy.conversations.saveLocal([{ header, messages: [] }]),
);
conversationPersistenceQueueRef.current = operation.catch(() => undefined);
await operation;
return conversation.id;
};
const composerContextMetrics = useMemo(() => {
if (
!activeConversation ||
!runtimeSettings ||
!activeRuntimeSelection ||
activeConversation.remote
) {
return undefined;
}
const resolvedRuntimeSelection = activeRuntimeSelection;
const activeModelProfile =
"profileId" in resolvedRuntimeSelection &&
resolvedRuntimeSelection.profileId
? runtimeSettings.modelProfiles.find(
(candidate) => candidate.id === resolvedRuntimeSelection.profileId,
)
: undefined;
if (activeModelProfile?.protocol === "openai-images-generations") {
return undefined;
}
const latest = activeConversation.contextMetrics;
const applicableLatest =
latest?.runtimeSelectionKey ===
agentRuntimeSelectionKey(resolvedRuntimeSelection)
? latest
: undefined;
if (!applicableLatest) {
return undefined;
}
const compressionSettings =
runtimeSettings.contextCompression ?? defaultContextCompressionSettings;
const contextTokens = applicableLatest.contextTokens;
const contextWindowTokens = activeModelProfile?.contextWindowTokens;
const effectiveTriggerTokens = getEffectiveContextTriggerTokens({
triggerTokens: compressionSettings.triggerTokens,
contextWindowTokens,
});
const denominatorTokens = contextWindowTokens;
const percentage =
denominatorTokens === undefined
? undefined
: Math.round((contextTokens / denominatorTokens) * 100);
return {
contextTokens,
effectiveTriggerTokens,
contextWindowTokens,
compressionEnabled:
resolvedRuntimeSelection.provider === "model" &&
compressionSettings.enabled,
source: applicableLatest.source,
basis:
applicableLatest.basis ??
(applicableLatest.source === "estimated" &&
activeConversation.contextCompressionState
? "conversation"
: "model-call"),
denominatorTokens,
percentage,
};
}, [activeConversation, activeRuntimeSelection, runtimeSettings]);
const mainSidebarOpen = narrowWindow && sidebarOpen;
const canResizePrimarySidebar = sidebarOpen && !narrowWindow;
const primarySidebarWidthLimits = getPrimarySidebarWidthLimits(
window.innerWidth,
);
const backgroundIsolated = mainSidebarOpen;
const runtimeState =
runtimeSwitching ||
!runtime ||
runtimeStatusKey !== activeRuntimeSelectionKey
? "connecting"
: runtime.available
? "ready"
: "unavailable";
const runtimeDetailId = "composer-runtime-detail";
const runtimeDetail =
runtimeState === "connecting"
? t("runtime.connecting")
: runtime?.detail ||
(runtimeState === "unavailable"
? t("runtime.unavailable")
: t("runtime.state.ready"));
const composerContextErrorId = "composer-context-error";
const runBrowserCommand = async (
command: (
browserApi: NonNullable<typeof window.goodbuddy.browser>,
) => Promise<void>,
): Promise<void> => {
const browserApi = window.goodbuddy.browser;
if (!browserApi) {
notify({
tone: "error",
message: t("notices.browserControlUnavailable"),
});
return;
}
await command(browserApi);
};
return (
<div className="app-shell" data-frosted-glass={applicationSettings?.transparentFrostedEffectEnabled ? 'true' : undefined}>
<DocumentConversationContext value={{
activeId: activeConversation?.remote ? undefined : activeId,
create: () => new Promise((resolve, reject) => {
const created = startNewConversation(activeProjectId || undefined, { ready: (conversation) => {
void window.goodbuddy.conversations.saveLocal([{ header: toLocalConversationHeader(conversation), messages: conversation.messages.map(toConversationMessage) }])
.then(() => resolve({ id: conversation.id, title: conversation.title }), reject);
} });
if (!created) reject(new Error('当前项目不能创建会话,请先选择普通项目'));
}),
navigate: (id) => { setActiveId(id); setView('chat'); },
notify: (message) => notify({ tone: 'success', message }),
openImage: (src, title, trigger) => { imageViewerTriggerRef.current = trigger; setImageViewerItem({ src, title }); }
}}>
<aside
aria-label={
narrowWindow && sidebarOpen ? t("sidebar.label") : undefined
}
aria-hidden={!sidebarOpen}
aria-modal={narrowWindow && sidebarOpen ? "true" : undefined}
className={
sidebarOpen
? `sidebar${
primarySidebarResizing && canResizePrimarySidebar
? " sidebar--resizing"
: ""
}`
: "sidebar sidebar--closed"
}
id="primary-sidebar"
inert={!sidebarOpen}
onKeyDown={(event) => {
if (mainSidebarOpen && event.currentTarget.contains(event.target as Node)) {
trapTabFocus(event, sidebarRef.current);
}
}}
ref={sidebarRef}
role={narrowWindow && sidebarOpen ? "dialog" : undefined}
style={
{
"--primary-sidebar-width": `${primarySidebarWidth}px`,
} as React.CSSProperties
}
>
<BrandLockup
className="brand"
copyClassName="brand__copy"
logo={
brandingPreferences.logoDataUrl ??
(resolvedAppearanceTheme === "dark"
? goodbuddyDarkIcon
: goodbuddyLightIcon)
}
markClassName="brand__mark"
name={brandingPreferences.name}
subtitle={brandingSubtitle || undefined}
/>
<ProjectSwitcher
activeProjectId={activeProjectId}
activityByProjectId={projectActivity.byProjectId}
recoveryByProjectId={projectRecoveryByProjectId}
runtimeSettings={runtimeSettings}
onArchive={archiveProject}
onCreate={createProject}
onDelete={deleteProject}
onRemoteCommitted={loadCommittedRemoteProject}
onRetryRecovery={retryProjectRecovery}
onSelect={selectProject}
onSelectRoot={() => window.goodbuddy.settings.selectWorkspace()}
onUpdate={updateProject}
projects={projects}
remoteProjectsEnabled={remoteProjectsEnabled}
/>
<ProjectActivity
activities={projectActivity.activities}
projects={projects}
visible={sidebarOpen}
onOpenConversation={openActivityConversation}
/>
<div
className={`sidebar-conversation-controls${searchOpen ? " sidebar-conversation-controls--searching" : ""}`}
onKeyDown={(event) => {
if (searchOpen && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeConversationSearch();
}
}}
>
{activeProject?.kind !== "channel" && (
<button
className="new-chat"
onClick={newConversation}
type="button"
aria-label={t("sidebar.newConversation")}
title={t("sidebar.newConversation")}
>
<MessageSquarePlus aria-hidden="true" size={17} />
{!searchOpen && <span>{t("sidebar.newConversation")}</span>}
</button>
)}
<div className="sidebar-search-slot">
{searchOpen ? (
<div className="sidebar-search">
<input
autoFocus
aria-label={t("sidebar.searchLabel")}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
value={searchQuery}
/>
<button
aria-label={t("sidebar.closeSearch")}
className="icon-button sidebar-search__clear"
onClick={closeConversationSearch}
title={t("sidebar.closeSearch")}
type="button"
>
<X aria-hidden="true" size={16} />
</button>
</div>
) : (
<button
ref={searchTriggerRef}
className="icon-button sidebar-search-trigger"
aria-label={t("sidebar.searchLabel")}
aria-expanded={false}
title={t("sidebar.searchLabel")}
onClick={() => setSearchOpen(true)}
type="button"
>
<Search aria-hidden="true" size={17} />
</button>
)}
</div>
</div>
<nav className="primary-nav" aria-label={t("navigation.label")}>
<button
aria-current={view === "chat" ? "page" : undefined}
className={
view === "chat" ? "nav-item nav-item--active" : "nav-item"
}
onClick={(event) =>
navigateFromSidebar("chat", event.currentTarget)
}
type="button"
>
<MessageSquare aria-hidden="true" size={17} />
<span>{t("navigation.chat")}</span>
</button>
{visibleApplications.map((id) => {
const definition = applicationDefinitions[id];
const Icon = definition.icon;
return (
<button
key={id}
type="button"
className={view === id ? "nav-item nav-item--active" : "nav-item"}
aria-current={view === id ? "page" : undefined}
onClick={(event) => navigateFromSidebar(id, event.currentTarget)}
onFocus={() => preloadWorkspaceRouteOnIntent(id)}
onPointerEnter={() => preloadWorkspaceRouteOnIntent(id)}
>
<Icon size={17} aria-hidden="true" />
<span>{t(definition.title)}</span>
{id === "magic-notes" && magicNotesShowIncompleteTodoCount && incompleteMagicTodoCount > 0 && (
<span className="nav-item__badge" aria-label={t("navigation.incompleteTodos", { count: incompleteMagicTodoCount })}>
{incompleteMagicTodoCount > 99 ? "99+" : incompleteMagicTodoCount}
</span>
)}
{id === "heartbeat" && pendingHeartbeatSuggestionCount > 0 && (
<span className="nav-item__badge" aria-label={t("navigation.pendingSuggestions", { count: pendingHeartbeatSuggestionCount })}>
{pendingHeartbeatSuggestionCount}
</span>
)}
</button>
);
})}
<button
aria-current={view === "activity" ? "page" : undefined}
className={
view === "activity" ? "nav-item nav-item--active" : "nav-item"
}
onFocus={() => preloadWorkspaceRouteOnIntent("activity")}
onClick={(event) =>
navigateFromSidebar("activity", event.currentTarget)
}
onPointerEnter={() => preloadWorkspaceRouteOnIntent("activity")}
type="button"
>
<ChartColumn aria-hidden="true" size={17} />
<span>{t("navigation.activity")}</span>
</button>
</nav>
<section className="sidebar-conversations" aria-label={t("sidebar.recent")}>
<div className="conversation-list">
{!conversationLoadError &&
filteredConversations.map((conversation) => {
const conversationTasks =
tasksByConversation.get(conversation.id) ?? [];
const tasksExpanded = expandedTaskConversationIds.has(
conversation.id,
);
const conversationTitle = getConversationDisplayTitle(
conversation,
t("conversation.defaultTitle"),
);
const activity = activityByConversationId.get(conversation.id);
const branchSourceTitle = conversation.branch
? (conversationTitles.get(
conversation.branch.sourceConversationId,
) ?? conversation.branch.sourceTitle)
: undefined;
const branchUnavailable =
activeConversationIds.has(conversation.id) ||
queuedConversationIds.has(conversation.id);
const branchDisabledReason = !conversationStoreReady
? t("conversation.branch.storageUnavailable")
: branchingConversationId
? branchingConversationId === conversation.id
? t("conversation.branch.creating")
: t("conversation.branch.anotherCreating")
: branchUnavailable
? t("conversation.branch.unavailable")
: undefined;
const branchDisabledReasonId = `conversation-branch-disabled-${conversation.id}`;
return (
<div className="conversation-entry" key={conversation.id}>
<div className="conversation-row">
{conversationTasks.length > 0 && (
<button
aria-expanded={tasksExpanded}
aria-label={t("conversation.tasks.toggle", {
title: conversationTitle,
count: conversationTasks.length,
})}
className="conversation-task-toggle"
onClick={() =>
setExpandedTaskConversationIds((current) => {
const next = new Set(current);
if (next.has(conversation.id)) {
next.delete(conversation.id);
} else {
next.add(conversation.id);
}
return next;
})
}
type="button"
>
{tasksExpanded ? (
<ChevronDown aria-hidden="true" size={13} />
) : (
<ChevronRight aria-hidden="true" size={13} />
)}
</button>
)}
<button
className={
conversation.id === activeId
? "conversation-item conversation-item--active"
: "conversation-item"
}
type="button"
onClick={() => {
setConversationActionsId("");
setSelectedAssistantTaskId(undefined);
setActiveId(conversation.id);
setUnreadConversationIds((current) => {
if (!current.has(conversation.id)) {
return current;
}
const next = new Set(current);
next.delete(conversation.id);
return next;
});
setView("chat");
if (narrowWindow) {
closeNarrowSidebar();
}
}}
>
<span className="conversation-item__primary">
{conversation.pinned && (
<span className="conversation-pin">
<Pin size={13} role="img" aria-label={t("conversation.actions.pinned")}><title>{t("conversation.actions.pinned")}</title></Pin>
</span>
)}
{branchSourceTitle && (
<ConversationBranchBadge
sourceTitle={branchSourceTitle}
/>
)}
{conversation.remote && (
<b className="conversation-source-badge">
{projectChannelLabels[conversation.remote.channel]}
</b>
)}
<OverflowMarquee
className="conversation-item__title"
text={conversationTitle}
/>
{unreadConversationIds.has(conversation.id) && (
<i
aria-label={t("conversation.unread")}
className="conversation-unread"
title={t("conversation.unreadRemote")}
/>
)}
</span>
<small>
<time
dateTime={new Date(
conversation.updatedAt,
).toISOString()}
title={formatMediumDateTime(
conversation.updatedAt,
locale,
)}
>
{formatConversationListTime(
conversation.updatedAt,
locale,
)}
</time>
</small>
</button>
{activity && (
<span
aria-label={activity.status === "running"
? t("conversation.active")
: tWorkspace(`projectActivity.status.${activity.status}`)}
className={activity.status === "running"
? "conversation-activity-indicator"
: "conversation-activity-indicator conversation-activity-indicator--attention"}
role="status"
title={tWorkspace(`projectActivity.status.${activity.status}`)}
/>
)}
<button
aria-controls={`conversation-actions-${conversation.id}`}
aria-haspopup="menu"
aria-expanded={conversationActionsId === conversation.id}
aria-label={t("conversation.actions.more", {
title: conversationTitle,
})}
className="conversation-more"
onClick={() => {
setRenamingConversationId("");
setConfirmingConversationId("");
setConversationActionsId((current) =>
current === conversation.id ? "" : conversation.id,
);
}}
ref={(element) => {
if (element) {
conversationActionTriggerRefs.current.set(
conversation.id,
element,
);
} else {
conversationActionTriggerRefs.current.delete(
conversation.id,
);
}
}}
type="button"
>
<MoreHorizontal size={14} />
</button>
</div>
{sidebarOpen && conversationActionsId === conversation.id && createPortal(
<div
ref={conversationActionsRef}
role="menu"
onKeyDown={(event) => {
if (event.defaultPrevented) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setConversationActionsId("");
setConfirmingConversationId("");
focusConversationActions(conversation.id);
return;
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled):not([aria-disabled="true"])'));
const index = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[next]?.focus();
}}
aria-label={t("conversation.actions.region", {
title: conversationTitle,
})}
className="conversation-actions"
id={`conversation-actions-${conversation.id}`}
>
<button role="menuitem" type="button" disabled={!conversationStoreReady || Boolean(pinningConversationId)} onClick={() => {
setConversationActionsId("");
focusConversationActions(conversation.id);
void setConversationPinned(conversation);
}}>
{conversation.pinned ? <PinOff size={14} /> : <Pin size={14} />}
{t(conversation.pinned ? "conversation.actions.unpin" : "conversation.actions.pin")}
</button>
{!conversation.remote && (
<>
<button
role="menuitem"
aria-describedby={
branchDisabledReason
? branchDisabledReasonId
: undefined
}
aria-disabled={
branchDisabledReason ? true : undefined
}
onClick={() => {
if (branchDisabledReason) {
notify({
tone: "info",
message: branchDisabledReason,
});
return;
}
setConversationActionsId("");
void branchConversation(conversation);
}}
title={branchDisabledReason}
type="button"
>
<GitFork
aria-hidden="true"
className="conversation-branch-icon"
size={14}
/>
{branchingConversationId === conversation.id
? t("conversation.branch.creating")
: t("conversation.actions.branch")}
</button>
{branchDisabledReason && (
<span
className="sr-only"
id={branchDisabledReasonId}
>
{branchDisabledReason}
</span>
)}
</>
)}
{!conversation.remote && (
<button
role="menuitem"
onClick={() => {
setConversationActionsId("");
setRenamingConversationId(conversation.id);
}}
type="button"
>
<Edit3 size={14} />
{t("conversation.actions.rename")}
</button>
)}
<button
role="menuitem"
onClick={() => {
setConversationActionsId("");
void copyConversation(conversation).finally(() =>
focusConversationActions(conversation.id),
);
}}
type="button"
>
<Copy size={14} />
{t("conversation.actions.copy")}
</button>
<button
role="menuitem"
onClick={() => {
setConversationActionsId("");
exportConversation(conversation);
focusConversationActions(conversation.id);
}}
type="button"
>
<Download size={14} />
{t("conversation.actions.export")}
</button>
{!conversation.remote && (
<DestructiveConfirmActions
triggerRole="menuitem"
cancelAriaLabel={t("conversation.delete.cancelAria", {
title: conversationTitle,
})}
confirmAriaLabel={t(
"conversation.delete.confirmAria",
{
title: conversationTitle,
},
)}
confirmLabel={t("conversation.delete.confirm")}
confirming={
confirmingConversationId === conversation.id
}
disabled={deletingConversationId === conversation.id}
icon={<Trash2 aria-hidden="true" size={14} />}
onCancel={() => setConfirmingConversationId("")}
onConfirm={() =>
void deleteConversation(conversation.id)
}
onRequestConfirm={() =>
setConfirmingConversationId(conversation.id)
}
triggerAriaLabel={t(
"conversation.delete.triggerAria",
{
title: conversationTitle,
},
)}
triggerLabel={t("conversation.delete.trigger")}
/>
)}
</div>, document.body
)}
{!conversation.remote &&
renamingConversationId === conversation.id && (
<form
className="conversation-rename"
onSubmit={(event) => {
event.preventDefault();
const input =
event.currentTarget.elements.namedItem("title");
if (input instanceof HTMLInputElement) {
saveTitle(conversation.id, input.value);
}
}}
>
<input
aria-label={t("conversation.renameAria", {
title: conversationTitle,
})}
autoFocus
defaultValue={conversation.title}
maxLength={80}
name="title"
onKeyDown={(event) => {
if (event.key === "Escape") {
setRenamingConversationId("");
focusConversationActions(conversation.id);
}
}}
pattern=".*\S.*"
required
/>
<button
aria-label={t("conversation.saveName")}
type="submit"
>
<Check size={14} />
</button>
<button
aria-label={t("conversation.cancelRename")}
onClick={() => {
setRenamingConversationId("");
focusConversationActions(conversation.id);
}}
type="button"
>
<X size={14} />
</button>
</form>
)}
{tasksExpanded && conversationTasks.length > 0 && (
<ul
aria-label={t("conversation.tasks.list", {
title: conversationTitle,
})}
className="conversation-task-children"
>
{conversationTasks.slice(0, 3).map((task) => {
const schedule = findTaskSchedule(
task,
assistantSchedules,
);
const statusLabel = tWorkspace(
`task.status.${task.status}`,
);
const metadata = schedule
? [
tWorkspace(
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`,
),
task.status === "completed"
? undefined
: statusLabel,
]
.filter((value) => value !== undefined)
.join(" · ")
: task.status === "completed"
? ""
: statusLabel;
return (
<li key={task.id}>
<button
className={
selectedAssistantTaskId === task.id
? "conversation-task-child conversation-task-child--active"
: "conversation-task-child"
}
onClick={() => {
openAssistantTask(task);
if (narrowWindow) {
closeNarrowSidebar();
}
}}
type="button"
>
<span className="conversation-task-child__title">
{task.title}
</span>
{metadata && (
<small className="conversation-task-child__meta">
{metadata}
</small>
)}
{task.status === "completed" ? (
<span
className="task-status-dot task-status-dot--completed conversation-task-child__completed-status"
title={statusLabel}
>
<Check
aria-hidden="true"
size={7}
strokeWidth={3}
/>
<span className="sr-only">{statusLabel}</span>
</span>
) : (
<span
aria-hidden="true"
className={`task-status-dot task-status-dot--${task.status}`}
/>
)}
</button>
</li>
);
})}
{conversationTasks.length > 3 && (
<li>
<button
className="conversation-task-view-all"
onClick={() => {
setSelectedAssistantTaskId(
conversationTasks[0]?.id,
);
setActiveId(conversation.id);
setView("chat");
if (narrowWindow) {
closeNarrowSidebar();
}
}}
type="button"
>
{t("conversation.tasks.viewAll", {
count: conversationTasks.length,
})}
</button>
</li>
)}
</ul>
)}
</div>
);
})}
{conversationLoadError ? (
<div className="conversation-empty" role="alert">
<strong>{t("conversation.loadFailed")}</strong>
<span>{conversationLoadError}</span>
<button
className="secondary-button"
onClick={() => {
setConversationLoadError(undefined);
setConversationLoadRetry((current) => current + 1);
}}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{t("conversation.retryLoad")}
</button>
</div>
) : filteredConversations.length === 0 ? (
deferredSearchQuery.trim() ? (
<div className="conversation-empty">
<strong>{t("conversation.noMatches")}</strong>
<span>{t("conversation.noMatchesDescription")}</span>
<button
className="secondary-button"
onClick={() => setSearchQuery("")}
type="button"
>
{t("conversation.clearSearch")}
</button>
</div>
) : activeProject?.kind === "channel" ? (
<div className="conversation-empty">
<strong>{t("conversation.noRemote")}</strong>
<span>{t("chat.remote.waiting")}</span>
</div>
) : (
<div className="conversation-empty">
<strong>{t("conversation.empty")}</strong>
<span>{t("conversation.emptyDescription")}</span>
<button
className="secondary-button"
onClick={newConversation}
type="button"
>
<MessageSquarePlus aria-hidden="true" size={13} />
{t("sidebar.newConversation")}
</button>
</div>
)
) : null}
</div>
</section>
<div className="sidebar-footer sidebar-footer--applications">
<button
className="nav-item"
type="button"
ref={applicationMenuTriggerRef}
aria-haspopup="menu"
aria-expanded={applicationMenuOpen}
aria-controls="application-menu"
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
if (!applicationMenuOpen) {
setApplicationMenuOpen(true);
void reloadApplicationSettings();
}
}}
onClick={() => {
setApplicationMenuOpen(!applicationMenuOpen);
if (!applicationMenuOpen) void reloadApplicationSettings();
}}
>
<Grid2X2 size={17} aria-hidden="true" />
<span>{t("applications.menuLabel")}</span>
<ChevronUp size={16} aria-hidden="true" />
</button>
<div className="sidebar-footer__divider" aria-hidden="true" />
<button
className="nav-item"
aria-label={t('navigation.settings')}
title={t('navigation.settings')}
type="button"
onFocus={() => preloadWorkspaceRouteOnIntent("settings")}
onClick={(event) =>
navigateFromSidebar("settings", event.currentTarget)
}
onPointerEnter={() => preloadWorkspaceRouteOnIntent("settings")}
>
<Settings size={16} aria-hidden="true" />
<span>{t('navigation.settings')}</span>
</button>
</div>
</aside>
{sidebarOpen && (
<div
aria-controls="primary-sidebar"
aria-disabled={!canResizePrimarySidebar}
aria-label={t("sidebar.resizeAriaLabel")}
aria-orientation="vertical"
aria-valuemax={primarySidebarWidthLimits.maximum}
aria-valuemin={primarySidebarWidthLimits.minimum}
aria-valuenow={primarySidebarWidth}
aria-valuetext={t("sidebar.resizeValue", {
width: primarySidebarWidth,
})}
className="primary-sidebar-resize-handle"
onKeyDown={resizePrimarySidebarWithKeyboard}
onLostPointerCapture={(event) => {
if (primarySidebarResizePointerIdRef.current === event.pointerId) {
primarySidebarResizePointerIdRef.current = undefined;
setPrimarySidebarWidth(livePrimarySidebarWidthRef.current);
setPrimarySidebarResizing(false);
}
}}
onPointerCancel={finishPrimarySidebarResize}
onPointerDown={(event) => {
if (event.button !== 0 || !canResizePrimarySidebar) {
return;
}
event.preventDefault();
primarySidebarResizePointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
resizePrimarySidebarFromClientX(event.clientX, true);
setPrimarySidebarResizing(true);
}}
onPointerMove={(event) => {
if (primarySidebarResizePointerIdRef.current !== event.pointerId) {
return;
}
if (!canResizePrimarySidebar) {
finishPrimarySidebarResize(event);
return;
}
event.preventDefault();
resizePrimarySidebarFromClientX(event.clientX, false);
}}
onPointerUp={finishPrimarySidebarResize}
role="separator"
tabIndex={canResizePrimarySidebar ? 0 : -1}
/>
)}
{sidebarOpen && (
<button
aria-label={t("sidebar.close")}
className="sidebar-backdrop"
onClick={closeNarrowSidebar}
type="button"
/>
)}
<div className="app-frame">
<header className="topbar">
<button
className="icon-button sidebar-toggle"
type="button"
aria-label={t("sidebar.toggle")}
onClick={() => setSidebarOpen((open) => !open)}
ref={sidebarToggleRef}
>
<PanelLeft aria-hidden="true" size={18} />
</button>
{view === "chat" && (
<>
<div
className="conversation-title"
title={activeConversation?.title}
>
<span className="conversation-title__text">
{activeConversation
? getConversationDisplayTitle(
activeConversation,
t("conversation.defaultTitle"),
)
: activeProject?.kind === "channel"
? t("conversation.remoteTitle")
: t("conversation.defaultTitle")}
</span>
{activeConversation?.branch && (
<ConversationBranchBadge
sourceTitle={
conversationTitles.get(
activeConversation.branch.sourceConversationId,
) ?? activeConversation.branch.sourceTitle
}
/>
)}
{activeConversation?.remote && (
<b className="conversation-source-badge">
{projectChannelLabels[activeConversation.remote.channel]}
</b>
)}
</div>
{magicNotesEnabled && activeConversation && <>
<button type="button" className="icon-button" ref={noteTitleMenuRef} aria-label={t('magicNotes:capture.menu')} title={t('magicNotes:capture.menu')} aria-haspopup="menu" aria-expanded={noteTitleMenuOpen} onClick={() => setNoteTitleMenuOpen(value => !value)}><MoreHorizontal size={16} /></button>
{noteTitleMenuOpen && <AnchoredMenu anchorRef={noteTitleMenuRef} id="note-conversation-menu" label={t('magicNotes:capture.menu')} onClose={() => setNoteTitleMenuOpen(false)}>
<button role="menuitem" tabIndex={-1} type="button" disabled={isRunning || noteCaptureLoading || noteDraft.saving} title={isRunning ? t('magicNotes:capture.wait') : undefined} onClick={() => { setNoteTitleMenuOpen(false); void captureToNote(activeConversation.id); }}>{t(noteCaptureLoading ? 'magicNotes:capture.loading' : 'magicNotes:capture.conversation')}</button>
</AnchoredMenu>}
</>}
<ScopeBadge
scope={
activeProject
? {
kind: "project",
projectName:
activeProjectDisplayName ?? activeProject.name,
}
: {
kind: "unavailable",
explanation: t("notices.projectNotLoaded"),
}
}
/>
</>
)}
<div className="topbar__actions">
<button
aria-controls="assistant-sidebar"
aria-expanded={assistantSidebarOpen}
aria-label={t("topbar.toggleAssistantSidebar")}
className="icon-button assistant-sidebar-toggle"
onClick={() => setAssistantSidebarOpen((current) => !current)}
ref={assistantSidebarToggleRef}
title={t("topbar.toggleAssistantSidebar")}
type="button"
>
{assistantSidebarOpen ? (
<PanelRightClose aria-hidden="true" size={18} />
) : (
<PanelRightOpen aria-hidden="true" size={18} />
)}
</button>
<button
aria-label={
resolvedAppearanceTheme === "dark"
? t("topbar.switchLight")
: t("topbar.switchDark")
}
aria-pressed={resolvedAppearanceTheme === "dark"}
className="icon-button theme-toggle-button"
onClick={toggleAppearanceTheme}
title={
resolvedAppearanceTheme === "dark"
? t("topbar.switchLight")
: t("topbar.switchDark")
}
type="button"
>
{resolvedAppearanceTheme === "dark" ? (
<Sun aria-hidden="true" size={18} />
) : (
<Moon aria-hidden="true" size={18} />
)}
</button>
</div>
<WindowControls onError={handleWindowControlError} />
</header>
<div className="app-content">
<main
aria-hidden={backgroundIsolated ? "true" : undefined}
className="workspace"
inert={backgroundIsolated}
>
{(view === "chat" || cachedWorkspaceViewKeys.has("chat")) && (
<KeepAliveRoute active={view === "chat"} route="chat">
<PageShell variant="reading">
<div className="chat-scroll-region">
{cachedConversations.map((conversation) => conversation.messageSummary ? (
<ConversationHistoryLoader
key={conversation.id}
conversationId={conversation.id}
active={view === "chat" && conversation.id === activeId}
load={ensureConversationHistory}
/>
) : (
<ChatHistoryPane
active={view === "chat" && conversation.id === activeId}
artifactById={assistantArtifactById}
conversationHtmlRenderingEnabled={
conversationHtmlRenderingEnabled
}
conversation={conversation}
key={conversation.id}
locale={locale}
onCopyMessage={copyMessage}
onAddToNote={magicNotesEnabled ? captureToNote : undefined}
noteMessageNavigation={noteMessageNavigation}
onDownloadImage={downloadImage}
onEditImage={editImage}
onOpenImageModelSettings={openImageModelSettings}
onReselectImageSources={reselectImageSources}
onOpenCitationContext={openCitationContext}
onOpenCitationSource={openCitationSource}
onOpenImage={openImageViewer}
onRespondApproval={respondToApproval}
onRespondQuestion={respondToQuestion}
onRetry={retryMessage}
onScrollSnapshotChange={handleChatScrollSnapshotChange}
onSetInput={setQuickActionInput}
onVisibleMessageCountChange={
handleVisibleMessageCountChange
}
quickActions={quickActions}
scrollSnapshot={chatScrollSnapshots[conversation.id]}
taskStrip={conversationTaskStrips.get(conversation.id)}
visibleMessageCount={
visibleMessageCounts[conversation.id] ??
messageRenderBatchSize
}
/>
))}
{activeProject?.kind === "channel" &&
!activeConversation && (
<section className="chat">
<EmptyState
action={
<button
className="secondary-button"
onClick={() => {
setSettingsInitialCategory("channels");
setSettingsInitialChannel(
activeProject.channel,
);
setView("settings");
}}
type="button"
>
{t("chat.remote.openSettings")}
</button>
}
description={t("chat.remote.emptyDescription", {
project: activeProject.name,
})}
icon={<MessageSquare size={28} />}
level="page"
title={t("conversation.noRemote")}
/>
</section>
)}
</div>
<footer className="composer-wrap">
{activeProject?.kind === "channel" ? (
<div className="remote-conversation-notice">
<MessageSquare aria-hidden="true" size={18} />
<div>
<strong>{t("chat.remote.title")}</strong>
<span>
{activeConversation?.remote
? t("chat.remote.continueInClient", {
client:
projectChannelLabels[
activeConversation.remote.channel
],
})
: t("chat.remote.waiting")}
</span>
</div>
</div>
) : (
<>
<ConversationInputQueue
items={activeConversationQueueItems}
onError={handleConversationQueueError}
onInterruptAndRun={interruptConversationQueueItem}
onRemove={removeConversationQueueItem}
onRestore={async (itemId) => {
await attachmentSaveQueue.current;
const restored = await window.goodbuddy.conversationQueue.restoreToDraft(itemId, "");
setInput((current) => [current, restored.prompt].filter(Boolean).join("\n\n"));
}}
running={conversationExecutionRunning}
/>
<div className="composer">
<ImageCapabilityNotice
runtime={runtime}
workMode={effectiveWorkMode}
hasCallableImageModels={runtimeSettings?.modelProfiles.some(profile =>
profile.protocol === "openai-images-generations" && profile.allowConversationInvocation === true
) ?? false}
onOpenModelSettings={openImageModelSettings}
/>
{composerOptionSummary && (
<div className="composer__option-summary" aria-label={t("composer.settings")}>
{composerOptionSummary}
</div>
)}
<PendingDocumentImports key={activeId} conversationId={activeId} refreshing={selectingContextFiles} onBusyChange={updateAttachmentBusy} />
{attachments.some((attachment) => attachment.kind === 'image') && <AttachmentCapabilityNotice key={`${activeId}:${activeRuntimeSelectionKey}`} conversationId={activeId} selection={activeRuntimeSelection} revision={`${attachments.map((item) => item.id).join(',')}:${runtimeSettings?.defaultModelProfileId}:${runtimeStatusKey}`} />}
{(attachments.length > 0 || imageReferences.length > 0 ||
selectingContextFiles) && (
<div
aria-busy={selectingContextFiles}
aria-describedby={
contextError
? composerContextErrorId
: undefined
}
aria-invalid={contextError ? true : undefined}
className="context-list"
>
{imageReferences.map(artifact => <div className="context-chip" key={artifact.id}>
{artifact.content && <img alt="" className="context-chip__thumbnail" src={artifact.content} />}
<span><strong>{artifact.title}</strong><small>{t('chat.images.edit')}</small></span>
<button type="button" aria-label={t('composer.removeAttachment', { name: artifact.title })}
onClick={() => setImageReferencesByConversation(current => ({ ...current,
[activeId]: (current[activeId] ?? []).filter(item => item.id !== artifact.id),
}))}>×</button>
</div>)}
{attachments.map((attachment) => (
<div
className="context-chip"
key={attachment.attachmentId ?? attachment.id}
title={attachment.preview}
>
{attachment.kind === "image" &&
(attachment.thumbnailUrl || attachment.contentUrl) ? (
<button
type="button"
className="message-image-button"
aria-label={t('chat.images.viewNamed', { title: attachment.name })}
onClick={(event) => openImageViewer({ src: attachment.contentUrl ?? attachment.thumbnailUrl!, title: attachment.name }, event.currentTarget)}
>
<img
alt=""
className="context-chip__thumbnail"
src={attachment.thumbnailUrl ?? attachment.contentUrl}
/>
</button>
) : (
<FileText size={14} />
)}
<span>
<strong title={attachment.name}>{attachment.name}</strong>
<span className="attachment-metadata">
<AttachmentStatus attachment={attachment} />
<small>
{formatAttachmentSize(attachment.size)}
</small>
</span>
<span className="attachment-actions">
{attachment.resultId && <AttachmentResultButton resultId={attachment.resultId} name={attachment.name} conversationId={activeId} />}
<AttachmentActions attachment={attachment} conversationId={activeId} onBusyChange={updateAttachmentBusy} />
</span>
</span>
<button
className="icon-button attachment-action"
title={t("composer.removeAttachment", { name: attachment.name })}
data-tooltip={t("composer.removeAttachment", { name: attachment.name })}
aria-label={t("composer.removeAttachment", {
name: attachment.name,
})}
onClick={() => {
void window.goodbuddy.context.remove(
attachment.id,
);
updateAttachments((current) =>
current.filter(
(item) => item.id !== attachment.id,
),
);
}}
type="button"
>
<X size={16} aria-hidden="true" />
</button>
</div>
))}
{selectingContextFiles && (
<div
aria-live="polite"
className="context-chip context-chip--processing"
role="status"
>
<LoaderCircle
aria-hidden="true"
className="context-chip__spinner"
size={16}
/>
<span>
<strong>
{fileSelectionProgress
? t(
`composer.attachmentProgress.${fileSelectionProgress.phase}`,
{
name: fileSelectionProgress.fileName,
},
)
: t(
"composer.attachmentProgress.selecting",
)}
</strong>
<small>
{fileSelectionProgress
? t(
"composer.attachmentProgress.fileCount",
{
current:
fileSelectionProgress.fileNumber,
total:
fileSelectionProgress.fileCount,
},
)
: t(
"composer.attachmentProgress.waiting",
)}
</small>
</span>
<button type="button" className="icon-button attachment-action" aria-label="取消文件导入" title="取消文件导入" data-tooltip="取消文件导入" onClick={() => void window.goodbuddy.context.cancelImport(fileSelectionProgress?.operationId)}><X size={16} aria-hidden="true" /></button>
<progress
aria-label={t(
"composer.attachmentProgress.progressLabel",
)}
/>
</div>
)}
</div>
)}
<div className="composer__input">
<textarea
aria-describedby={
contextError
? composerContextErrorId
: undefined
}
aria-invalid={contextError ? true : undefined}
aria-label={t("composer.inputLabel")}
placeholder={`${
runtime?.capability === "image-generation"
? t("composer.imagePlaceholder")
: t("composer.placeholder")
}\n${composerKeyboardHint}\n${composerConversationHint}`}
ref={inputRef}
rows={3}
title={`${composerKeyboardHint}\n${composerConversationHint}`}
value={input}
onChange={(event) => setInput(event.target.value)}
onPaste={(event) => {
const files = Array.from(event.clipboardData.files);
if (files.length > 0) {
event.preventDefault();
if (selectingContextFilesRef.current) {
setContextError(t("composer.attachmentProgress.waitBeforeSending"));
return;
}
if (files.length > maximumAttachmentsPerMessage) {
setContextError(t("composer.errors.attachmentLimit"));
return;
}
try {
const paths = files.map((file) => window.goodbuddy.context.getFilePath(file));
if (paths.some(Boolean)) {
if (paths.some((path) => !path)) {
setContextError(t("composer.errors.pasteFilePath"));
return;
}
void selectContextFiles(paths);
return;
}
if (files.some((file) => !file.type.startsWith("image/"))) {
setContextError(t("composer.errors.pasteFilePath"));
return;
}
} catch (reason) {
setContextError(reason instanceof Error ? reason.message : t("composer.errors.addContext"));
return;
}
}
const image = files.find((file) => file.type.startsWith("image/"));
if (!image) {
return;
}
const mimeType =
image?.type === "image/jpeg" ||
image?.type === "image/png" ||
image?.type === "image/webp"
? image.type
: undefined;
event.preventDefault();
if (!image || !mimeType) {
setContextError(
t("composer.errors.pasteImageType"),
);
return;
}
void addContext(async () => {
if (image.size > maximumPastedImageBytes) {
throw new Error(
t("composer.errors.pasteImageSize"),
);
}
return window.goodbuddy.context.addPastedImage(
{
data: new Uint8Array(
await image.arrayBuffer(),
),
mimeType,
},
);
});
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void submit();
}
}}
/>
</div>
<div className="composer__toolbar">
<div className="composer__controls">
<div
aria-label={t("composer.addContent")}
className="composer__tool-group"
role="group"
>
<button
aria-label={t("composer.addAttachment")}
aria-describedby={
contextError
? composerContextErrorId
: undefined
}
aria-invalid={contextError ? true : undefined}
disabled={selectingContextFiles || attachmentOperations > 0}
ref={attachmentButtonRef}
onClick={() => void selectContextFiles()}
title={t("composer.addAttachment")}
type="button"
>
<Paperclip aria-hidden="true" size={18} />
</button>
<button
aria-label={
voiceRecording
? t("composer.voice.stopRecording")
: voiceListening
? t("composer.voice.cancel")
: t("composer.voice.input")
}
aria-pressed={voiceRecording}
className={
voiceRecording
? "composer__voice-button composer__voice-button--recording"
: voiceListening
? "composer__voice-button composer__voice-button--processing"
: "composer__voice-button"
}
data-state={
voiceRecording
? "recording"
: voiceListening
? "processing"
: "idle"
}
onClick={toggleVoiceInput}
title={
voiceRecording
? t("composer.voice.stopAndRecognize")
: voiceListening
? t("composer.voice.cancel")
: t("composer.voice.description")
}
type="button"
>
<Mic aria-hidden="true" size={18} />
</button>
</div>
<button
aria-controls="composer-options"
aria-expanded={composerOptionsOpen}
aria-haspopup="dialog"
aria-label={t("composer.options")}
className="composer__options-trigger"
onClick={() => {
setComposerOptionsOpen(!composerOptionsOpen);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}}
ref={composerOptionsTriggerRef}
title={t("composer.settings")}
type="button"
>
<SlidersHorizontal aria-hidden="true" size={18} />
</button>
<div
aria-label={t("composer.settings")}
className="composer__options"
id="composer-options"
hidden={!composerOptionsOpen}
ref={composerOptionsRef}
role="dialog"
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
if (knowledgeScopeOpen) {
setKnowledgeScopeOpen(false);
knowledgeScopeTriggerRef.current?.focus();
} else {
setComposerOptionsOpen(false);
setComposerMenuOpen(undefined);
composerOptionsTriggerRef.current?.focus();
}
}}
>
<strong>{t("composer.settings")}</strong>
{knowledgeSnapshot.libraries.length > 0 && (
<div
className="knowledge-scope"
onBlurCapture={(event) => {
if (
event.relatedTarget instanceof Node &&
!event.currentTarget.contains(
event.relatedTarget,
)
) {
setKnowledgeScopeOpen(false);
}
}}
>
<button
aria-controls="knowledge-scope-popover"
aria-haspopup="dialog"
aria-label={t("composer.knowledge.select", {
count: enabledKnowledgeLibraryIds.length,
})}
aria-expanded={knowledgeScopeOpen}
onClick={() => {
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(
(current) => !current,
);
}}
ref={knowledgeScopeTriggerRef}
title={t("composer.knowledge.title")}
type="button"
>
<Library aria-hidden="true" size={16} />
<span>
{t("navigation.knowledge")}
<strong>
{enabledKnowledgeLibraryIds.length}
</strong>
</span>
<ChevronDown
aria-hidden="true"
className="knowledge-scope__chevron"
size={14}
/>
</button>
{knowledgeScopeOpen && (
<div
aria-label={t("composer.knowledge.scope")}
className="knowledge-scope__popover"
id="knowledge-scope-popover"
ref={knowledgeScopePopoverRef}
role="dialog"
>
<strong>
{t("composer.knowledge.scope")}
</strong>
{knowledgeSnapshot.libraries.map(
(library) => {
const instance = externalInstances.find(item => item.id === library.external?.instanceId);
const externalStatus = !instance ? 'temporarily-unavailable' : !instance.enabled ? 'instance-disabled' : instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed' ? 'credential-error' : ['failed', 'unreachable'].includes(instance.probeStatus) ? 'temporarily-unavailable' : 'ready';
return (
<label key={library.id}>
<input
disabled={!!library.external && externalStatus !== 'ready'}
checked={enabledKnowledgeLibraryIds.includes(
library.id,
)}
onChange={(event) =>
setEnabledKnowledgeLibraryIds(
(current) =>
event.target.checked
? [
...new Set([
...current,
library.id,
]),
]
: current.filter(
(id) =>
id !== library.id,
),
)
}
type="checkbox"
/>
<span>{library.name}</span>
<small>
{library.external ? `${({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[library.external.provider]} · ${instance?.name ?? library.external.instanceId} · ${t(`external.states.${externalStatus}`, { ns: 'knowledge' })}` : t(
"composer.knowledge.documents",
{
count: library.documentCount,
},
)}
</small>
</label>
)},
)}
<div className="knowledge-scope__retrieval-mode">
<strong>
{t("composer.knowledge.modeLabel")}
</strong>
<SegmentedControl
ariaLabel={t(
"composer.knowledge.modeLabel",
)}
onChange={(mode) =>
setConversations((current) =>
current.map((conversation) =>
conversation.id === activeId
? {
...conversation,
knowledgeRetrievalMode:
mode,
updatedAt: Date.now(),
}
: conversation,
),
)
}
options={[
{
value: "auto",
label: t(
"composer.knowledge.auto",
),
},
{
value: "always",
label: t(
"composer.knowledge.always",
),
},
]}
value={
activeConversation?.knowledgeRetrievalMode ??
"auto"
}
/>
<small>
{activeConversation?.knowledgeRetrievalMode ===
"always"
? t(
"composer.knowledge.alwaysDescription",
)
: t(
"composer.knowledge.autoDescription",
)}
</small>
</div>
</div>
)}
</div>
)}
<div className="composer__configuration">
<ComposerMenuSelect
ariaLabel={t("composer.expertLabel")}
className="composer-picker--expert"
disabled={
isRunning ||
runtime?.capability === "image-generation"
}
icon={<Bot aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "expert"}
onChange={setSelectedExpertId}
onOpenChange={setExpertMenuOpen}
options={assistantExpertOptions}
value={selectedExpertId}
/>
</div>
{runtimeControlsProvider && (
<div aria-label={runtimeControlsLabel} className="composer__runtime-toolbar" role="group">
<strong className="composer__runtime-toolbar-label">{runtimeControlsLabel}</strong>
<div className="composer__runtime-controls">
{runtimeAgentControlAvailable && (
<ComposerMenuSelect
ariaLabel={t("composer.runtimeControls.agentLabel")}
className="composer-picker--runtime"
disabled={isRunning}
icon={<TerminalSquare aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "runtime-agent"}
onChange={setSelectedRuntimeAgent}
onOpenChange={setRuntimeAgentMenuOpen}
options={runtimeAgentOptions}
value={selectedRuntimeAgent}
/>
)}
{runtimePresetControlAvailable && (
<ComposerMenuSelect
ariaLabel={t("composer.runtimeControls.presetLabel")}
className="composer-picker--runtime"
disabled={isRunning}
icon={<TerminalSquare aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "runtime-preset"}
onChange={setSelectedContinuePreset}
onOpenChange={setRuntimePresetMenuOpen}
options={runtimePresetOptions}
value={selectedContinuePreset}
/>
)}
{runtimeActionControlAvailable && (
<ComposerMenuSelect
ariaLabel={t("composer.runtimeControls.actionLabel")}
className="composer-picker--runtime-action"
disabled={isRunning}
icon={<TerminalSquare aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "runtime-action"}
onChange={selectRuntimeAction}
onOpenChange={setRuntimeActionMenuOpen}
options={runtimeActionOptions}
value={runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.value ?? ""}
/>
)}
</div>
</div>
)}
</div>
<div className="composer__configuration" role="group" aria-label={t("composer.settings")}>
<div className="runtime-picker">
<button
aria-expanded={runtimeMenuOpen}
aria-haspopup="menu"
className="model-button"
disabled={isRunning || runtimeSwitching}
onClick={() => {
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(!runtimeMenuOpen);
}}
onKeyDown={(event) => {
if (
!runtimeMenuOpen &&
(event.key === "ArrowDown" ||
event.key === "Enter" ||
event.key === " ")
) {
event.preventDefault();
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(true);
}
}}
ref={runtimeMenuButtonRef}
title={t("runtime.pickerTitle", {
label: activeRuntimeModelDetail
? `${activeRuntimeLabel} (${activeRuntimeModelDetail})`
: activeRuntimeLabel,
})}
type="button"
>
<Sparkles aria-hidden="true" size={15} />
<span className="model-button__label">
{runtimeSwitching
? t("runtime.switching")
: activeRuntimeLabel}
</span>
{!runtimeSwitching && activeRuntimeModelDetail && (
<span className="sr-only">{` (${activeRuntimeModelDetail})`}</span>
)}
{runtime?.capability ===
"image-generation" && (
<span className="runtime-capability-badge">
{t("runtime.imageGeneration")}
</span>
)}
<ChevronDown aria-hidden="true" size={14} />
</button>
{runtimeMenuOpen && runtimeSettings && (
<RuntimeModelPicker
conversationLayer={activeConversation?.runtimeSelection}
onClose={() => {
setRuntimeMenuOpen(false);
runtimeMenuButtonRef.current?.focus();
}}
onManage={() => {
setRuntimeMenuOpen(false);
setSettingsInitialCategory("model");
setView("settings");
}}
onSelect={(layer) => void switchRuntime(layer)}
projectLayer={activeProject?.runtimeSelection}
ref={runtimeMenuRef}
remote={activeProjectUsesManagedSsh}
runtimeSettings={runtimeSettings}
/>
)}
</div>
<ComposerMenuSelect
ariaLabel={t("composer.modeLabel")}
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
disabled={isRunning}
icon={
effectiveWorkMode === "execute" ? (
<ShieldCheck aria-hidden="true" size={15} />
) : (
<CircleHelp aria-hidden="true" size={15} />
)
}
menuOpen={composerMenuOpen === "mode"}
onChange={setWorkMode}
onOpenChange={setModeMenuOpen}
options={workModeOptions}
triggerLabel={effectiveWorkMode === "execute" ? "Execute" : "Ask"}
value={effectiveWorkMode}
/>
</div>
</div>
<div className="composer__submit-actions">
{isRunning && (
<button
className="send-button send-button--stop"
type="button"
aria-label={t("composer.stop")}
onClick={() => void stop()}
title={t("composer.stop")}
>
<Square
aria-hidden="true"
fill="currentColor"
size={15}
/>
</button>
)}
<span className="sr-only" id={runtimeDetailId}>
{runtimeDetail}
</span>
<button
aria-describedby={
runtimeState !== "ready"
? runtimeDetailId
: undefined
}
className="send-button"
type="button"
aria-label={
conversationExecutionRunning
? t("composer.queueMessage")
: t("composer.send")
}
disabled={
(!input.trim() &&
!(
activeRuntimeSelection?.provider ===
"opencode" &&
runtimeNativeSnapshot?.commands.some(
(command) =>
command.id === selectedRuntimeCommand,
)
)) ||
selectingContextFiles || attachmentOperations > 0 ||
activeProjectRecoveryBlocked ||
!runtime?.available ||
runtimeSwitching ||
runtimeStatusKey !== activeRuntimeSelectionKey
}
onClick={() => void submit()}
title={
conversationExecutionRunning
? t("composer.queueMessageTitle")
: t("composer.sendTitle")
}
>
<Send aria-hidden="true" size={17} />
</button>
</div>
</div>
</div>
<div
className="composer-meta"
>
<div className="composer-meta__actions">
{nativeClientAvailable && <RuntimeNativeClientActions
browser={activeRuntimeSelection?.provider === "deepseek-harness"}
contextKey={nativeClientContextKey}
conversationId={activeConversation?.id}
prepareConversation={prepareNativeClientConversation}
notify={notify}
onTerminal={(terminal) => {
const focus = activeProjectIdRef.current === activeProjectId &&
activeConversationIdRef.current === activeId && viewRef.current === "chat";
setNativeTerminals(current => [...current, { terminal, focus }]);
if (focus) setAssistantSidebarOpen(true);
}}
/>}
{runtimeContextCompactAvailable && (
<button
className="composer-context-compact"
disabled={runtimeContextCompacting || isRunning}
onClick={() => void compactRuntimeContext()}
title={runtimeNativeSnapshot?.context.detail}
type="button"
>
{runtimeContextCompacting ? (
<LoaderCircle
aria-hidden="true"
className="context-chip__spinner"
size={13}
/>
) : (
<RefreshCw aria-hidden="true" size={13} />
)}
{runtimeContextCompacting
? t("composer.context.compacting")
: t("composer.context.compact")}
</button>
)}
</div>
{composerContextMetrics && (
<div
className={`composer-context-meter${
composerContextMetrics.percentage !==
undefined &&
composerContextMetrics.percentage >= 90
? " composer-context-meter--warning"
: ""
}`}
title={
composerContextMetrics.compressionEnabled
? t("composer.context.compressionTrigger", {
tokens: formatCompactTokens(
composerContextMetrics.effectiveTriggerTokens,
),
})
: undefined
}
>
<span className="composer-context-meter__summary">
{composerContextMetrics.denominatorTokens ===
undefined
? t(
composerContextMetrics.basis ===
"conversation"
? composerContextMetrics.compressionEnabled
? "composer.context.conversationThresholdUsage"
: "composer.context.conversationTokenCount"
: composerContextMetrics.compressionEnabled
? composerContextMetrics.source ===
"provider"
? "composer.context.confirmedThresholdUsage"
: "composer.context.thresholdUsage"
: composerContextMetrics.source ===
"provider"
? "composer.context.confirmedTokenCount"
: "composer.context.tokenCount",
{
used: formatCompactTokens(
composerContextMetrics.contextTokens,
),
total: formatCompactTokens(
composerContextMetrics.effectiveTriggerTokens,
),
},
)
: t(
composerContextMetrics.basis ===
"conversation"
? "composer.context.conversationWindowUsage"
: composerContextMetrics.source ===
"provider"
? "composer.context.confirmedWindowUsage"
: "composer.context.windowUsage",
{
used: formatCompactTokens(
composerContextMetrics.contextTokens,
),
total: formatCompactTokens(
composerContextMetrics.denominatorTokens,
),
percentage:
composerContextMetrics.percentage ??
0,
},
)}
</span>
{composerContextMetrics.denominatorTokens !==
undefined && (
<div
aria-label={t(
"composer.context.progressLabel",
)}
aria-valuemax={
composerContextMetrics.denominatorTokens
}
aria-valuemin={0}
aria-valuenow={Math.min(
composerContextMetrics.contextTokens,
composerContextMetrics.denominatorTokens,
)}
className="composer-context-meter__track"
role="progressbar"
>
<span
className="composer-context-meter__fill"
style={{
width: `${Math.min(
100,
composerContextMetrics.percentage ?? 0,
)}%`,
}}
/>
{composerContextMetrics.contextWindowTokens !==
undefined &&
composerContextMetrics.compressionEnabled && (
<span
aria-hidden="true"
className="composer-context-meter__trigger"
style={{
left: `${Math.min(
100,
Math.round(
(composerContextMetrics.effectiveTriggerTokens /
composerContextMetrics.contextWindowTokens) *
100,
),
)}%`,
}}
/>
)}
</div>
)}
</div>
)}
{contextError && (
<span
aria-label={contextError}
className="composer-meta__error"
id={composerContextErrorId}
role="alert"
>
{contextError}
</span>
)}
</div>
</>
)}
</footer>
</PageShell>
</KeepAliveRoute>
)}
{(view === "magic-notes" ||
cachedWorkspaceViewKeys.has("magic-notes")) && (
<KeepAliveRoute
active={view === "magic-notes"}
route="magic-notes"
>
<ApplicationAvailability enabled={magicNotesEnabled}>
<PageShell variant="master-detail">
<RouteErrorBoundary
key="magic-notes"
fallback={
<RouteLoadError
message={t("route.loadFailed")}
reloadLabel={t("route.reload")}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t("route.loading")} />
}
>
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} onBeforeLeave={registerNotesLeaveRequester} navigation={notesNavigation} onOpenSource={openNoteSource} />
</Suspense>
</RouteErrorBoundary>
</PageShell>
</ApplicationAvailability>
</KeepAliveRoute>
)}
{(view === "knowledge" ||
cachedWorkspaceViewKeys.has("knowledge")) && (
<KeepAliveRoute active={view === "knowledge"} route="knowledge">
<PageShell variant="master-detail">
<RouteErrorBoundary
key="knowledge"
fallback={
<RouteLoadError
message={t("route.loadFailed")}
reloadLabel={t("route.reload")}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t("route.loading")} />
}
>
<KnowledgeWorkspace
externalInstances={externalInstances}
notify={notify}
onExternalChanged={async (snapshot, createdId) => {
await refreshKnowledge(createdId ?? snapshot?.selectedLibraryId ?? knowledgeSnapshot.selectedLibraryId);
if (createdId) setEnabledKnowledgeLibraryIds(current => [...new Set([...current, createdId])]);
}}
documents={knowledgeSnapshot.documents}
evidence={knowledgeSnapshot.evidence}
graphNodes={knowledgeSnapshot.graphNodes}
graphRelations={knowledgeSnapshot.graphRelations}
libraries={knowledgeSnapshot.libraries}
loadError={knowledgeLoadError}
loading={knowledgeLoading}
onCreateLibrary={createKnowledgeLibrary}
onCreateEntity={async (input) => {
const libraryId = knowledgeSnapshot.selectedLibraryId;
if (!libraryId) {
throw new Error(t("notices.selectKnowledgeBase"));
}
await runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.createEntity(
libraryId,
input,
),
);
}}
onCreateRelation={async (input) => {
const libraryId = knowledgeSnapshot.selectedLibraryId;
if (!libraryId) {
throw new Error(t("notices.selectKnowledgeBase"));
}
await runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.createRelation(
libraryId,
input,
),
);
}}
onDeleteEntity={(entityId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.deleteEntity(entityId),
)
}
onDeleteLibrary={deleteKnowledgeLibrary}
onReextractGraph={async (libraryId) => {
await runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.reextractGraph(
libraryId,
),
);
notify({
tone: "success",
message: t("notices.knowledgeGraphRebuilt"),
dedupeKey: `knowledge-graph:${libraryId}`,
});
}}
onUpdateLibrary={async (libraryId, update) => {
await runKnowledgeSourceAction(async () => {
await window.goodbuddy.knowledge.updateLibrary(
libraryId,
update,
);
});
notify({
tone: "success",
message: t("notices.knowledgeSettingsUpdated"),
dedupeKey: `knowledge-library:${libraryId}`,
});
}}
onDeleteRelation={(relationId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.deleteRelation(
relationId,
),
)
}
onImportDirectory={(
libraryId,
files,
graphStrategy,
) => {
void files;
return runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.selectDirectory(
libraryId,
graphStrategy,
),
);
}}
onImportFiles={(libraryId, files, graphStrategy) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.importDroppedFiles(
libraryId,
files,
graphStrategy,
),
)
}
onImportUrl={(libraryId, url, graphStrategy) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.importUrl(
libraryId,
url,
graphStrategy,
),
)
}
onOpenDocumentSource={(libraryId, documentId) =>
window.goodbuddy.knowledge.openDocumentSource({
knowledgeBaseId: libraryId,
documentId,
})
}
onOpenModelSettings={() => {
setSettingsInitialCategory("model");
setView("settings");
}}
onMergeEntities={(sourceId, targetId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.mergeEntities(
sourceId,
targetId,
),
)
}
onMoveNode={(nodeId, position) => {
setKnowledgeSnapshot((current) => ({
...current,
graphNodes: current.graphNodes.map((node) =>
node.id === nodeId
? { ...node, ...position }
: node,
),
}));
void window.goodbuddy.knowledge
.moveEntity(nodeId, position)
.catch(() => void refreshSelectedKnowledge());
}}
onOpenEvidence={(evidence) =>
notify({
tone: "info",
message: t("notices.evidenceExcerpt", {
source: `${evidence.documentName}${
evidence.location
? ` · ${evidence.location}`
: ""
}`,
excerpt: evidence.excerpt,
}).slice(0, 500),
})
}
onUseInChat={(libraryId) => {
setEnabledKnowledgeLibraryIds((current) =>
current.includes(libraryId)
? current
: [...current, libraryId],
);
setView("chat");
requestAnimationFrame(() =>
inputRef.current?.focus(),
);
}}
onPauseSource={(sourceId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.pauseSource(sourceId),
)
}
onRemoveSource={(sourceId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.removeSource(sourceId),
)
}
onRetrieve={(libraryId, query, settings) =>
window.goodbuddy.knowledge.retrieve({
knowledgeBaseId: libraryId,
query,
settings,
})
}
onUpdateKnowledgeSettings={async (
libraryId,
settings,
) => {
await runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.updateSettings({
knowledgeBaseId: libraryId,
...settings,
}),
);
notify({
tone: "success",
message: t("notices.knowledgeSettingsUpdated"),
dedupeKey: `knowledge-retrieval-settings:${libraryId}`,
});
}}
onListChunks={({
libraryId,
documentId,
page,
pageSize,
search,
}) =>
window.goodbuddy.knowledge.listChunks({
knowledgeBaseId: libraryId,
documentId,
page,
pageSize,
search,
})
}
onUpdateChunk={(input) =>
window.goodbuddy.knowledge.updateChunk(input)
}
onDeleteChunk={(input) =>
window.goodbuddy.knowledge.deleteChunk(input)
}
onRebuildDocument={(libraryId, documentId) =>
runKnowledgeSourceAction(async () => {
await window.goodbuddy.knowledge.rebuildDocument({
knowledgeBaseId: libraryId,
documentId,
});
})
}
onRebuildLibrary={(libraryId) =>
runKnowledgeSourceAction(async () => {
const result =
await window.goodbuddy.knowledge.rebuildLibrary({
knowledgeBaseId: libraryId,
});
if (result.failed > 0) {
throw new Error(
t("notices.knowledgeRebuildPartial", {
rebuilt: result.rebuilt,
failed: result.failed,
}),
);
}
notify({
tone: "success",
message: t("notices.knowledgeRebuildCompleted", {
count: result.rebuilt,
}),
dedupeKey: `knowledge-rebuild:${libraryId}`,
});
})
}
onCancelRebuild={async (libraryId) => {
const cancelled =
await window.goodbuddy.knowledge.cancelRebuild(
libraryId,
);
if (!cancelled) {
throw new Error(
t("notices.knowledgeRebuildNotRunning"),
);
}
}}
onGetEmbeddingIndex={(libraryId) =>
window.goodbuddy.knowledge.getEmbeddingIndex(
libraryId,
)
}
onRebuildEmbeddingIndex={(libraryId) =>
window.goodbuddy.knowledge.rebuildEmbeddingIndex(
libraryId,
)
}
onCancelTask={async (taskId) => {
const cancelled =
await window.goodbuddy.knowledge.cancelTask(taskId);
if (!cancelled) {
throw new Error(
t("notices.knowledgeTaskNotRunning"),
);
}
await refreshSelectedKnowledge();
}}
onOpenReferenceSource={(input) =>
window.goodbuddy.knowledge.openReferenceSource(input)
}
onRetrySource={(sourceId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.retrySource(sourceId),
)
}
onRetryTask={(taskId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.retryTask(taskId),
)
}
onRetryLoad={retryKnowledgeLoad}
onSelectLibrary={(libraryId) => {
void refreshKnowledge(libraryId).catch(() => {
// KnowledgeWorkspace renders the recoverable load error.
});
}}
onSyncSource={(sourceId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.syncSource(sourceId),
)
}
onUpdateEntity={(entityId, update) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.updateEntity(
entityId,
update,
),
)
}
onUpdateRelation={(relationId, input) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.updateRelation(
relationId,
input,
),
)
}
selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
sources={knowledgeSnapshot.sources}
tasks={knowledgeSnapshot.tasks}
/>
</Suspense>
</RouteErrorBoundary>
</PageShell>
</KeepAliveRoute>
)}
{(view === "heartbeat" ||
cachedWorkspaceViewKeys.has("heartbeat")) && (
<KeepAliveRoute active={view === "heartbeat"} route="heartbeat">
<PageShell variant="supervisor">
<RouteErrorBoundary
key="heartbeat"
fallback={
<RouteLoadError
message={t("route.loadFailed")}
reloadLabel={t("route.reload")}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t("route.loading")} />
}
>
{isApplicationEnabled(applicationSettings, 'heartbeat') ? <HeartbeatCenter
applicationSettings={applicationSettings}
applicationSettingsPending={applicationSettingsPending}
applicationSettingsLocked={applicationSettingsUnconfirmed}
applicationSettingsError={applicationSettingsError}
onUpdateApplicationSettings={updateApplicationSettings}
onRetryApplicationSettings={() => void reloadApplicationSettings()}
active={view === 'heartbeat'}
graphNavigation={supervisionGraphNavigation}
onOpenConversation={openActivityConversation}
configs={assistantHeartbeats}
entries={heartbeatEntries}
loadError={heartbeatLoadError}
loading={heartbeatLoading}
memories={heartbeatMemories}
onCreate={createHeartbeat}
onRefresh={retryHeartbeatLoad}
onRetryLoad={retryHeartbeatLoad}
onRemove={removeHeartbeat}
onRunNow={runHeartbeat}
onSetMemoryStatus={setMemoryStatus}
onSetPaused={setHeartbeatPaused}
onSetTaskStatus={setHeartbeatTaskStatus}
onUpdate={updateHeartbeat}
onUseFollowUpTask={useHeartbeatTask}
projects={projects}
runs={heartbeatRuns}
tasks={assistantTasks}
/> : <p role="status">{t('applications.disabledPage')}</p>}
</Suspense>
</RouteErrorBoundary>
</PageShell>
</KeepAliveRoute>
)}
{localInferenceOpen && (
<LocalInferencePage
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
onClose={() => setLocalInferenceOpen(false)}
restoreFocus={() => applicationMenuTriggerRef.current?.closest('[aria-hidden="true"]')
? document.querySelector<HTMLElement>('.sidebar-toggle')
: applicationMenuTriggerRef.current}
/>
)}
{applicationCenterOpen && (
<ApplicationCenter
settings={applicationSettings}
pending={applicationSettingsPending}
locked={applicationSettingsUnconfirmed}
error={applicationSettingsError}
onClose={() => setApplicationCenterOpen(false)}
onOpen={(id) => {
if (!isApplicationEnabled(applicationSettings, id)) return;
setApplicationCenterOpen(false);
setView(id);
if (window.innerWidth < 900) setSidebarOpen(false);
}}
onUpdate={updateApplicationSettings}
onRetry={() => void reloadApplicationSettings()}
/>
)}
{applicationMenuOpen && (
<ApplicationMenu
anchorRef={applicationMenuTriggerRef}
settings={applicationSettings}
pending={applicationSettingsPending || (!applicationSettings && !applicationSettingsError)}
error={applicationSettingsError}
onClose={() => setApplicationMenuOpen(false)}
onOpen={(id) => {
setView(id);
if (window.innerWidth < 900) setSidebarOpen(false);
}}
onManage={() => {
setApplicationCenterOpen(true);
}}
onRetry={() => void reloadApplicationSettings()}
/>
)}
{settingsOpen && (
<RouteErrorBoundary
key="settings"
fallback={
<RouteLoadError
message={t("route.loadFailed")}
reloadLabel={t("route.reload")}
/>
}
>
<Suspense fallback={null}>
<SettingsPanel
appearanceTheme={appearanceTheme}
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? false}
applicationSettingsPending={applicationSettingsPending}
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
applicationSettingsError={applicationSettingsError}
onTransparentFrostedEffectEnabledChange={(enabled) => updateApplicationSettings({ transparentFrostedEffectEnabled: enabled })}
onRetryApplicationSettings={() => void reloadApplicationSettings()}
brandingFallbackLogo={
resolvedAppearanceTheme === "dark"
? goodbuddyDarkIcon
: goodbuddyLightIcon
}
brandingPreferences={brandingPreferences}
initialCategory={settingsInitialCategory}
initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onConversationHtmlRenderingEnabledChange={
setConversationHtmlRenderingEnabled
}
onAppearanceThemeChange={setAppearanceTheme}
onBrandingPreferencesChange={(preferences) => {
if (!saveBrandingPreferences(preferences)) {
return false;
}
setBrandingPreferences(preferences);
notify({
tone: "success",
message: t("notices.brandingSaved"),
dedupeKey: "branding-saved",
});
return true;
}}
onClearLocalData={clearLocalData}
onClose={() => {
commitView(viewRef.current);
}}
onExpertsChanged={(experts) => {
setAssistantExperts(experts);
if (
(selectedExpertId === "team" && experts.length < 2) ||
(selectedExpertId &&
selectedExpertId !== "team" &&
!experts.some(
(expert) => expert.id === selectedExpertId,
))
) {
setSelectedExpertId("");
}
}}
onRemoteProjectsEnabledChange={
handleRemoteProjectsEnabledChange
}
onNotify={notify}
onLeaveRequestReady={registerSettingsLeaveRequester}
onProjectsDeleted={removeProjectsFromUi}
onSaved={(settings) => {
setRuntimeSettings(settings);
}}
onShortcutSettingsChanged={handleShortcutSettingsChanged}
onUpdateProject={updateProject}
open={settingsOpen}
projects={projects}
/>
</Suspense>
</RouteErrorBoundary>
)}
{(view === "activity" ||
cachedWorkspaceViewKeys.has("activity")) && (
<KeepAliveRoute active={view === "activity"} route="activity">
<PageShell variant="dashboard">
<RouteErrorBoundary
key="activity"
fallback={
<RouteLoadError
message={t("route.loadFailed")}
reloadLabel={t("route.reload")}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t("route.loading")} />
}
>
<ActivityPanel
onClear={clearActivity}
onOpenConversation={openActivityConversation}
onRefreshTokenUsage={manualRefreshTokenUsage}
projects={projects}
records={activityRecords}
tokenUsage={tokenUsage}
/>
</Suspense>
</RouteErrorBoundary>
</PageShell>
</KeepAliveRoute>
)}
</main>
<AppNotificationViewport
dispatch={notify}
notifications={notifications}
/>
{releaseNotes && (
<ReleaseNotesDialog
locale={locale}
onAcknowledge={async (version) => {
const releaseNotesApi = window.goodbuddy.releaseNotes;
if (!releaseNotesApi) {
throw new Error("Release notes service is unavailable");
}
await releaseNotesApi.acknowledge(version);
}}
onClose={() => setReleaseNotes(undefined)}
snapshot={releaseNotes}
/>
)}
{citationDialog && (
<KnowledgeCitationDialog
context={citationDialog.context}
error={citationDialog.error}
loading={citationDialog.loading}
onClose={() => { citationRequestRef.current++; setCitationDialog(undefined); }}
onOpenSource={async () => {
const { reference } = citationDialog;
if (reference.external || !reference.chunkId || !reference.documentId) {
throw new Error(t("chat.citations.contextUnavailable"));
}
await window.goodbuddy.knowledge.openReferenceSource({
knowledgeBaseId: reference.libraryId,
documentId: reference.documentId,
chunkId: reference.chunkId,
});
}}
reference={citationDialog.reference}
/>
)}
{imageViewerItem && createPortal(
<div
className="image-viewer-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeImageViewer();
}
}}
>
<section
aria-labelledby="image-viewer-title"
aria-modal="true"
className="image-viewer-dialog"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
closeImageViewer();
return;
}
trapTabFocus(event, imageViewerDialogRef.current);
}}
ref={imageViewerDialogRef}
role="dialog"
>
<header className="image-viewer-dialog__header">
<strong id="image-viewer-title">
{imageViewerItem.title}
</strong>
<div>
<button
aria-label={t("chat.images.downloadImage")}
className="icon-button attachment-action"
title={t("chat.images.downloadImage")}
data-tooltip={t("chat.images.downloadImage")}
onClick={() => downloadImage(imageViewerItem)}
type="button"
>
<Download size={16} aria-hidden="true" />
</button>
<button
aria-label={t("chat.images.closeViewer")}
className="icon-button"
onClick={closeImageViewer}
ref={imageViewerCloseRef}
type="button"
>
<X size={16} />
</button>
</div>
</header>
<div className="image-viewer-dialog__content">
<img alt={imageViewerItem.title} src={imageViewerItem.src} />
</div>
</section>
</div>,
document.body
)}
{customTaskDialog && activeProject?.kind === "user" && (
<CustomTaskDialog
conversations={conversations.filter(
(conversation) => !conversation.remote &&
conversation.projectId === activeProject.id,
)}
currentConversationAvailable={Boolean(
activeConversation &&
!activeConversation.remote &&
activeConversation.projectId === activeProject.id,
)}
currentConversationId={activeConversation?.id}
defaultDestination={customTaskDialog.defaultDestination}
onClose={() => setCustomTaskDialog(undefined)}
onCreate={createCustomTask}
projectId={activeProject.id}
projectName={activeProjectDisplayName ?? activeProject.name}
workspaceLabel={
activeProject.rootPath || t("customTask.scope.noWorkspace")
}
/>
)}
<RightAssistantSidebar
notesEnabled={magicNotesEnabled}
notesSettingsReady={Boolean(applicationSettings)}
notesOpenRequest={notesOpenRequest}
onBeforeCloseNotes={async () => { if (!await guardNoteDraft()) return false; setNoteDraft(undefined); return true; }}
notesPanel={<MagicNotesPanel state={noteDraft} active={assistantSidebarOpen && magicNotesEnabled} commentMode={applicationSettings?.magicNoteCommentMode} commentFormat={applicationSettings?.magicNoteCommentFormat} onNotify={notify} onOpenSource={openNoteSource}
onCancelCapture={() => requestAnimationFrame(() => { const trigger = noteCaptureTrigger.current; (trigger?.isConnected && !trigger.closest('[hidden], [inert]') ? trigger : noteTitleMenuRef.current)?.focus(); })}
onOpenWorkspace={(noteId, entryId) => requestWorkspaceLeave('magic-notes', () => { setNotesNavigation({ noteId, entryId, requestId: Date.now() }); commitView('magic-notes'); })} />}
nativeTerminals={nativeTerminals}
activeConversationId={activeId}
conversationStats={statsConversation && executionStats.conversation ? {
conversationId: statsConversation.id,
messageCount: statsMessageCount,
replyDurationMs: executionStats.conversation.durationMs,
incomplete: executionStats.conversation.incompleteRequestCount > 0,
} : undefined}
taskDurations={taskDurations}
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
browserStates={browserStates}
conversationTitles={conversationTitles}
currentProject={activeProject}
supervisionEnabled={isApplicationEnabled(applicationSettings, 'heartbeat')}
supervisionLibraries={knowledgeSnapshot.libraries}
onOpenSupervisionGraph={(resultId) => {
requestWorkspaceLeave('heartbeat', () => {
setSupervisionGraphNavigation({ resultId });
commitView('heartbeat');
});
}}
onOpenSupervisionConversation={openActivityConversation}
onContinueSupervision={async (prompt, conversationId) => {
await window.goodbuddy.supervision.continue({
conversationId,
prompt,
})
openActivityConversation(conversationId)
}}
onCreateCustomTask={() => openCustomTaskDialog("current")}
schedules={assistantSchedules}
selectedTaskId={selectedAssistantTaskId}
tasks={productAssistantTasks}
projectNames={projectNames}
onBackBrowser={(conversationId, tabId) =>
runBrowserCommand((browserApi) =>
browserApi.back({ conversationId, tabId }),
)
}
onNavigateBrowser={(conversationId, tabId, url) =>
runBrowserCommand((browserApi) =>
browserApi.navigate({ conversationId, tabId, url }),
)
}
onReloadBrowser={(conversationId, tabId) =>
runBrowserCommand((browserApi) =>
browserApi.reload({ conversationId, tabId }),
)
}
onStopLoadingBrowser={(conversationId, tabId) =>
runBrowserCommand((browserApi) =>
browserApi.stopLoading({ conversationId, tabId }),
)
}
onImportArtifacts={async () => {
const imported = await window.goodbuddy.artifacts.importFiles(
activeProjectId || undefined,
);
if (imported.length > 0) {
setAssistantArtifacts((current) => [...imported, ...current]);
setAssistantSidebarTab("results");
}
}}
onLoadArtifact={async (artifactId) => {
if (assistantArtifactById.get(artifactId)?.content) {
return;
}
const artifact = await window.goodbuddy.artifacts.get(artifactId);
setAssistantArtifacts((current) =>
mergeArtifacts(current, [artifact]),
);
}}
onOpenTask={openAssistantTask}
onRemoveSchedule={removeAssistantSchedule}
onRespondApproval={(approval, decision) => {
void respondToApproval(
approval.conversationId,
approval.messageId,
approval.approvalId,
decision,
);
}}
onRunSchedule={runAssistantSchedule}
onSetScheduleEnabled={setAssistantScheduleEnabled}
onListWorkspaceDirectory={listWorkspaceDirectory}
onLoadWorkspaceFile={loadWorkspaceFile}
onOpenWorkspaceEntry={openWorkspaceEntry}
onLoadWorkspaceDiff={loadWorkspaceDiff}
onRefreshChanges={refreshWorkspaceChanges}
onTabChange={setAssistantSidebarTab}
open={assistantSidebarOpen}
restoreFocusRef={assistantSidebarToggleRef}
tab={assistantSidebarTab}
workspaceChanges={
workspaceChanges?.projectId === activeProjectId
? workspaceChanges.changes
: undefined
}
workspaceProjectId={activeProjectId || undefined}
/>
</div>
</div>
{noteDraft.confirmation}
</DocumentConversationContext>
</div>
);
}
export default App;