This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
@@ -0,0 +1,13 @@
"use client";
import React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
@@ -0,0 +1,312 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import {
Sparkles,
Send,
X,
Loader2,
MessageCircle,
CheckCircle2,
AlertCircle,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { askAsf, jobs } from "@/lib/api";
import { useAppStore } from "@/stores/useAppStore";
import { Button } from "@/components/ui/Button";
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
import { cn, fmtDate, truncate } from "@/lib/utils";
import type { Job } from "@/lib/types";
interface ChatMessage {
id: string;
role: "user" | "system" | "assistant";
kind?: "question" | "info" | "success" | "error";
content: string;
ts: Date;
jobId?: string;
}
export function AskAsfCommandBar() {
const open = useAppStore((s) => s.asfOpen);
const closeAsf = useAppStore((s) => s.closeAsf);
const openAsf = useAppStore((s) => s.openAsf);
const [q, setQ] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: "welcome",
role: "system",
kind: "info",
content:
"Ask anything about your tenant data: requirements without tests, why a build failed, which services implement REQ-441, … This UI wires to the AI Gateway through asf-worker — in bootstrap you'll see a transparent MockProvider response.",
ts: new Date(),
},
]);
const [pollingJobId, setPollingJobId] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// open via global event + ⌘K
useEffect(() => {
function onKey(e: KeyboardEvent) {
const meta = e.metaKey || e.ctrlKey;
if (meta && e.key.toLowerCase() === "k") {
e.preventDefault();
if (open) closeAsf(); else openAsf();
}
if (e.key === "Escape" && open) closeAsf();
}
function onOpenEv() { openAsf(); }
window.addEventListener("keydown", onKey);
window.addEventListener("asf:open-command", onOpenEv);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("asf:open-command", onOpenEv);
};
}, [open, openAsf, closeAsf]);
useEffect(() => {
if (open) setTimeout(() => textareaRef.current?.focus(), 50);
}, [open]);
const submitMutation = useMutation({
mutationFn: (question: string) =>
askAsf.query({ question }),
onMutate: (question) => {
setMessages((m) => [
...m,
{ id: "u" + Date.now(), role: "user", kind: "question", content: question, ts: new Date() },
]);
},
onSuccess: (resp) => {
setMessages((m) => [
...m,
{
id: "subm" + Date.now(),
role: "system",
kind: "info",
content: `Query accepted. Polling job ${resp.job_id.slice(0, 8)}… — status: queued`,
ts: new Date(),
jobId: resp.job_id,
},
]);
setPollingJobId(resp.job_id);
},
onError: (err: Error) => {
setMessages((m) => [
...m,
{ id: "e" + Date.now(), role: "system", kind: "error", content: err.message, ts: new Date() },
]);
},
});
// long-poll the accepted job until terminal
useQuery({
queryKey: ["ask-job", pollingJobId],
enabled: !!pollingJobId,
refetchInterval: (q) => (q.state.data?.status === "completed" || q.state.data?.status === "failed" || q.state.data?.status === "cancelled" ? false : 1500),
queryFn: async (): Promise<Job | undefined> => {
if (!pollingJobId) return undefined;
const job = await jobs.get(pollingJobId);
if (job && (job.status === "completed" || job.status === "failed" || job.status === "cancelled")) {
setPollingJobId(null);
setMessages((m) => {
// avoid adding duplicate result messages
if (m.some(x => x.id === `res-${job.id}`)) return m;
if (job.status === "completed" && job.result) {
const answer =
(job.result as Record<string, unknown>).answer as string ||
JSON.stringify(job.result, null, 2);
return [
...m,
{
id: `res-${job.id}`,
role: "assistant",
kind: "success",
content: answer,
ts: new Date(),
jobId: job.id,
},
];
}
if (job.status === "failed") {
return [
...m,
{
id: `fail-${job.id}`,
role: "system",
kind: "error",
content: job.error || "Unknown AI job failure.",
ts: new Date(),
jobId: job.id,
},
];
}
return m;
});
}
return job;
},
});
function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!q.trim()) return;
submitMutation.mutate(q.trim());
setQ("");
}
const [expanded, setExpanded] = useState(true);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4 bg-slate-950/40 backdrop-blur-[2px] animate-fade-in"
onClick={closeAsf}
>
<div
className="w-full max-w-2xl bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* header */}
<div className="flex items-center gap-3 px-5 py-3.5 border-b border-slate-200 bg-gradient-to-r from-brand-50/60 to-transparent">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-accent grid place-items-center text-white shadow-sm">
<Sparkles className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-slate-800 leading-tight">Ask ASF</div>
<div className="text-[11px] text-slate-500 leading-tight">
Context-aware queries backed by the ASF AI Gateway
</div>
</div>
<Badge tone="violet">
<MessageCircle className="w-3 h-3 mr-1" />
mock provider
</Badge>
<button className="asf-btn-ghost !p-1.5" onClick={closeAsf} aria-label="Close">
<X className="w-4.5 h-4.5 w-[18px] h-[18px]" />
</button>
</div>
{/* messages */}
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
{messages.map((m) => (
<MessageBubble key={m.id} m={m} />
))}
{submitMutation.isPending && (
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Submitting query to ASF AI job queue…
</div>
)}
{pollingJobId && (
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Polling job <code className="asf-monospace text-slate-700 bg-slate-50 rounded px-1.5 py-0.5 border border-slate-200">{pollingJobId.slice(0,12)}</code> until terminal state…
</div>
)}
</div>
{/* toggle for expanded mini-chat */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="hidden w-full items-center justify-center gap-1 py-2 text-xs text-slate-500 border-t border-slate-100 hover:bg-slate-50"
>
{expanded ? <>Collapse <ChevronDown className="w-3 h-3" /></> : <>Expand <ChevronUp className="w-3 h-3" /></>}
</button>
{/* input */}
<form onSubmit={onSubmit} className="px-5 py-3.5 border-t border-slate-200 bg-slate-50/50 flex gap-2">
<textarea
ref={textareaRef}
rows={2}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(e);
}}
placeholder={
expanded
? 'e.g. "Show requirements without tests", "Which services implement REQ-441?"'
: "Ask ASF… (⌘↩ to send)"
}
className="asf-input resize-none"
/>
<Button
type="submit"
variant="primary"
className="self-end shrink-0"
disabled={!q.trim() || submitMutation.isPending}
>
{submitMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
<span className="hidden md:inline">Send</span>
</Button>
</form>
<div className="px-5 pb-4 flex flex-wrap gap-2 bg-slate-50/50">
{[
"Show requirements without tests.",
"Why did yesterday's build fail?",
"Which services implement REQ-441?",
"Generate tests for TASK-921.",
].map((s) => (
<button
key={s}
type="button"
onClick={() => setQ(s)}
className="asf-chip hover:bg-brand-50 hover:text-brand-700 hover:border-brand-100 transition-colors"
>
{s}
</button>
))}
</div>
</div>
</div>
);
}
function MessageBubble({ m }: { m: ChatMessage }) {
const Icon =
m.kind === "success" ? CheckCircle2 :
m.kind === "error" ? AlertCircle :
m.kind === "question" ? MessageCircle : MessageCircle;
const tone =
m.role === "assistant" ? "bg-brand-50 border-brand-100" :
m.role === "user" ? "bg-slate-50 border-slate-200" :
m.kind === "error" ? "bg-red-50 border-red-100" :
"bg-white border-slate-200";
const toneIcon =
m.kind === "success" ? "text-emerald-600" :
m.kind === "error" ? "text-red-600" :
m.role === "user" ? "text-slate-600" :
"text-brand-600";
return (
<div className={cn("flex gap-3 animate-fade-in")}>
<div className={cn("w-7 h-7 rounded-lg grid place-items-center shrink-0 border", tone)}>
<Icon className={cn("w-4 h-4", toneIcon)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-medium capitalize text-slate-600">{m.role}</span>
<span>· {fmtDate(m.ts)}</span>
{m.jobId && <JobStatusBadge status={"queued"} />}
</div>
<div className={cn(
"mt-1 rounded-lg border text-sm px-3 py-2 whitespace-pre-wrap break-words",
tone,
m.role === "assistant" ? "asf-monospace !font-sans leading-relaxed" : "",
)}>
{truncate(m.content, 1200)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
ClipboardList,
Search,
Code2,
GitBranch,
Bot,
GitFork,
Rocket,
ShieldCheck,
Activity,
Plug,
BarChart3,
Settings2,
Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
badge?: string;
}
export const NAV: NavItem[] = [
{ href: "/", label: "Overview", icon: LayoutDashboard },
{ href: "/requirements", label: "Requirements", icon: ClipboardList },
{ href: "/context", label: "Context Explorer", icon: Search },
{ href: "/code", label: "Code", icon: Code2 },
{ href: "/traceability", label: "Traceability", icon: GitBranch },
{ href: "/ask-asf", label: "Ask ASF", icon: Sparkles, badge: "AI" },
{ href: "/jobs", label: "Jobs", icon: Activity },
{ href: "/ai-agents", label: "AI Agents", icon: Bot, badge: "α" },
{ href: "/pipelines", label: "Pipelines", icon: GitFork },
{ href: "/deployments", label: "Deployments", icon: Rocket },
{ href: "/quality", label: "Quality", icon: ShieldCheck },
{ href: "/observability", label: "Observability", icon: Activity },
{ href: "/integrations", label: "Integrations", icon: Plug },
{ href: "/usage", label: "Usage", icon: BarChart3 },
{ href: "/settings", label: "Settings", icon: Settings2 },
];
export function Sidebar() {
const path = usePathname();
return (
<aside className="hidden lg:flex w-64 shrink-0 border-r border-slate-200/80 bg-white/60 backdrop-blur-sm flex-col">
<div className="h-16 flex items-center gap-3 px-5 border-b border-slate-200/80">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-brand-500 to-accent shadow-soft grid place-items-center text-white font-bold">
A
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-800 leading-tight">
{process.env.NEXT_PUBLIC_APP_NAME || "ASF"}
</div>
<div className="text-[11px] text-slate-500 leading-tight">
Software Factory
</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-0.5">
{NAV.map(({ href, label, icon: Icon, badge }) => {
const active = href === "/" ? path === "/" : path.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn("asf-nav-item", active && "asf-nav-item-active")}
>
<Icon className="w-4.5 h-4.5 w-[18px] h-[18px] shrink-0" />
<span className="flex-1">{label}</span>
{badge && (
<span className="asf-chip border-accent/20 bg-accent/10 text-accent-muted !px-1.5">
{badge}
</span>
)}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-slate-200/80">
<button
onClick={() => {
// keyboard shortcut is handled in layout; this button also triggers.
const ev = new CustomEvent("asf:open-command");
window.dispatchEvent(ev);
}}
className="w-full asf-btn-secondary justify-between !px-3"
type="button"
>
<span className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-accent" />
<span className="text-slate-700">Ask ASF…</span>
</span>
<kbd className="asf-chip !text-[10px]">⌘ K</kbd>
</button>
<p className="mt-3 text-[11px] text-slate-400 leading-relaxed">
Tip: every lifecycle element in ASF is an <span className="font-mono text-slate-600 bg-slate-100 rounded px-1">Artifact</span>.
</p>
</div>
</aside>
);
}
@@ -0,0 +1,127 @@
"use client";
import React, { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Building2,
FolderKanban,
Bell,
Search,
ChevronDown,
UserCircle,
} from "lucide-react";
import { orgs, projects } from "@/lib/api";
import { Dropdown, type Option } from "@/components/ui/Dropdown";
import { Button } from "@/components/ui/Button";
import { useAppStore } from "@/stores/useAppStore";
import { cn } from "@/lib/utils";
export function Topbar({ className }: { className?: string }) {
const setOrg = useAppStore((s) => s.setOrg);
const setProject = useAppStore((s) => s.setProject);
const currentOrg = useAppStore((s) => s.org_id);
const currentProject = useAppStore((s) => s.project_id);
const openAsf = useAppStore((s) => s.openAsf);
const { data: orgList = [] } = useQuery({
queryKey: ["orgs"],
queryFn: () => orgs.list({ limit: 50 }),
initialData: [],
});
const { data: projectList = [] } = useQuery({
queryKey: ["projects"],
queryFn: () => projects.list({ limit: 100 }),
initialData: [],
enabled: !!currentOrg,
});
const orgOptions: Option<string>[] = orgList.map((o) => ({
value: o.id,
label: o.name,
description: o.slug,
}));
if (orgOptions.length === 0) {
// Dev-mode synthetic org (matches NEXT_PUBLIC_DEV_TENANT_ID)
orgOptions.push({
value: currentOrg || "00000000-0000-0000-0000-000000000001",
label: "Azurian Labs",
description: "azurian-labs (bootstrap demo)",
});
}
const projectOptions: Option<string>[] = projectList.map((p) => ({
value: p.id,
label: `${p.key} · ${p.name}`,
description: p.description || undefined,
}));
// Add a synthetic "All projects" option
projectOptions.unshift({
value: "__all__",
label: "All projects",
description: "Tenant-wide view",
});
useEffect(() => {
if (!currentProject && projectList.length > 0) {
setProject(projectList[0].id);
}
}, [currentProject, projectList, setProject]);
return (
<header className={cn(
"h-16 shrink-0 border-b border-slate-200/80 bg-white/80 backdrop-blur",
"flex items-center gap-4 px-4 md:px-6",
className,
)}>
{/* Mobile brand */}
<div className="lg:hidden flex items-center gap-2 font-semibold text-slate-800">
<div className="w-8 h-8 rounded-md bg-gradient-to-br from-brand-500 to-accent text-white grid place-items-center text-sm font-bold">
A
</div>
ASF
</div>
<div className="flex-1 max-w-xl hidden md:block">
<button
type="button"
onClick={openAsf}
className="w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-slate-500
bg-slate-50 hover:bg-slate-100 border border-slate-200 transition-colors text-left"
>
<Search className="w-4 h-4 text-slate-400 group-hover:text-slate-600" />
<span className="flex-1">Search artifacts, projects, requirements… or press <span className="font-medium text-slate-600">⌘ K</span></span>
<kbd className="asf-chip !text-[10px]">Search</kbd>
</button>
</div>
<div className="ml-auto flex items-center gap-2 md:gap-3">
<Dropdown
label="Organization"
size="sm"
icon={<Building2 className="w-4 h-4" />}
options={orgOptions}
value={currentOrg}
onChange={(v) => {
setOrg(v);
setProject(undefined);
}}
/>
<Dropdown
label="Project"
size="sm"
icon={<FolderKanban className="w-4 h-4" />}
options={projectOptions}
value={currentProject === undefined ? "__all__" : currentProject}
onChange={(v) => setProject(v === "__all__" ? undefined : v)}
/>
<Button variant="ghost" size="md" aria-label="Notifications">
<Bell className="w-4.5 h-4.5 w-[18px] h-[18px]" />
</Button>
<div className="h-9 w-9 rounded-full bg-slate-100 border border-slate-200 grid place-items-center text-slate-600">
<UserCircle className="w-5 h-5" />
</div>
</div>
</header>
);
}