Skip to content
Merged
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
86 changes: 86 additions & 0 deletions app/web/src/hooks/useHubCustomAgents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import {
createHubClient,
type HubCustomAgent,
} from '@shared/index';
import { getHubBaseUrl } from './useHubSession';

export type { HubCustomAgent };

type CustomAgentsState = {
agents: HubCustomAgent[];
error?: string;
isLoading: boolean;
source: 'hub' | 'catalog';
};

function formatHubError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error || 'Hub custom agent catalog unavailable');
}

export function useHubCustomAgents(token: string | null) {
const [state, setState] = useState<CustomAgentsState>({
agents: [],
isLoading: false,
source: 'catalog',
});

useEffect(() => {
if (!token) {
setState({ agents: [], isLoading: false, source: 'catalog' });
return undefined;
}

const controller = new AbortController();
let cancelled = false;
const timeoutId = window.setTimeout(() => controller.abort(), 2500);

setState((current) => ({ ...current, isLoading: true, error: undefined }));

const client = createHubClient({
baseUrl: getHubBaseUrl(),
fetch: (input, init) => fetch(input, { ...init, signal: controller.signal }),
getToken: () => token,
});

client
.listCustomAgents()
.then((agents) => {
if (cancelled) return;
setState({
agents,
isLoading: false,
source: 'hub',
});
})
.catch((error) => {
if (cancelled) return;
if (controller.signal.aborted) {
setState({
agents: [],
error: 'Hub custom agent catalog timed out',
isLoading: false,
source: 'catalog',
});
return;
}

setState({
agents: [],
error: formatHubError(error),
isLoading: false,
source: 'catalog',
});
})
.finally(() => window.clearTimeout(timeoutId));

return () => {
cancelled = true;
window.clearTimeout(timeoutId);
controller.abort();
};
}, [token]);

return state;
}
113 changes: 113 additions & 0 deletions app/web/src/hooks/useHubIMSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import {
createHubClient,
type HubMessage,
type HubSession,
} from '@shared/index';
import { getHubBaseUrl } from './useHubSession';

type HubIMSnapshotStatus = 'locked' | 'loading' | 'ready' | 'error';

export type HubIMSnapshot = {
error?: string;
messagesBySessionId: Record<string, HubMessage[]>;
sessions: HubSession[];
status: HubIMSnapshotStatus;
};

const emptyMessagesBySessionId: Record<string, HubMessage[]> = {};

function formatHubError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error || 'Hub IM snapshot unavailable');
}

function getSessionId(session: HubSession): string {
return session.session_id || session.id || '';
}

export function useHubIMSnapshot(token: string | null): HubIMSnapshot {
const [snapshot, setSnapshot] = useState<HubIMSnapshot>({
messagesBySessionId: emptyMessagesBySessionId,
sessions: [],
status: token ? 'loading' : 'locked',
});

useEffect(() => {
if (!token) {
setSnapshot({
messagesBySessionId: emptyMessagesBySessionId,
sessions: [],
status: 'locked',
});
return undefined;
}

const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 3500);
let cancelled = false;

const client = createHubClient({
baseUrl: getHubBaseUrl(),
fetch: (input, init) => fetch(input, { ...init, signal: controller.signal }),
getToken: () => token,
});

setSnapshot((current) => ({
...current,
error: undefined,
messagesBySessionId: emptyMessagesBySessionId,
sessions: [],
status: 'loading',
}));

async function loadSnapshot() {
try {
const sessions = (await client.listSessions()).filter(
(session) => session.type === 'private',
);
const messageEntries = await Promise.all(
sessions.map(async (session) => {
const sessionId = getSessionId(session);
if (!sessionId) return ['', []] as const;
const messages = await client.getMessages(sessionId, { limit: 20 });
return [sessionId, messages] as const;
}),
);

if (cancelled) return;

setSnapshot({
messagesBySessionId: Object.fromEntries(
messageEntries.filter(([sessionId]) => Boolean(sessionId)),
),
sessions,
status: 'ready',
});
} catch (error) {
if (cancelled) return;

setSnapshot({
error: controller.signal.aborted
? 'Hub IM snapshot timed out'
: formatHubError(error),
messagesBySessionId: emptyMessagesBySessionId,
sessions: [],
status: 'error',
});
} finally {
window.clearTimeout(timeoutId);
}
}

void loadSnapshot();

return () => {
cancelled = true;
window.clearTimeout(timeoutId);
controller.abort();
};
}, [token]);

