fix(types): remove unnecessary as any casts in web components
- ProjectHealthWidget: row already typed as ProjectHealthRow with id field - ResourceDetail: use narrowed unknown cast instead of any for error code - provider.tsx: same pattern for TRPCClientError data access - ChatPanel: use intersection type for Next.js typed route push Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,13 +77,17 @@ 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";
|
||||
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). */
|
||||
@@ -95,21 +99,26 @@ function loadPersistedMessages(): Message[] {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.filter((item): item is Partial<Message> & { role: Message["role"]; content: string } => (
|
||||
typeof item === "object"
|
||||
&& item !== null
|
||||
&& (item.role === "user" || item.role === "assistant")
|
||||
&& typeof item.content === "string"
|
||||
))
|
||||
.filter(
|
||||
(item): item is Partial<Message> & { role: Message["role"]; content: string } =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
(item.role === "user" || item.role === "assistant") &&
|
||||
typeof item.content === "string",
|
||||
)
|
||||
.map((item) => ({
|
||||
role: item.role,
|
||||
content: item.content,
|
||||
...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}),
|
||||
...(Array.isArray(item.insights)
|
||||
? { insights: item.insights as AssistantInsight[] }
|
||||
: {}),
|
||||
...(isAssistantApproval(item.approval) ? { approval: item.approval } : {}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore corrupt data */ }
|
||||
} catch {
|
||||
/* ignore corrupt data */
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -138,7 +147,9 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
const [messages, setMessages] = useState<Message[]>(() => cachedMessages ?? loadPersistedMessages());
|
||||
const [messages, setMessages] = useState<Message[]>(
|
||||
() => cachedMessages ?? loadPersistedMessages(),
|
||||
);
|
||||
const [conversationId, setConversationId] = useState<string>(() => loadConversationId());
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -156,16 +167,26 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
.map((message) => message.approval?.id)
|
||||
.filter((approvalId): approvalId is string => typeof approvalId === "string"),
|
||||
);
|
||||
const visiblePendingApprovals = pendingApprovals.filter((approval) => !inlineApprovalIds.has(approval.id));
|
||||
const visiblePendingApprovals = pendingApprovals.filter(
|
||||
(approval) => !inlineApprovalIds.has(approval.id),
|
||||
);
|
||||
|
||||
// Sync to module-level cache + sessionStorage on every change
|
||||
useEffect(() => {
|
||||
cachedMessages = messages;
|
||||
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch { /* quota exceeded */ }
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
||||
} catch {
|
||||
/* quota exceeded */
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
try { sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId); } catch { /* quota exceeded */ }
|
||||
try {
|
||||
sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId);
|
||||
} catch {
|
||||
/* quota exceeded */
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
@@ -179,90 +200,96 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
if (!overrideText) {
|
||||
setInput("");
|
||||
}
|
||||
setError(null);
|
||||
setApprovalNotice(null);
|
||||
|
||||
const userMsg: Message = { role: "user", content: text };
|
||||
const updated = persistInCurrentChat ? [...messages, userMsg] : messages;
|
||||
if (persistInCurrentChat) {
|
||||
setMessages(updated);
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const reply = await chatMutation.mutateAsync({
|
||||
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;
|
||||
};
|
||||
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);
|
||||
if (!overrideText) {
|
||||
setInput("");
|
||||
}
|
||||
setError(null);
|
||||
setApprovalNotice(null);
|
||||
|
||||
// Handle actions from the AI (navigation, data invalidation)
|
||||
const actions = typedReply.actions;
|
||||
if (actions) {
|
||||
for (const action of actions) {
|
||||
if (action.type === "navigate" && action.url) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(action.url as any);
|
||||
} else if (action.type === "invalidate" && action.scope) {
|
||||
// Invalidate relevant tRPC queries so the UI refreshes
|
||||
for (const scope of action.scope) {
|
||||
if (scope === "allocation" || scope === "timeline") {
|
||||
void utils.allocation.invalidate();
|
||||
void utils.timeline.invalidate();
|
||||
const userMsg: Message = { role: "user", content: text };
|
||||
const updated = persistInCurrentChat ? [...messages, userMsg] : messages;
|
||||
if (persistInCurrentChat) {
|
||||
setMessages(updated);
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const reply = await chatMutation.mutateAsync({
|
||||
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;
|
||||
};
|
||||
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;
|
||||
if (actions) {
|
||||
for (const action of actions) {
|
||||
if (action.type === "navigate" && action.url) {
|
||||
router.push(action.url as string & {});
|
||||
} else if (action.type === "invalidate" && action.scope) {
|
||||
// Invalidate relevant tRPC queries so the UI refreshes
|
||||
for (const scope of action.scope) {
|
||||
if (scope === "allocation" || scope === "timeline") {
|
||||
void utils.allocation.invalidate();
|
||||
void utils.timeline.invalidate();
|
||||
}
|
||||
if (scope === "resource") void utils.resource.invalidate();
|
||||
if (scope === "project") void utils.project.invalidate();
|
||||
if (scope === "country") void utils.country.invalidate();
|
||||
if (scope === "holidayCalendar") void utils.holidayCalendar.invalidate();
|
||||
if (scope === "vacation") void utils.vacation.invalidate();
|
||||
}
|
||||
if (scope === "resource") void utils.resource.invalidate();
|
||||
if (scope === "project") void utils.project.invalidate();
|
||||
if (scope === "country") void utils.country.invalidate();
|
||||
if (scope === "holidayCalendar") void utils.holidayCalendar.invalidate();
|
||||
if (scope === "vacation") void utils.vacation.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
await utils.assistant.listPendingApprovals.invalidate();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Something went wrong";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
await utils.assistant.listPendingApprovals.invalidate();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Something went wrong";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [conversationId, input, isLoading, messages, chatMutation, pathname, router, utils]);
|
||||
},
|
||||
[conversationId, input, isLoading, messages, chatMutation, pathname, router, utils],
|
||||
);
|
||||
|
||||
// Track user message history for up-arrow recall
|
||||
const userHistory = useRef<string[]>([]);
|
||||
@@ -277,9 +304,10 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "ArrowUp" && input === "" && userHistory.current.length > 0) {
|
||||
e.preventDefault();
|
||||
const nextIdx = historyIndex.current < 0
|
||||
? userHistory.current.length - 1
|
||||
: Math.max(0, historyIndex.current - 1);
|
||||
const nextIdx =
|
||||
historyIndex.current < 0
|
||||
? userHistory.current.length - 1
|
||||
: Math.max(0, historyIndex.current - 1);
|
||||
historyIndex.current = nextIdx;
|
||||
setInput(userHistory.current[nextIdx] ?? "");
|
||||
return;
|
||||
@@ -308,7 +336,11 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
setApprovalNotice(null);
|
||||
setConversationId(generateConversationId());
|
||||
cachedMessages = null;
|
||||
try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* noop */ }
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -318,7 +350,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-brand-600 text-white">
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">HartBOT</h2>
|
||||
@@ -332,7 +369,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
title="Chat leeren"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
@@ -342,7 +384,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-slate-800 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -403,9 +450,11 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
data-testid="assistant-approval-confirm"
|
||||
onClick={() => void sendMessage("Ja, bitte ausführen.", approval.conversationId, {
|
||||
persistInCurrentChat: belongsToCurrentConversation,
|
||||
})}
|
||||
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"
|
||||
>
|
||||
@@ -414,9 +463,11 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
data-testid="assistant-approval-cancel"
|
||||
onClick={() => void sendMessage("Abbrechen.", approval.conversationId, {
|
||||
persistInCurrentChat: belongsToCurrentConversation,
|
||||
})}
|
||||
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"
|
||||
>
|
||||
@@ -431,7 +482,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
{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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="font-medium">Frag mich etwas!</p>
|
||||
<p className="mt-1 text-xs">z.B. "Welche Ressourcen gibt es?"</p>
|
||||
@@ -522,7 +578,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white transition-colors hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5l7 7-7 7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 12h14M12 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user