Skip to content
Open
7 changes: 6 additions & 1 deletion src/components/ErrorBoundary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {

render() {
if (this.state.hasError) {
return <ErrorPage error={this.state.error} />;
return (
<ErrorPage
error={this.state.error}
componentStack={this.state.errorInfo?.componentStack ?? undefined}
/>
);
}

return this.props.children;
Expand Down
84 changes: 44 additions & 40 deletions src/engines/ChatPanel/panels/ProjectPanelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { usePublishChatPanelHeader } from "@src/engines/ChatPanel/header";
import KanbanBoard from "@src/features/KanbanBoard";
import type { KanbanTask, TaskStatus } from "@src/features/KanbanBoard";
import { allocateCloudAwareWorkItemId } from "@src/features/Org2Cloud/cloudShortId";
import { useAsyncResource } from "@src/hooks/async";
import { createLogger } from "@src/hooks/logger";
import {
useCurrentUserMemberIds,
Expand Down Expand Up @@ -75,6 +76,16 @@ interface ProjectPanelViewProps {

const PROJECT_PANEL_TABS: ProjectPanelTab[] = ["overview", "list", "kanban"];

interface ProjectWorkItemsResource {
shortIds: Map<string, string>;
workItems: WorkItem[];
}

const EMPTY_PROJECT_WORK_ITEMS: ProjectWorkItemsResource = {
shortIds: new Map(),
workItems: [],
};

function getProjectOverviewDescription(
project: ChatPanelSelectedProject["project"]
) {
Expand All @@ -101,12 +112,6 @@ export const ProjectPanelView: React.FC<ProjectPanelViewProps> = ({
const [projectBodyLoading, setProjectBodyLoading] = useState(false);
const [projectBodyError, setProjectBodyError] = useState<string | null>(null);
const lastSavedDescriptionRef = useRef(sidebarProjectDescription);
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [workItemShortIds, setWorkItemShortIds] = useState<Map<string, string>>(
new Map()
);
const [workItemsLoading, setWorkItemsLoading] = useState(false);
const [workItemsError, setWorkItemsError] = useState<string | null>(null);
const [projectSyncAdapter, setProjectSyncAdapter] = useState<{
projectSlug: string;
adapterId: string | null;
Expand Down Expand Up @@ -242,36 +247,34 @@ export const ProjectPanelView: React.FC<ProjectPanelViewProps> = ({
};
}, [projectSlug, selectedProject.project.id, sidebarProjectDescription]);

const loadProjectWorkItems = useCallback(async () => {
if (!projectSlug) {
setWorkItems([]);
setWorkItemShortIds(new Map());
return;
}

setWorkItemsLoading(true);
setWorkItemsError(null);
try {
const viewData = await projectApi.readWorkItemsViewData(projectSlug, {
view: "list",
});
setWorkItemShortIds(
new Map(viewData.items.map((item) => [item.id, item.shortId]))
const fetchProjectWorkItems = useCallback(
async (scopeProjectSlug: string) => {
const viewData = await projectApi.readWorkItemsViewData(
scopeProjectSlug,
{ view: "list" }
);
setWorkItems(viewData.items.map(enrichedWorkItemToUI));
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load work items";
logger.error("Failed to load project work items:", error);
setWorkItemsError(message);
} finally {
setWorkItemsLoading(false);
}
}, [projectSlug]);

useEffect(() => {
void loadProjectWorkItems();
}, [loadProjectWorkItems]);
return {
shortIds: new Map(
viewData.items.map((item) => [item.id, item.shortId])
),
workItems: viewData.items.map(enrichedWorkItemToUI),
};
},
[]
);
const workItemsResource = useAsyncResource({
enabled: Boolean(projectSlug),
fetcher: fetchProjectWorkItems,
initialData: EMPTY_PROJECT_WORK_ITEMS,
scopeKey: projectSlug || null,
});
const {
data: { shortIds: workItemShortIds, workItems },
error: workItemsError,
loading: workItemsLoading,
refresh: loadProjectWorkItems,
setData: setWorkItemsData,
} = workItemsResource;

useProjectDataChanged(
useCallback(
Expand Down Expand Up @@ -489,13 +492,14 @@ export const ProjectPanelView: React.FC<ProjectPanelViewProps> = ({
payload
);
const updatedItem = enrichedWorkItemToUI(updated);
setWorkItems((currentItems) =>
currentItems.map((item) =>
setWorkItemsData((current) => ({
...current,
workItems: current.workItems.map((item) =>
item.session_id === workItemId ? updatedItem : item
)
);
),
}));
},
[currentUser, getWorkItemShortId, projectSlug, setWorkItems]
[currentUser, getWorkItemShortId, projectSlug, setWorkItemsData]
);

const handleAddKanbanTask = useCallback(
Expand Down
157 changes: 91 additions & 66 deletions src/engines/SessionCore/hooks/session/useSessionDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* useAgentCompatibility() stays in sync.
*/
import { useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";

import type { AgentInfo, ProviderInfo } from "@src/api/http/config";
import { loadAvailableAgents } from "@src/api/services/availableAgents";
Expand All @@ -20,6 +20,10 @@ import type {
AvailableApiProvider,
KeyInfo,
} from "@src/api/tauri/rpc/schemas/validation";
import {
type AsyncResourceFetchContext,
useAsyncResource,
} from "@src/hooks/async";
import { loadSharedLocalKeys } from "@src/hooks/keyVault/sharedLocalKeyStore";
import { createLogger } from "@src/hooks/logger";
import { agentRegistryAtom } from "@src/store/session/agentRegistryAtom";
Expand Down Expand Up @@ -143,6 +147,47 @@ function mapAgents(agents: AvailableAgent[]): AgentInfo[] {
}));
}

interface SessionDiscoveryData {
apiProviders: AvailableApiProvider[];
mappedAgents: AgentInfo[];
providers: ProviderInfo[];
rawAgents: AvailableAgent[];
}

const EMPTY_SESSION_DISCOVERY: SessionDiscoveryData = {
apiProviders: [],
mappedAgents: [],
providers: [],
rawAgents: [],
};

let discoveryInFlight: Promise<SessionDiscoveryData> | null = null;

function loadSessionDiscovery(force: boolean): Promise<SessionDiscoveryData> {
if (!force && discoveryInFlight) return discoveryInFlight;

const promise = Promise.all([
rpc.validation.getAvailableApiProviders(),
loadAvailableAgents(),
loadSharedLocalKeys(force),
]).then(([apiProviders, rawAgents, allKeys]) => ({
apiProviders,
mappedAgents: mapAgents(rawAgents),
providers: buildProviderInfoList(apiProviders, allKeys),
rawAgents,
}));
discoveryInFlight = promise;
void promise.then(
() => {
if (discoveryInFlight === promise) discoveryInFlight = null;
},
() => {
if (discoveryInFlight === promise) discoveryInFlight = null;
}
);
return promise;
}

// ============================================
// Hook Implementation
// ============================================
Expand All @@ -151,22 +196,44 @@ export function useSessionDiscovery(
options: UseSessionDiscoveryOptions = {}
): UseSessionDiscoveryReturn {
const { autoLoad = true, onSuccess, onError } = options;

const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);
const mountedRef = useRef(true);

const setAgentRegistry = useSetAtom(agentRegistryAtom);

const callbacksRef = useRef({ onError, onSuccess });
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
callbacksRef.current = { onError, onSuccess };
}, [onError, onSuccess]);

const fetchDiscovery = useCallback(
async (
_scopeKey: string,
context: AsyncResourceFetchContext<SessionDiscoveryData>
) => {
try {
const data = await loadSessionDiscovery(context.cause === "refresh");
callbacksRef.current.onSuccess?.({
agents: data.mappedAgents,
providers: data.providers,
});
return data;
} catch (error) {
const normalizedError =
error instanceof Error
? error
: new Error("Failed to load session data");
log.error("[useSessionDiscovery] Refresh failed:", error);
callbacksRef.current.onError?.(normalizedError);
throw normalizedError;
}
},
[]
);
const resource = useAsyncResource({
autoLoad,
fetcher: fetchDiscovery,
initialData: EMPTY_SESSION_DISCOVERY,
scopeKey: "session-discovery",
});
const providers = resource.data.providers;
const agents = resource.data.mappedAgents;

const availableAgents = useMemo(
() => agents.filter((agent) => agent.available),
Expand Down Expand Up @@ -201,56 +268,14 @@ export function useSessionDiscovery(
[agents]
);

// ============================================
// Refresh
// ============================================

const refresh = useCallback(async () => {
if (!mountedRef.current) return;
setLoading(true);
setError(null);

try {
const [apiProviders, rawAgents, allKeys] = await Promise.all([
rpc.validation.getAvailableApiProviders(),
loadAvailableAgents(),
loadSharedLocalKeys(),
]);

if (!mountedRef.current) return;

// Populate agentRegistryAtom so useAgentCompatibility stays current
setAgentRegistry({ agents: rawAgents, apiProviders });

const mappedProviders = buildProviderInfoList(apiProviders, allKeys);
const mappedAgents = mapAgents(rawAgents);

setProviders(mappedProviders);
setAgents(mappedAgents);

onSuccess?.({ providers: mappedProviders, agents: mappedAgents });
} catch (err) {
if (!mountedRef.current) return;
const errorMessage =
err instanceof Error ? err.message : "Failed to load session data";
log.error("[useSessionDiscovery] Refresh failed:", err);
setError(errorMessage);
onError?.(err as Error);
} finally {
if (mountedRef.current) setLoading(false);
}
}, [onSuccess, onError, setAgentRegistry]);

// ============================================
// Effects
// ============================================

useEffect(() => {
if (autoLoad && !hasLoadedRef.current) {
hasLoadedRef.current = true;
refresh();
if (resource.status === "ready") {
setAgentRegistry({
agents: resource.data.rawAgents,
apiProviders: resource.data.apiProviders,
});
}
}, [autoLoad, refresh]);
}, [resource.data, resource.status, setAgentRegistry]);

// ============================================
// Return
Expand All @@ -260,9 +285,9 @@ export function useSessionDiscovery(
providers,
agents,
availableAgents,
loading,
error,
refresh,
loading: resource.loading,
error: resource.error,
refresh: resource.refresh,
getModelsForProvider,
isProviderAvailable,
isAgentAvailable,
Expand Down
Loading
Loading