return snapshot;
}
56 changes: 56 additions & 0 deletions app/web/src/hooks/useHubSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useEffect, useMemo, useState } from 'react';

const HUB_TOKEN_KEYS = [
'agenthub_hub_token',
'agenthub:web_hub_token',
'agenthub_web_hub_token',
'agenthub:hub_token',
];

function readStorageToken(storage: Storage | undefined, key: string): string | null {
try {
return storage?.getItem(key)?.trim() || null;
} catch {
return null;
}
}

export function getWebHubToken(): string | null {
if (typeof window === 'undefined') return null;

for (const key of HUB_TOKEN_KEYS) {
const token = readStorageToken(window.sessionStorage, key) ?? readStorageToken(window.localStorage, key);
if (token) return token;
}

return null;
}

export function getHubBaseUrl(): string {
const configured = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env?.VITE_HUB_URL;
return (configured || 'http://localhost:8080').replace(/\/+$/, '');
}

export function useHubSession() {
const [token, setToken] = useState<string | null>(() => getWebHubToken());

useEffect(() => {
const refresh = () => setToken(getWebHubToken());
window.addEventListener('storage', refresh);
window.addEventListener('focus', refresh);

return () => {
window.removeEventListener('storage', refresh);
window.removeEventListener('focus', refresh);
};
}, []);

return useMemo(
() => ({
hasSession: Boolean(token),
token,
hubBaseUrl: getHubBaseUrl(),
}),
[token],
);
}
102 changes: 102 additions & 0 deletions app/web/src/hooks/useWorkbenchProjection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useEffect, useReducer } from 'react';
import {
listApprovals,
listArtifacts,
listPreviews,
listProjects,
listRunners,
listRuns,
listThreads,
workbenchReducer,
type WorkbenchState,
} from '@shared/index';

const initialWorkbenchProjectionState: WorkbenchState = {
projects: [],
threads: [],
runners: [],
runs: [],
threadItems: [],
approvals: [],
artifacts: [],
previews: [],
runLogs: {},
connection: { status: 'idle' },
lastSeq: 0,
};

function formatError(error: unknown) {
if (error instanceof Error) return error.message;
return String(error || 'Edge catalog unavailable');
}

function withTimeout<T>(promise: Promise<T>, timeoutMs = 2500): Promise<T> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('Edge catalog did not respond.')), timeoutMs);
promise.then(
(value) => {
window.clearTimeout(timer);
resolve(value);
},
(error) => {
window.clearTimeout(timer);
reject(error);
},
);
});
}

export function useWorkbenchProjection() {
const [state, dispatch] = useReducer(
workbenchReducer,
initialWorkbenchProjectionState,
(initialState) => workbenchReducer(initialState, { type: 'connection.loading' }),
);

useEffect(() => {
let cancelled = false;

async function loadSnapshot() {
dispatch({ type: 'connection.loading' });
try {
const [projects, threads, runners, runs, approvals, artifacts, previews] =
await withTimeout(Promise.all([
listProjects({ pageSize: 50 }),
listThreads({ pageSize: 50 }),
listRunners(),
listRuns({ pageSize: 50 }),
listApprovals(),
listArtifacts(),
listPreviews(),
]));

if (cancelled) return;

dispatch({
type: 'snapshot.loaded',
snapshot: {
projects,
threads,
runners,
runs,
approvals,
artifacts,
previews,
},
});
} catch (error) {
if (!cancelled) {
dispatch({ type: 'connection.error', error: formatError(error) });
}
}
}

loadSnapshot();

return () => {
cancelled = true;
};
}, []);

return state;
}
Loading
Loading