feat(assistant): add approval inbox and e2e hardening
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const ADMIN_EMAIL = "admin@capakraken.dev";
|
||||
const ADMIN_PASSWORD = "admin123";
|
||||
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
|
||||
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
|
||||
const WEB_ENV_PATHS = [
|
||||
resolve(process.cwd(), ".env.local"),
|
||||
resolve(process.cwd(), "apps/web/.env.local"),
|
||||
];
|
||||
|
||||
function resolveDatabaseUrl(): string {
|
||||
const explicitPlaywrightUrl = process.env["PLAYWRIGHT_DATABASE_URL"];
|
||||
if (explicitPlaywrightUrl) {
|
||||
return explicitPlaywrightUrl;
|
||||
}
|
||||
|
||||
for (const envPath of WEB_ENV_PATHS) {
|
||||
if (!existsSync(envPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const envFile = readFileSync(envPath, "utf8");
|
||||
for (const rawLine of envFile.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("PLAYWRIGHT_DATABASE_URL=")) {
|
||||
return line.slice("PLAYWRIGHT_DATABASE_URL=".length);
|
||||
}
|
||||
if (line.startsWith("DATABASE_URL_TEST=")) {
|
||||
return line.slice("DATABASE_URL_TEST=".length);
|
||||
}
|
||||
if (line.startsWith("DATABASE_URL=")) {
|
||||
return line.slice("DATABASE_URL=".length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackTestUrl = process.env["DATABASE_URL_TEST"];
|
||||
if (fallbackTestUrl) {
|
||||
return fallbackTestUrl;
|
||||
}
|
||||
|
||||
const fallback = process.env["DATABASE_URL"];
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
throw new Error("DATABASE_URL is not available for the Playwright assistant approval test.");
|
||||
}
|
||||
|
||||
function runDbJson<T>(body: string): T {
|
||||
const script = `
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
${body}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
`;
|
||||
|
||||
const output = execFileSync("node", ["--input-type=module", "-e", script], {
|
||||
cwd: DB_WORKDIR,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: resolveDatabaseUrl(),
|
||||
},
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
function runDb(body: string): void {
|
||||
runDbJson<null>(`${body}\nconsole.log("null");`);
|
||||
}
|
||||
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', ADMIN_EMAIL);
|
||||
await page.fill('input[type="password"]', ADMIN_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
}
|
||||
|
||||
test.describe("Assistant approvals", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((conversationId) => {
|
||||
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
|
||||
}, CURRENT_CONVERSATION_ID);
|
||||
|
||||
runDb(`
|
||||
await prisma.systemSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: {
|
||||
aiProvider: "openai",
|
||||
azureOpenAiApiKey: "e2e-dummy-key",
|
||||
azureOpenAiDeployment: "gpt-4o-mini",
|
||||
},
|
||||
create: {
|
||||
id: "singleton",
|
||||
aiProvider: "openai",
|
||||
azureOpenAiApiKey: "e2e-dummy-key",
|
||||
azureOpenAiDeployment: "gpt-4o-mini",
|
||||
},
|
||||
});
|
||||
|
||||
const admin = await prisma.user.findUniqueOrThrow({
|
||||
where: { email: ${JSON.stringify(ADMIN_EMAIL)} },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.assistantApproval.deleteMany({
|
||||
where: { userId: admin.id },
|
||||
});
|
||||
|
||||
await prisma.client.deleteMany({
|
||||
where: {
|
||||
name: {
|
||||
startsWith: "E2E Approval Client",
|
||||
},
|
||||
},
|
||||
});
|
||||
`);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
runDb(`
|
||||
await prisma.assistantApproval.deleteMany({
|
||||
where: {
|
||||
conversationId: {
|
||||
startsWith: "assistant-e2e-",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.client.deleteMany({
|
||||
where: {
|
||||
name: {
|
||||
startsWith: "E2E Approval Client",
|
||||
},
|
||||
},
|
||||
});
|
||||
`);
|
||||
});
|
||||
|
||||
test("renders the pending approval inbox and handles cross-conversation actions", async ({ page }) => {
|
||||
const suffix = Date.now();
|
||||
const currentClientName = `E2E Approval Client Current ${suffix}`;
|
||||
const otherClientName = `E2E Approval Client Other ${suffix}`;
|
||||
const otherConversationId = `assistant-e2e-other-${suffix}`;
|
||||
|
||||
const { currentApproval, otherApproval } = runDbJson<{
|
||||
currentApproval: { id: string; summary: string };
|
||||
otherApproval: { id: string; summary: string };
|
||||
}>(`
|
||||
const admin = await prisma.user.findUniqueOrThrow({
|
||||
where: { email: ${JSON.stringify(ADMIN_EMAIL)} },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const currentApproval = await prisma.assistantApproval.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
conversationId: ${JSON.stringify(CURRENT_CONVERSATION_ID)},
|
||||
toolName: "create_client",
|
||||
toolArguments: ${JSON.stringify(JSON.stringify({ name: currentClientName }))},
|
||||
summary: ${JSON.stringify(`create client (name=${currentClientName})`)},
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
},
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
const otherApproval = await prisma.assistantApproval.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
conversationId: ${JSON.stringify(otherConversationId)},
|
||||
toolName: "create_client",
|
||||
toolArguments: ${JSON.stringify(JSON.stringify({ name: otherClientName }))},
|
||||
summary: ${JSON.stringify(`create client (name=${otherClientName})`)},
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
},
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ currentApproval, otherApproval }));
|
||||
`);
|
||||
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/dashboard");
|
||||
await page.getByTestId("assistant-open-button").click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "HartBOT" })).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-open-approvals")).toBeVisible();
|
||||
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
||||
await expect(page.getByText(otherApproval.summary)).toBeVisible();
|
||||
|
||||
const currentCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]').first();
|
||||
const otherCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]').first();
|
||||
await expect(currentCard).toContainText("This chat");
|
||||
await expect(otherCard).toContainText("Other chat");
|
||||
|
||||
await otherCard.getByTestId("assistant-approval-cancel").click();
|
||||
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
|
||||
await expect(page.locator(`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`)).toHaveCount(0);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return runDbJson<string | null>(`
|
||||
const approval = await prisma.assistantApproval.findUnique({
|
||||
where: { id: ${JSON.stringify(otherApproval.id)} },
|
||||
select: { status: true },
|
||||
});
|
||||
console.log(JSON.stringify(approval?.status ?? null));
|
||||
`);
|
||||
})
|
||||
.toBe("CANCELLED");
|
||||
|
||||
await currentCard.getByTestId("assistant-approval-confirm").click();
|
||||
|
||||
await expect(page.getByText(`Ausgeführt: Created client: ${currentClientName}`)).toBeVisible();
|
||||
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return runDbJson<string | null>(`
|
||||
const approval = await prisma.assistantApproval.findUnique({
|
||||
where: { id: ${JSON.stringify(currentApproval.id)} },
|
||||
select: { status: true },
|
||||
});
|
||||
console.log(JSON.stringify(approval?.status ?? null));
|
||||
`);
|
||||
})
|
||||
.toBe("APPROVED");
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return runDbJson<"created" | "missing">(`
|
||||
const client = await prisma.client.findFirst({
|
||||
where: { name: ${JSON.stringify(currentClientName)} },
|
||||
select: { id: true },
|
||||
});
|
||||
console.log(JSON.stringify(client ? "created" : "missing"));
|
||||
`);
|
||||
})
|
||||
.toBe("created");
|
||||
|
||||
await expect(page.getByTestId("assistant-approval-card")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ function writeManagedWebEnv(rootEnv) {
|
||||
|
||||
const contents = managedEnvKeys
|
||||
.map((key) => {
|
||||
const value = rootEnv[key] ?? process.env[key];
|
||||
const value = process.env[key] ?? rootEnv[key];
|
||||
return value ? `${key}=${value}` : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -39,6 +39,17 @@ interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
insights?: AssistantInsight[];
|
||||
approval?: AssistantApproval;
|
||||
}
|
||||
|
||||
interface AssistantApproval {
|
||||
id: string;
|
||||
status: "pending" | "approved" | "cancelled";
|
||||
conversationId: string;
|
||||
toolName: string;
|
||||
summary: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface AssistantInsightMetric {
|
||||
@@ -61,6 +72,19 @@ interface AssistantInsight {
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "capakraken-chat-messages";
|
||||
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
|
||||
|
||||
function isAssistantApproval(value: unknown): value is AssistantApproval {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const approval = value as Partial<AssistantApproval>;
|
||||
return typeof approval.id === "string"
|
||||
&& (approval.status === "pending" || approval.status === "approved" || approval.status === "cancelled")
|
||||
&& typeof approval.conversationId === "string"
|
||||
&& typeof approval.toolName === "string"
|
||||
&& typeof approval.summary === "string"
|
||||
&& typeof approval.createdAt === "string"
|
||||
&& typeof approval.expiresAt === "string";
|
||||
}
|
||||
|
||||
/** Load messages from sessionStorage (survives page reloads, clears on tab close). */
|
||||
function loadPersistedMessages(): Message[] {
|
||||
@@ -81,6 +105,7 @@ function loadPersistedMessages(): Message[] {
|
||||
role: item.role,
|
||||
content: item.content,
|
||||
...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}),
|
||||
...(isAssistantApproval(item.approval) ? { approval: item.approval } : {}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -91,17 +116,47 @@ function loadPersistedMessages(): Message[] {
|
||||
/** Module-level cache — avoids re-parsing sessionStorage on every client-side navigation. */
|
||||
let cachedMessages: Message[] | null = null;
|
||||
|
||||
function generateConversationId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `conversation-${Date.now()}`;
|
||||
}
|
||||
|
||||
function loadConversationId(): string {
|
||||
if (typeof window === "undefined") return generateConversationId();
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CONVERSATION_ID_KEY)?.trim();
|
||||
if (raw) return raw;
|
||||
} catch {
|
||||
// ignore storage access errors
|
||||
}
|
||||
return generateConversationId();
|
||||
}
|
||||
|
||||
export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
const [messages, setMessages] = useState<Message[]>(() => cachedMessages ?? loadPersistedMessages());
|
||||
const [conversationId, setConversationId] = useState<string>(() => loadConversationId());
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvalNotice, setApprovalNotice] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const chatMutation = trpc.assistant.chat.useMutation();
|
||||
const pendingApprovalsQuery = trpc.assistant.listPendingApprovals.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const pendingApprovals = pendingApprovalsQuery.data ?? [];
|
||||
const inlineApprovalIds = new Set(
|
||||
messages
|
||||
.map((message) => message.approval?.id)
|
||||
.filter((approvalId): approvalId is string => typeof approvalId === "string"),
|
||||
);
|
||||
const visiblePendingApprovals = pendingApprovals.filter((approval) => !inlineApprovalIds.has(approval.id));
|
||||
|
||||
// Sync to module-level cache + sessionStorage on every change
|
||||
useEffect(() => {
|
||||
@@ -109,6 +164,10 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch { /* quota exceeded */ }
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
try { sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId); } catch { /* quota exceeded */ }
|
||||
}, [conversationId]);
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -120,37 +179,58 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
const sendMessage = useCallback(async (
|
||||
overrideText?: string,
|
||||
targetConversationId?: string,
|
||||
options?: { persistInCurrentChat?: boolean },
|
||||
) => {
|
||||
const text = (overrideText ?? input).trim();
|
||||
if (!text || isLoading) return;
|
||||
const persistInCurrentChat = options?.persistInCurrentChat ?? true;
|
||||
const activeConversationId = targetConversationId ?? conversationId;
|
||||
|
||||
setInput("");
|
||||
if (!overrideText) {
|
||||
setInput("");
|
||||
}
|
||||
setError(null);
|
||||
setApprovalNotice(null);
|
||||
|
||||
const userMsg: Message = { role: "user", content: text };
|
||||
const updated = [...messages, userMsg];
|
||||
setMessages(updated);
|
||||
const updated = persistInCurrentChat ? [...messages, userMsg] : messages;
|
||||
if (persistInCurrentChat) {
|
||||
setMessages(updated);
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const reply = await chatMutation.mutateAsync({
|
||||
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
|
||||
messages: (persistInCurrentChat ? updated.slice(-40) : [userMsg]).map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})),
|
||||
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
|
||||
conversationId: activeConversationId,
|
||||
});
|
||||
const typedReply = reply as {
|
||||
content: string;
|
||||
role: "assistant";
|
||||
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
|
||||
insights?: AssistantInsight[];
|
||||
approval?: AssistantApproval;
|
||||
};
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: typedReply.content,
|
||||
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
|
||||
},
|
||||
]);
|
||||
if (persistInCurrentChat) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: typedReply.content,
|
||||
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
|
||||
...(isAssistantApproval(typedReply.approval) ? { approval: typedReply.approval } : {}),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setApprovalNotice(typedReply.content);
|
||||
}
|
||||
|
||||
// Handle actions from the AI (navigation, data invalidation)
|
||||
const actions = typedReply.actions;
|
||||
@@ -172,13 +252,14 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
await utils.assistant.listPendingApprovals.invalidate();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Something went wrong";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [input, isLoading, messages, chatMutation, pathname]);
|
||||
}, [conversationId, input, isLoading, messages, chatMutation, pathname, router, utils]);
|
||||
|
||||
// Track user message history for up-arrow recall
|
||||
const userHistory = useRef<string[]>([]);
|
||||
@@ -221,6 +302,8 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setApprovalNotice(null);
|
||||
setConversationId(generateConversationId());
|
||||
cachedMessages = null;
|
||||
try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* noop */ }
|
||||
};
|
||||
@@ -269,6 +352,79 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
|
||||
{(visiblePendingApprovals.length > 0 || approvalNotice) && (
|
||||
<div
|
||||
data-testid="assistant-open-approvals"
|
||||
className="space-y-2 rounded-2xl border border-amber-200 bg-amber-50/80 p-3 text-xs text-amber-950 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-amber-800 dark:text-amber-200">
|
||||
Open approvals
|
||||
</div>
|
||||
{visiblePendingApprovals.length > 0 && (
|
||||
<span className="rounded-full border border-amber-300/80 px-2 py-0.5 text-[11px] font-medium dark:border-amber-800">
|
||||
{visiblePendingApprovals.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{approvalNotice && (
|
||||
<div className="rounded-xl border border-amber-300/80 bg-white/70 px-3 py-2 text-[12px] text-amber-900 dark:border-amber-800 dark:bg-slate-900/40 dark:text-amber-100">
|
||||
{approvalNotice}
|
||||
</div>
|
||||
)}
|
||||
{visiblePendingApprovals.map((approval) => {
|
||||
const belongsToCurrentConversation = approval.conversationId === conversationId;
|
||||
return (
|
||||
<div
|
||||
key={approval.id}
|
||||
data-testid="assistant-approval-card"
|
||||
data-approval-id={approval.id}
|
||||
data-conversation-scope={belongsToCurrentConversation ? "current" : "other"}
|
||||
className="rounded-xl border border-amber-200/90 bg-white/80 px-3 py-2.5 shadow-sm dark:border-amber-900/50 dark:bg-slate-900/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-amber-950 dark:text-amber-50">
|
||||
{approval.summary}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-[11px] text-amber-700 dark:text-amber-200/80">
|
||||
<span>{approval.toolName}</span>
|
||||
<span>Expires {new Date(approval.expiresAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-amber-300/80 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] dark:border-amber-800">
|
||||
{belongsToCurrentConversation ? "This chat" : "Other chat"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="assistant-approval-confirm"
|
||||
onClick={() => void sendMessage("Ja, bitte ausführen.", approval.conversationId, {
|
||||
persistInCurrentChat: belongsToCurrentConversation,
|
||||
})}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg bg-amber-900 px-3 py-1.5 font-medium text-amber-50 transition hover:bg-amber-950 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="assistant-approval-cancel"
|
||||
onClick={() => void sendMessage("Abbrechen.", approval.conversationId, {
|
||||
persistInCurrentChat: belongsToCurrentConversation,
|
||||
})}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-amber-300 px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
<svg className="mb-2 h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -279,12 +435,56 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
{...(msg.insights ? { insights: msg.insights } : {})}
|
||||
/>
|
||||
<div key={i} className="space-y-2">
|
||||
<ChatMessage
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
{...(msg.insights ? { insights: msg.insights } : {})}
|
||||
/>
|
||||
{msg.role === "assistant" && msg.approval && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] rounded-2xl border border-amber-200 bg-amber-50/90 px-3 py-2.5 text-xs text-amber-900 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold uppercase tracking-[0.08em]">
|
||||
{msg.approval.status === "pending" ? "Approval pending" : msg.approval.status}
|
||||
</span>
|
||||
<span className="rounded-full border border-amber-300/70 px-2 py-0.5 font-medium dark:border-amber-800">
|
||||
{msg.approval.toolName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm font-medium text-amber-950 dark:text-amber-50">
|
||||
{msg.approval.summary}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-amber-700 dark:text-amber-200/80">
|
||||
<span>Created: {new Date(msg.approval.createdAt).toLocaleString()}</span>
|
||||
{msg.approval.status === "pending" && (
|
||||
<span>Expires: {new Date(msg.approval.expiresAt).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
{msg.approval.status === "pending" && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sendMessage("Ja, bitte ausführen.")}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg bg-amber-900 px-3 py-1.5 font-medium text-amber-50 transition hover:bg-amber-950 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sendMessage("Abbrechen.")}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-amber-300 px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isLoading && <TypingIndicator />}
|
||||
{error && (
|
||||
|
||||
@@ -910,6 +910,7 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatOpen(true)}
|
||||
data-testid="assistant-open-button"
|
||||
className="fixed bottom-6 right-6 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-brand-600 text-white shadow-lg shadow-brand-600/30 transition-all hover:bg-brand-700 hover:shadow-xl hover:shadow-brand-600/40 active:scale-95"
|
||||
title="HartBOT"
|
||||
>
|
||||
|
||||
@@ -75,6 +75,33 @@ export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry
|
||||
|
||||
export type ViewMode = "resource" | "project";
|
||||
|
||||
function buildTimelineFiltersFromSearchParams(searchParams: ReturnType<typeof useSearchParams>): TimelineFilters {
|
||||
const savedPrefs = readAppPreferences();
|
||||
const next: TimelineFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
hideCompletedProjects: savedPrefs.hideCompletedProjects,
|
||||
showPlaceholders: savedPrefs.showDemandProjects,
|
||||
};
|
||||
|
||||
const eids = searchParams.get("eids");
|
||||
if (eids) next.eids = eids.split(",").filter(Boolean);
|
||||
const projectIds = searchParams.get("projectIds");
|
||||
if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean);
|
||||
const chapters = searchParams.get("chapters");
|
||||
if (chapters) next.chapters = chapters.split(",").filter(Boolean);
|
||||
const clientIds = searchParams.get("clientIds");
|
||||
if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean);
|
||||
const countryCodes = searchParams.get("countryCodes");
|
||||
if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean);
|
||||
|
||||
if (eids || projectIds) {
|
||||
next.showDrafts = true;
|
||||
next.hideCompletedProjects = false;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
// ─── Derived resource type used throughout the timeline ─────────────────────
|
||||
export type ResourceBrief = {
|
||||
id: string;
|
||||
@@ -218,77 +245,31 @@ export function TimelineProvider({
|
||||
const viewEnd = addDays(viewStart, viewDays);
|
||||
|
||||
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
|
||||
const [filters, setFilters] = useState<TimelineFilters>(() => {
|
||||
const savedPrefs = readAppPreferences();
|
||||
const base: TimelineFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
hideCompletedProjects: savedPrefs.hideCompletedProjects,
|
||||
showPlaceholders: savedPrefs.showDemandProjects,
|
||||
};
|
||||
const eids = searchParams.get("eids");
|
||||
if (eids) base.eids = eids.split(",").filter(Boolean);
|
||||
const projectIds = searchParams.get("projectIds");
|
||||
if (projectIds) base.projectIds = projectIds.split(",").filter(Boolean);
|
||||
const chapters = searchParams.get("chapters");
|
||||
if (chapters) base.chapters = chapters.split(",").filter(Boolean);
|
||||
const clientIds = searchParams.get("clientIds");
|
||||
if (clientIds) base.clientIds = clientIds.split(",").filter(Boolean);
|
||||
const countryCodes = searchParams.get("countryCodes");
|
||||
if (countryCodes) base.countryCodes = countryCodes.split(",").filter(Boolean);
|
||||
// If URL params specify filters, also show drafts and don't hide completed
|
||||
if (eids || projectIds) {
|
||||
base.showDrafts = true;
|
||||
base.hideCompletedProjects = false;
|
||||
}
|
||||
return base;
|
||||
});
|
||||
// Track whether this is the initial mount (URL params already applied via useState initializers)
|
||||
const isInitialMount = useRef(true);
|
||||
const [filters, setFilters] = useState<TimelineFilters>(() => buildTimelineFiltersFromSearchParams(searchParams));
|
||||
|
||||
// Sync filters/viewStart/viewDays when URL search params change AFTER initial mount
|
||||
// (e.g. when the AI assistant calls router.push("/timeline?eids=...") while already on /timeline)
|
||||
// Sync filters/viewStart/viewDays from URL params on mount and after later changes
|
||||
// (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline)
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update viewStart if param changed
|
||||
const spStart = searchParams.get("startDate");
|
||||
if (spStart) {
|
||||
const d = new Date(spStart);
|
||||
if (!isNaN(d.getTime())) setViewStart(d);
|
||||
}
|
||||
setViewStart(() => {
|
||||
if (spStart) {
|
||||
const d = new Date(spStart);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
return addDays(today, -30);
|
||||
});
|
||||
|
||||
// Update viewDays if param changed
|
||||
const spDays = searchParams.get("days");
|
||||
if (spDays) {
|
||||
const n = parseInt(spDays, 10);
|
||||
if (n > 0 && n <= 365) setViewDays(n);
|
||||
}
|
||||
setViewDays(() => {
|
||||
if (spDays) {
|
||||
const n = parseInt(spDays, 10);
|
||||
if (n > 0 && n <= 365) return n;
|
||||
}
|
||||
return 180;
|
||||
});
|
||||
|
||||
// Update filters if any filter params present
|
||||
const eids = searchParams.get("eids");
|
||||
const projectIds = searchParams.get("projectIds");
|
||||
const chapters = searchParams.get("chapters");
|
||||
const clientIds = searchParams.get("clientIds");
|
||||
const countryCodes = searchParams.get("countryCodes");
|
||||
if (eids || projectIds || chapters || clientIds || countryCodes) {
|
||||
setFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
if (eids) next.eids = eids.split(",").filter(Boolean);
|
||||
if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean);
|
||||
if (chapters) next.chapters = chapters.split(",").filter(Boolean);
|
||||
if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean);
|
||||
if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean);
|
||||
if (eids || projectIds) {
|
||||
next.showDrafts = true;
|
||||
next.hideCompletedProjects = false;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [searchParams]);
|
||||
setFilters(buildTimelineFiltersFromSearchParams(searchParams));
|
||||
}, [searchParams, today]);
|
||||
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("resource");
|
||||
@@ -301,7 +282,9 @@ export function TimelineProvider({
|
||||
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
|
||||
|
||||
// ─── Data queries ──────────────────────────────────────────────────────────
|
||||
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const entriesViewQuery = trpc.timeline.getEntriesView.useQuery(
|
||||
{
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
@@ -314,17 +297,29 @@ export function TimelineProvider({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
|
||||
) as {
|
||||
data: TimelineEntriesView | undefined;
|
||||
isLoading: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery;
|
||||
|
||||
const assignments = entriesView?.assignments ?? [];
|
||||
const demands = entriesView?.demands ?? [];
|
||||
|
||||
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
|
||||
const {
|
||||
data: vacationEntries = [],
|
||||
refetch: refetchVacations,
|
||||
} = trpc.vacation.list.useQuery(
|
||||
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
|
||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
);
|
||||
|
||||
const { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery(
|
||||
const {
|
||||
data: holidayOverlayEntries = [],
|
||||
refetch: refetchHolidayOverlays,
|
||||
} = trpc.timeline.getHolidayOverlays.useQuery(
|
||||
{
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
@@ -337,6 +332,17 @@ export function TimelineProvider({
|
||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) return;
|
||||
mountedRef.current = true;
|
||||
|
||||
// Harden client-side route transitions: the timeline must actively refresh
|
||||
// its core read models once on mount instead of relying on a prefetched shell.
|
||||
void refetchEntriesView();
|
||||
void refetchVacations();
|
||||
void refetchHolidayOverlays();
|
||||
}, [refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
|
||||
|
||||
const vacationsByResource = useMemo(() => {
|
||||
const map = new Map<string, VacationEntry[]>();
|
||||
const mergedEntries = [...(vacationEntries as VacationEntry[])];
|
||||
|
||||
Reference in New Issue
Block a user