Merge pull request 'fix(chat): restrict to authenticated users only, fix overlay transparency' (#672) from fix/chat-auth-only into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed

This commit was merged in pull request #672.
This commit is contained in:
2026-03-04 20:15:13 +00:00
5 changed files with 53 additions and 158 deletions

View File

@@ -80,8 +80,8 @@
"session_id": "sess-002", "session_id": "sess-002",
"runtime": "unknown", "runtime": "unknown",
"started_at": "2026-02-28T20:30:13Z", "started_at": "2026-02-28T20:30:13Z",
"ended_at": "", "ended_at": "2026-03-04T13:45:06Z",
"ended_reason": "", "ended_reason": "completed",
"milestone_at_end": "", "milestone_at_end": "",
"tasks_completed": [], "tasks_completed": [],
"last_task_id": "" "last_task_id": ""

View File

@@ -1,8 +0,0 @@
{
"session_id": "sess-002",
"runtime": "unknown",
"pid": 3178395,
"started_at": "2026-02-28T20:30:13Z",
"project_path": "/tmp/ms21-ui-001",
"milestone_id": ""
}

View File

@@ -342,6 +342,31 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
)} )}
{/* Input Area */} {/* Input Area */}
{!user && (
<div className="mx-4 mb-2 lg:mx-auto lg:max-w-4xl lg:px-8">
<div
className="flex items-center justify-center gap-2 rounded-lg border px-4 py-3 text-center"
style={{
backgroundColor: "rgb(var(--surface-1))",
borderColor: "rgb(var(--border-default))",
}}
>
<svg
className="h-4 w-4"
style={{ color: "rgb(var(--text-secondary))" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span className="text-sm" style={{ color: "rgb(var(--text-secondary))" }}>
Sign in to chat with Jarvis
</span>
</div>
</div>
)}
<div <div
className="sticky bottom-0 border-t" className="sticky bottom-0 border-t"
style={{ style={{
@@ -352,7 +377,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8"> <div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
<ChatInput <ChatInput
onSend={handleSendMessage} onSend={handleSendMessage}
disabled={isChatLoading} disabled={isChatLoading || !user}
inputRef={inputRef} inputRef={inputRef}
isStreaming={isStreaming} isStreaming={isStreaming}
onStopStreaming={abortStream} onStopStreaming={abortStream}

View File

@@ -55,8 +55,8 @@ export function ChatOverlay(): React.JSX.Element {
onClick={open} onClick={open}
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8" className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8"
style={{ style={{
backgroundColor: "rgb(var(--accent-primary))", backgroundColor: "var(--accent-primary, #10b981)",
color: "rgb(var(--text-on-accent))", color: "var(--text-on-accent, #ffffff)",
}} }}
aria-label="Open chat" aria-label="Open chat"
title="Open Jarvis chat (Cmd+Shift+J)" title="Open Jarvis chat (Cmd+Shift+J)"
@@ -78,18 +78,18 @@ export function ChatOverlay(): React.JSX.Element {
if (isMinimized) { if (isMinimized) {
return ( return (
<div <div
className="fixed bottom-0 right-0 z-40 w-full sm:w-96" className="fixed bottom-0 right-0 z-40 w-full shadow-2xl sm:w-96"
style={{ style={{
backgroundColor: "rgb(var(--surface-0))", backgroundColor: "var(--surface-0, #ffffff)",
borderColor: "rgb(var(--border-default))", borderColor: "var(--border-default, #e5e7eb)",
}} }}
> >
<button <button
onClick={expand} onClick={expand}
className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset" className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset"
style={{ style={{
borderColor: "rgb(var(--border-default))", borderColor: "var(--border-default, #e5e7eb)",
backgroundColor: "rgb(var(--surface-0))", backgroundColor: "var(--surface-0, #ffffff)",
}} }}
aria-label="Expand chat" aria-label="Expand chat"
> >
@@ -135,10 +135,10 @@ export function ChatOverlay(): React.JSX.Element {
{/* Chat Panel */} {/* Chat Panel */}
<div <div
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l sm:w-96 lg:inset-y-16" className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l shadow-2xl sm:w-96 lg:inset-y-16"
style={{ style={{
backgroundColor: "rgb(var(--surface-0))", backgroundColor: "var(--surface-0, #ffffff)",
borderColor: "rgb(var(--border-default))", borderColor: "var(--border-default, #e5e7eb)",
}} }}
> >
{/* Header */} {/* Header */}

View File

@@ -4,12 +4,7 @@
*/ */
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef } from "react";
import { import { streamChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
sendChatMessage,
streamChatMessage,
streamGuestChat,
type ChatMessage as ApiChatMessage,
} from "@/lib/api/chat";
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas"; import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json"; import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
@@ -219,8 +214,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
const controller = new AbortController(); const controller = new AbortController();
abortControllerRef.current = controller; abortControllerRef.current = controller;
let streamingSucceeded = false;
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let hasReceivedData = false; let hasReceivedData = false;
@@ -248,7 +241,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
}); });
}, },
() => { () => {
streamingSucceeded = true;
setIsStreaming(false); setIsStreaming(false);
abortControllerRef.current = null; abortControllerRef.current = null;
resolve(); resolve();
@@ -279,70 +271,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
return; return;
} }
// Streaming failed - check if auth error, try guest mode // Streaming failed — show error (no guest fallback, auth required)
const isAuthError = console.warn("Streaming failed", {
err instanceof Error &&
(err.message.includes("403") ||
err.message.includes("401") ||
err.message.includes("auth") ||
err.message.includes("Forbidden"));
if (isAuthError) {
console.warn("Auth failed, trying guest chat mode");
// Try guest chat streaming
try {
await new Promise<void>((guestResolve, guestReject) => {
let hasReceivedData = false;
streamGuestChat(
request,
(chunk: string) => {
if (!hasReceivedData) {
hasReceivedData = true;
setIsLoading(false);
setIsStreaming(true);
setMessages((prev) => {
const updated = [...prev, { ...placeholderMessage }];
messagesRef.current = updated;
return updated;
});
}
setMessages((prev) => {
const updated = prev.map((msg) =>
msg.id === assistantMessageId ? { ...msg, content: msg.content + chunk } : msg
);
messagesRef.current = updated;
return updated;
});
},
() => {
streamingSucceeded = true;
setIsStreaming(false);
guestResolve();
},
(guestErr: Error) => {
guestReject(guestErr);
},
controller.signal
);
});
} catch (guestErr: unknown) {
// Guest also failed
setMessages((prev) => {
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
messagesRef.current = withoutPlaceholder;
return withoutPlaceholder;
});
const errorMsg = guestErr instanceof Error ? guestErr.message : "Chat unavailable";
setError(`Unable to connect to chat: ${errorMsg}`);
setIsLoading(false);
return;
}
} else {
// Streaming failed — fall back to non-streaming
console.warn("Streaming failed, falling back to non-streaming", {
error: err instanceof Error ? err : new Error(String(err)), error: err instanceof Error ? err : new Error(String(err)),
}); });
@@ -352,67 +282,15 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
return withoutPlaceholder; return withoutPlaceholder;
}); });
setIsStreaming(false); setIsStreaming(false);
try {
const response = await sendChatMessage(request);
const assistantMessage: Message = {
id: `assistant-${Date.now().toString()}`,
role: "assistant",
content: response.message.content,
createdAt: new Date().toISOString(),
model: response.model,
promptTokens: response.promptEvalCount ?? 0,
completionTokens: response.evalCount ?? 0,
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
};
setMessages((prev) => {
const updated = [...prev, assistantMessage];
messagesRef.current = updated;
return updated;
});
streamingSucceeded = true;
} catch (fallbackErr: unknown) {
const errorMsg =
fallbackErr instanceof Error ? fallbackErr.message : "Failed to send message";
setError("Unable to send message. Please try again.");
onError?.(fallbackErr instanceof Error ? fallbackErr : new Error(errorMsg));
console.error("Failed to send chat message", {
error: fallbackErr,
errorType: "LLM_ERROR",
conversationId: conversationIdRef.current,
messageLength: content.length,
messagePreview: content.substring(0, 50),
model,
messageCount: messagesRef.current.length,
timestamp: new Date().toISOString(),
});
const errorMessage: Message = {
id: `error-${String(Date.now())}`,
role: "assistant",
content: "Something went wrong. Please try again.",
createdAt: new Date().toISOString(),
};
setMessages((prev) => {
const updated = [...prev, errorMessage];
messagesRef.current = updated;
return updated;
});
setIsLoading(false); setIsLoading(false);
const errorMsg = err instanceof Error ? err.message : "Chat unavailable";
setError(`Chat error: ${errorMsg}`);
return; return;
} }
}
}
setIsLoading(false); setIsLoading(false);
if (!streamingSucceeded) {
return;
}
const finalMessages = messagesRef.current; const finalMessages = messagesRef.current;
const isFirstMessage = const isFirstMessage =