Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/components/ErrorBoundary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,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
81 changes: 42 additions & 39 deletions src/engines/ChatPanel/panels/ProjectPanelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 @@ -76,6 +77,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 @@ -102,12 +113,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 @@ -243,34 +248,31 @@ 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);
setWorkItemShortIds(
new Map(viewData.items.map((item) => [item.id, item.shortId]))
);
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]);
const fetchProjectWorkItems = useCallback(
async (scopeProjectSlug: string) => {
const viewData = await projectApi.readWorkItemsViewData(scopeProjectSlug);
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 @@ -483,13 +485,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