Merge pull request #97 from abhi1693/docs/frontend-code-doc-pass
docs(frontend): add inline docs for complex UI data-flow
This commit is contained in:
@@ -339,6 +339,12 @@ export default function BoardGroupDetailPage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the newest `created_at` timestamp in a list of memory items.
|
||||||
|
*
|
||||||
|
* We pass this as `since` when reconnecting SSE so we don't re-stream the
|
||||||
|
* entire chat history after transient disconnects.
|
||||||
|
*/
|
||||||
const latestMemoryTimestamp = useCallback((items: BoardGroupMemoryRead[]) => {
|
const latestMemoryTimestamp = useCallback((items: BoardGroupMemoryRead[]) => {
|
||||||
if (!items.length) return undefined;
|
if (!items.length) return undefined;
|
||||||
const latest = items.reduce((max, item) => {
|
const latest = items.reduce((max, item) => {
|
||||||
@@ -405,11 +411,13 @@ export default function BoardGroupDetailPage() {
|
|||||||
while (!isCancelled) {
|
while (!isCancelled) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
|
// Consider the stream healthy once we receive any bytes (including pings)
|
||||||
|
// and reset the backoff so a later disconnect doesn't wait the full max.
|
||||||
if (value && value.length) {
|
if (value && value.length) {
|
||||||
// Consider the stream healthy once we receive any bytes (including pings),
|
|
||||||
// then reset the backoff for future reconnects.
|
|
||||||
backoff.reset();
|
backoff.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
buffer = buffer.replace(/\r\n/g, "\n");
|
buffer = buffer.replace(/\r\n/g, "\n");
|
||||||
let boundary = buffer.indexOf("\n\n");
|
let boundary = buffer.indexOf("\n\n");
|
||||||
|
|||||||
@@ -1315,6 +1315,12 @@ export default function BoardDetailPage() {
|
|||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [chatMessages, isChatOpen]);
|
}, [chatMessages, isChatOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an ISO timestamp for the newest board chat message.
|
||||||
|
*
|
||||||
|
* Used as the `since` cursor when (re)connecting to the SSE endpoint so we
|
||||||
|
* don't re-stream the entire chat log.
|
||||||
|
*/
|
||||||
const latestChatTimestamp = (items: BoardChatMessage[]) => {
|
const latestChatTimestamp = (items: BoardChatMessage[]) => {
|
||||||
if (!items.length) return undefined;
|
if (!items.length) return undefined;
|
||||||
const latest = items.reduce((max, item) => {
|
const latest = items.reduce((max, item) => {
|
||||||
@@ -1373,9 +1379,9 @@ export default function BoardDetailPage() {
|
|||||||
while (!isCancelled) {
|
while (!isCancelled) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
// Consider the stream healthy once we receive any bytes (including pings)
|
||||||
|
// and reset the backoff so a later disconnect doesn't wait the full max.
|
||||||
if (value && value.length) {
|
if (value && value.length) {
|
||||||
// Consider the stream "healthy" once we receive any bytes (including pings),
|
|
||||||
// then reset the backoff for future reconnects.
|
|
||||||
backoff.reset();
|
backoff.reset();
|
||||||
}
|
}
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|||||||
@@ -145,6 +145,18 @@ function parsePageSizeParam(value: string | null) {
|
|||||||
return MARKETPLACE_DEFAULT_PAGE_SIZE;
|
return MARKETPLACE_DEFAULT_PAGE_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skills Marketplace admin page.
|
||||||
|
*
|
||||||
|
* Data-flow notes:
|
||||||
|
* - URL query params are treated as the shareable source-of-truth for filters
|
||||||
|
* and pagination (we mirror local state -> URL via `router.replace`).
|
||||||
|
* - We keep a *separate* query (without pagination) for building filter option
|
||||||
|
* lists (categories/risks) so options don't disappear just because the current
|
||||||
|
* page is filtered/paginated.
|
||||||
|
* - Total row count is best-effort: when the API provides `x-total-count`, we
|
||||||
|
* use it; otherwise we infer whether there is a next page from page-size.
|
||||||
|
*/
|
||||||
export default function SkillsMarketplacePage() {
|
export default function SkillsMarketplacePage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -247,6 +259,8 @@ export default function SkillsMarketplacePage() {
|
|||||||
resolvedGatewayId,
|
resolvedGatewayId,
|
||||||
selectedPackId,
|
selectedPackId,
|
||||||
]);
|
]);
|
||||||
|
// Fetch a non-paginated (or minimally constrained) slice for building filter options.
|
||||||
|
// Keeping this separate avoids "missing" categories/risks when the main list is paginated.
|
||||||
const filterOptionsParams = useMemo<MarketplaceSkillListParams>(() => {
|
const filterOptionsParams = useMemo<MarketplaceSkillListParams>(() => {
|
||||||
const params: MarketplaceSkillListParams = {
|
const params: MarketplaceSkillListParams = {
|
||||||
gateway_id: resolvedGatewayId,
|
gateway_id: resolvedGatewayId,
|
||||||
@@ -314,6 +328,8 @@ export default function SkillsMarketplacePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredSkills = useMemo(() => skills, [skills]);
|
const filteredSkills = useMemo(() => skills, [skills]);
|
||||||
|
// Prefer the API total-count header when available (true pagination).
|
||||||
|
// When missing, we fall back to a heuristic for "has next page".
|
||||||
const totalCountInfo = useMemo(() => {
|
const totalCountInfo = useMemo(() => {
|
||||||
if (skillsQuery.data?.status !== 200) {
|
if (skillsQuery.data?.status !== 200) {
|
||||||
return { hasKnownTotal: false, total: skills.length };
|
return { hasKnownTotal: false, total: skills.length };
|
||||||
@@ -435,6 +451,9 @@ export default function SkillsMarketplacePage() {
|
|||||||
}
|
}
|
||||||
}, [currentPage, totalCountInfo.hasKnownTotal, totalPages]);
|
}, [currentPage, totalCountInfo.hasKnownTotal, totalPages]);
|
||||||
|
|
||||||
|
// Mirror local filter/page state back into the URL so the view is shareable and
|
||||||
|
// back/forward navigation works. We use `replace` (not push) to avoid filling
|
||||||
|
// browser history with every keystroke / filter tweak.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextParams = new URLSearchParams(searchParams.toString());
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
const normalizedSearchForUrl = searchTerm.trim();
|
const normalizedSearchForUrl = searchTerm.trim();
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ const PACKS_SORTABLE_COLUMNS = [
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill packs admin page.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Sync actions are intentionally serialized (per-pack) to avoid a thundering herd
|
||||||
|
* of GitHub fetches / backend sync jobs.
|
||||||
|
* - We keep UI state (`syncingPackIds`, warnings) local; the canonical list is
|
||||||
|
* still React Query (invalidate after sync/delete).
|
||||||
|
*/
|
||||||
export default function SkillsPacksPage() {
|
export default function SkillsPacksPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
@@ -141,6 +150,8 @@ export default function SkillsPacksPage() {
|
|||||||
try {
|
try {
|
||||||
let hasFailure = false;
|
let hasFailure = false;
|
||||||
|
|
||||||
|
// Run sequentially so the UI remains predictable and the backend isn't hit with
|
||||||
|
// concurrent sync bursts (which can trigger rate-limits).
|
||||||
for (const pack of packs) {
|
for (const pack of packs) {
|
||||||
if (!pack.id) continue;
|
if (!pack.id) continue;
|
||||||
setSyncingPackIds((previous) => {
|
setSyncingPackIds((previous) => {
|
||||||
|
|||||||
@@ -148,9 +148,15 @@ const formatRubricTooltipValue = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow unknown values to a plain record.
|
||||||
|
*
|
||||||
|
* Used for defensive parsing of `approval.payload` (schema can evolve).
|
||||||
|
*/
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
/** Safely read any value at a nested path inside an approval payload. */
|
||||||
const payloadAtPath = (payload: Approval["payload"], path: string[]) => {
|
const payloadAtPath = (payload: Approval["payload"], path: string[]) => {
|
||||||
let current: unknown = payload;
|
let current: unknown = payload;
|
||||||
for (const key of path) {
|
for (const key of path) {
|
||||||
@@ -160,6 +166,12 @@ const payloadAtPath = (payload: Approval["payload"], path: string[]) => {
|
|||||||
return current ?? null;
|
return current ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely read a simple scalar value from an approval payload.
|
||||||
|
*
|
||||||
|
* The backend payload shape can evolve (camelCase vs snake_case). Keeping these
|
||||||
|
* helpers centralized makes it easier to support older approvals.
|
||||||
|
*/
|
||||||
const payloadValue = (payload: Approval["payload"], key: string) => {
|
const payloadValue = (payload: Approval["payload"], key: string) => {
|
||||||
const value = payloadAtPath(payload, [key]);
|
const value = payloadAtPath(payload, [key]);
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
@@ -168,12 +180,18 @@ const payloadValue = (payload: Approval["payload"], key: string) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely read a string[] value from an approval payload.
|
||||||
|
*
|
||||||
|
* Filters non-string entries to keep UI rendering predictable.
|
||||||
|
*/
|
||||||
const payloadValues = (payload: Approval["payload"], key: string) => {
|
const payloadValues = (payload: Approval["payload"], key: string) => {
|
||||||
const value = payloadAtPath(payload, [key]);
|
const value = payloadAtPath(payload, [key]);
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.filter((item): item is string => typeof item === "string");
|
return value.filter((item): item is string => typeof item === "string");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Safely read a scalar value from an approval payload at a nested path. */
|
||||||
const payloadNestedValue = (payload: Approval["payload"], path: string[]) => {
|
const payloadNestedValue = (payload: Approval["payload"], path: string[]) => {
|
||||||
const value = payloadAtPath(payload, path);
|
const value = payloadAtPath(payload, path);
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
@@ -182,6 +200,7 @@ const payloadNestedValue = (payload: Approval["payload"], path: string[]) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Safely read a string[] value from an approval payload at a nested path. */
|
||||||
const payloadNestedValues = (payload: Approval["payload"], path: string[]) => {
|
const payloadNestedValues = (payload: Approval["payload"], path: string[]) => {
|
||||||
const value = payloadAtPath(payload, path);
|
const value = payloadAtPath(payload, path);
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
@@ -222,6 +241,15 @@ const normalizeRubricScores = (raw: unknown): Record<string, number> => {
|
|||||||
const payloadRubricScores = (payload: Approval["payload"]) =>
|
const payloadRubricScores = (payload: Approval["payload"]) =>
|
||||||
normalizeRubricScores(payloadAtPath(payload, ["analytics", "rubric_scores"]));
|
normalizeRubricScores(payloadAtPath(payload, ["analytics", "rubric_scores"]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract task ids referenced by an approval.
|
||||||
|
*
|
||||||
|
* Approvals can reference tasks in multiple places depending on the producer:
|
||||||
|
* - top-level `task_id` / `task_ids` fields
|
||||||
|
* - nested payload keys (task_id/taskId/taskIDs, etc.)
|
||||||
|
*
|
||||||
|
* We merge/dedupe to get a best-effort list for UI deep links.
|
||||||
|
*/
|
||||||
const approvalTaskIds = (approval: Approval) => {
|
const approvalTaskIds = (approval: Approval) => {
|
||||||
const payload = approval.payload ?? {};
|
const payload = approval.payload ?? {};
|
||||||
const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null })
|
const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null })
|
||||||
@@ -318,6 +346,12 @@ const approvalRelatedTasks = (approval: Approval): RelatedTaskSummary[] => {
|
|||||||
const taskHref = (boardId: string, taskId: string) =>
|
const taskHref = (boardId: string, taskId: string) =>
|
||||||
`/boards/${encodeURIComponent(boardId)}?taskId=${encodeURIComponent(taskId)}`;
|
`/boards/${encodeURIComponent(boardId)}?taskId=${encodeURIComponent(taskId)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a small, human-readable summary of an approval request.
|
||||||
|
*
|
||||||
|
* Used by the approvals panel modal: it prefers explicit fields but falls back
|
||||||
|
* to payload-derived values so older approvals still render well.
|
||||||
|
*/
|
||||||
const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
|
const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
|
||||||
const payload = approval.payload ?? {};
|
const payload = approval.payload ?? {};
|
||||||
const taskIds = approvalTaskIds(approval);
|
const taskIds = approvalTaskIds(approval);
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ type NormalizedMessage = {
|
|||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize backend onboarding messages into a strict `{role, content}` list.
|
||||||
|
*
|
||||||
|
* The server stores messages as untyped JSON; this protects the UI from partial
|
||||||
|
* or malformed entries.
|
||||||
|
*/
|
||||||
const normalizeMessages = (
|
const normalizeMessages = (
|
||||||
value?: BoardOnboardingReadMessages,
|
value?: BoardOnboardingReadMessages,
|
||||||
): NormalizedMessage[] | null => {
|
): NormalizedMessage[] | null => {
|
||||||
@@ -59,6 +65,16 @@ const FREE_TEXT_OPTION_RE =
|
|||||||
|
|
||||||
const isFreeTextOption = (label: string) => FREE_TEXT_OPTION_RE.test(label);
|
const isFreeTextOption = (label: string) => FREE_TEXT_OPTION_RE.test(label);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort parser for assistant-produced question payloads.
|
||||||
|
*
|
||||||
|
* During onboarding, the assistant can respond with either:
|
||||||
|
* - raw JSON (ideal)
|
||||||
|
* - a fenced ```json block
|
||||||
|
* - slightly-structured objects
|
||||||
|
*
|
||||||
|
* This function validates shape and normalizes option ids/labels.
|
||||||
|
*/
|
||||||
const normalizeQuestion = (value: unknown): Question | null => {
|
const normalizeQuestion = (value: unknown): Question | null => {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const data = value as { question?: unknown; options?: unknown };
|
const data = value as { question?: unknown; options?: unknown };
|
||||||
@@ -90,6 +106,12 @@ const normalizeQuestion = (value: unknown): Question | null => {
|
|||||||
return { question: data.question, options };
|
return { question: data.question, options };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the most recent assistant question from the transcript.
|
||||||
|
*
|
||||||
|
* We intentionally only inspect the last assistant message: the user may have
|
||||||
|
* typed arbitrary text between questions.
|
||||||
|
*/
|
||||||
const parseQuestion = (messages?: NormalizedMessage[] | null) => {
|
const parseQuestion = (messages?: NormalizedMessage[] | null) => {
|
||||||
if (!messages?.length) return null;
|
if (!messages?.length) return null;
|
||||||
const lastAssistant = [...messages]
|
const lastAssistant = [...messages]
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ function useChart() {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recharts wrapper that:
|
||||||
|
* - Provides a shared `ChartConfig` via context (labels/icons/colors)
|
||||||
|
* - Exposes a small legend state (hide/toggle series)
|
||||||
|
* - Injects CSS variables (`--color-*`) scoped to this chart instance
|
||||||
|
*/
|
||||||
function ChartContainer({
|
function ChartContainer({
|
||||||
id,
|
id,
|
||||||
className,
|
className,
|
||||||
@@ -108,6 +114,10 @@ function ChartContainer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits scoped theme-aware CSS variables so Recharts series can use
|
||||||
|
* `var(--color-<key>)` without hardcoding colors in every chart.
|
||||||
|
*/
|
||||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
const colorConfig = Object.entries(config).filter(
|
const colorConfig = Object.entries(config).filter(
|
||||||
([, config]) => config.theme || config.color,
|
([, config]) => config.theme || config.color,
|
||||||
|
|||||||
@@ -82,15 +82,25 @@ const columns: Array<{
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build compact due-date UI state for a task card.
|
||||||
|
*
|
||||||
|
* - Returns `due: undefined` when the task has no due date (or it's invalid), so
|
||||||
|
* callers can omit the due-date UI entirely.
|
||||||
|
* - Treats a task as overdue only if it is not `done` (so "Done" tasks don't
|
||||||
|
* keep showing as overdue forever).
|
||||||
|
*/
|
||||||
const resolveDueState = (
|
const resolveDueState = (
|
||||||
task: Task,
|
task: Task,
|
||||||
): { due: string | undefined; isOverdue: boolean } => {
|
): { due: string | undefined; isOverdue: boolean } => {
|
||||||
const date = parseApiDatetime(task.due_at);
|
const date = parseApiDatetime(task.due_at);
|
||||||
if (!date) return { due: undefined, isOverdue: false };
|
if (!date) return { due: undefined, isOverdue: false };
|
||||||
|
|
||||||
const dueLabel = date.toLocaleDateString(undefined, {
|
const dueLabel = date.toLocaleDateString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
|
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
|
||||||
return {
|
return {
|
||||||
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
|
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
|
||||||
@@ -103,6 +113,16 @@ type CardPosition = { left: number; top: number };
|
|||||||
const KANBAN_MOVE_ANIMATION_MS = 240;
|
const KANBAN_MOVE_ANIMATION_MS = 240;
|
||||||
const KANBAN_MOVE_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
|
const KANBAN_MOVE_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kanban-style task board with 4 columns.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Uses a lightweight FLIP animation (via `useLayoutEffect`) to animate cards
|
||||||
|
* to their new positions when tasks move between columns.
|
||||||
|
* - Drag interactions can temporarily fight browser-managed drag images; the
|
||||||
|
* animation is disabled while a card is being dragged.
|
||||||
|
* - Respects `prefers-reduced-motion`.
|
||||||
|
*/
|
||||||
export const TaskBoard = memo(function TaskBoard({
|
export const TaskBoard = memo(function TaskBoard({
|
||||||
tasks,
|
tasks,
|
||||||
onTaskSelect,
|
onTaskSelect,
|
||||||
@@ -131,6 +151,12 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot each card's position relative to the scroll container.
|
||||||
|
*
|
||||||
|
* We store these measurements so we can compute deltas (prev - next) and
|
||||||
|
* apply the FLIP technique on the next render.
|
||||||
|
*/
|
||||||
const measurePositions = useCallback((): Map<string, CardPosition> => {
|
const measurePositions = useCallback((): Map<string, CardPosition> => {
|
||||||
const positions = new Map<string, CardPosition>();
|
const positions = new Map<string, CardPosition>();
|
||||||
const container = boardRef.current;
|
const container = boardRef.current;
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ const extractErrorMessage = (error: unknown, fallback: string) => {
|
|||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form used for creating/editing a marketplace skill source.
|
||||||
|
*
|
||||||
|
* Intentionally keeps validation lightweight + client-side only:
|
||||||
|
* the backend remains the source of truth and returns actionable errors.
|
||||||
|
*/
|
||||||
export function MarketplaceSkillForm({
|
export function MarketplaceSkillForm({
|
||||||
initialValues,
|
initialValues,
|
||||||
sourceUrlReadOnly = false,
|
sourceUrlReadOnly = false,
|
||||||
@@ -80,6 +86,13 @@ export function MarketplaceSkillForm({
|
|||||||
);
|
);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic repo URL validation.
|
||||||
|
*
|
||||||
|
* This is strict by design (https + github.com + at least owner/repo)
|
||||||
|
* to catch obvious mistakes early. More complex URLs (subpaths, branches)
|
||||||
|
* are handled server-side.
|
||||||
|
*/
|
||||||
const isValidSourceUrl = (value: string) => {
|
const isValidSourceUrl = (value: string) => {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(value);
|
const parsed = new URL(value);
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ export const SKILLS_TABLE_EMPTY_ICON = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small helper for supporting both controlled and uncontrolled table sorting.
|
||||||
|
*
|
||||||
|
* TanStack Table expects a `sorting` state + `onSortingChange` callback.
|
||||||
|
* Some pages want to control this from the URL (shareable links), while others
|
||||||
|
* are fine letting the table manage it internally.
|
||||||
|
*/
|
||||||
export const useTableSortingState = (
|
export const useTableSortingState = (
|
||||||
sorting: SortingState | undefined,
|
sorting: SortingState | undefined,
|
||||||
onSortingChange: OnChangeFn<SortingState> | undefined,
|
onSortingChange: OnChangeFn<SortingState> | undefined,
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ type DropdownSelectProps = {
|
|||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve trigger placeholder text with explicit prop override first, then accessible fallback.
|
/**
|
||||||
|
* Derive a human-friendly trigger placeholder from an accessible `ariaLabel`.
|
||||||
|
*
|
||||||
|
* Keeps placeholder strings consistent even when callers only provide aria text.
|
||||||
|
*/
|
||||||
const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => {
|
const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => {
|
||||||
if (placeholder) {
|
if (placeholder) {
|
||||||
return placeholder;
|
return placeholder;
|
||||||
@@ -52,7 +56,11 @@ const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => {
|
|||||||
return trimmed.endsWith("...") ? trimmed : `${trimmed}...`;
|
return trimmed.endsWith("...") ? trimmed : `${trimmed}...`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve search input placeholder from explicit override or a normalized aria label.
|
/**
|
||||||
|
* Search input placeholder derived from `ariaLabel`.
|
||||||
|
*
|
||||||
|
* Example: ariaLabel="Select agent" -> "Search agent...".
|
||||||
|
*/
|
||||||
const resolveSearchPlaceholder = (
|
const resolveSearchPlaceholder = (
|
||||||
ariaLabel: string,
|
ariaLabel: string,
|
||||||
searchPlaceholder?: string,
|
searchPlaceholder?: string,
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Normalize a repository-ish URL for UI usage.
|
||||||
|
*
|
||||||
|
* - Trims whitespace
|
||||||
|
* - Removes trailing slashes
|
||||||
|
* - Strips a trailing ".git" (common for clone URLs)
|
||||||
|
*/
|
||||||
export const normalizeRepoSourceUrl = (sourceUrl: string): string => {
|
export const normalizeRepoSourceUrl = (sourceUrl: string): string => {
|
||||||
const trimmed = sourceUrl.trim().replace(/\/+$/, "");
|
const trimmed = sourceUrl.trim().replace(/\/+$/, "");
|
||||||
return trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed;
|
return trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the base repo URL from a skill source URL.
|
||||||
|
*
|
||||||
|
* We support skill source URLs that may include a `/tree/<branch>/...` suffix
|
||||||
|
* (e.g. a skill living inside a monorepo). In those cases, we want to link to
|
||||||
|
* the repo root (the "pack") rather than the nested path.
|
||||||
|
*
|
||||||
|
* Returns `null` for invalid/unsupported URLs.
|
||||||
|
*/
|
||||||
export const repoBaseFromSkillSourceUrl = (
|
export const repoBaseFromSkillSourceUrl = (
|
||||||
skillSourceUrl: string,
|
skillSourceUrl: string,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
@@ -26,11 +42,23 @@ export const repoBaseFromSkillSourceUrl = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the pack URL from a skill source URL.
|
||||||
|
*
|
||||||
|
* Prefer the repo base when the URL points at a nested `/tree/...` path.
|
||||||
|
* Otherwise, fall back to the original source URL.
|
||||||
|
*/
|
||||||
export const packUrlFromSkillSourceUrl = (skillSourceUrl: string): string => {
|
export const packUrlFromSkillSourceUrl = (skillSourceUrl: string): string => {
|
||||||
const repoBase = repoBaseFromSkillSourceUrl(skillSourceUrl);
|
const repoBase = repoBaseFromSkillSourceUrl(skillSourceUrl);
|
||||||
return repoBase ?? skillSourceUrl;
|
return repoBase ?? skillSourceUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a short, stable label for a pack URL (used in tables and filter pills).
|
||||||
|
*
|
||||||
|
* - For GitHub-like URLs, prefers `owner/repo`.
|
||||||
|
* - For other URLs, falls back to the host.
|
||||||
|
*/
|
||||||
export const packLabelFromUrl = (packUrl: string): string => {
|
export const packLabelFromUrl = (packUrl: string): string => {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(packUrl);
|
const parsed = new URL(packUrl);
|
||||||
@@ -44,6 +72,12 @@ export const packLabelFromUrl = (packUrl: string): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a packs page href filtered to a specific pack URL.
|
||||||
|
*
|
||||||
|
* We use a query param instead of path segments so the packs list can be
|
||||||
|
* shareable/bookmarkable without additional route definitions.
|
||||||
|
*/
|
||||||
export const packsHrefFromPackUrl = (packUrl: string): string => {
|
export const packsHrefFromPackUrl = (packUrl: string): string => {
|
||||||
const params = new URLSearchParams({ source_url: packUrl });
|
const params = new URLSearchParams({ source_url: packUrl });
|
||||||
return `/skills/packs?${params.toString()}`;
|
return `/skills/packs?${params.toString()}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user