Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

repo-chat.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

repo-chat.tsxBlame966 lines · 1 contributor
38d31d3Claude1/**
2 * AI rubber-duck chat — repo-grounded, streaming, with citations.
3 *
4 * GET /:owner/:repo/chat — chat home (new chat shell)
5 * GET /:owner/:repo/chat/:chatId — resume an existing thread
6 * POST /:owner/:repo/chat — non-streaming form submit
7 * (works with JS disabled)
8 *
9 * The streaming endpoint lives in `src/routes/api-v2.ts` at
10 * `POST /api/v2/repos/:owner/:repo/chat/messages` (SSE) — see that file
11 * for the wire format. This route renders the UI shell + handles the
12 * no-JS fallback path.
13 *
14 * RepoNav is locked (`src/views/components.tsx`), so the nav tab for
15 * "Chat" isn't wired here — see CLAUDE.md / the locked-components list.
16 * The page renders a self-contained header with breadcrumb context
17 * back to the repo so users can navigate without the nav tab.
18 *
19 * Visual recipe (mirrors ask.tsx):
20 * - Gradient hairline strip across the top of the hero (purple→cyan)
21 * - Soft radial orb in the corner
22 * - Display headline with gradient-text on the title
23 * - Left column: previous chats + "new chat" button
24 * - Center column: message thread — user right-aligned, AI left-aligned
25 * - Bottom: composer with focus ring + gradient submit button
26 * - Citations rendered as an expandable "Sources" disclosure under each
27 * assistant bubble.
28 * - Scoped CSS — every class prefixed `.rchat-*`.
29 */
30
31import { Hono } from "hono";
32import { and, eq } from "drizzle-orm";
33import { db } from "../db";
34import { repositories, users } from "../db/schema";
35import type { RepoChat, RepoChatMessage } from "../db/schema";
36import { requireAuth, softAuth } from "../middleware/auth";
37import type { AuthEnv } from "../middleware/auth";
38import { Layout } from "../views/layout";
39import { getUnreadCount } from "../lib/unread";
40import { isAiAvailable } from "../lib/ai-client";
41import {
42 appendUserMessage,
43 createChat,
44 getChatForUser,
45 listChatsForRepo,
46 listMessages,
47 streamAssistantReply,
48 type Citation,
49} from "../lib/repo-chat";
50
51const repoChatRoutes = new Hono<AuthEnv>();
52repoChatRoutes.use("*", softAuth);
53
54// ---------------------------------------------------------------------------
55// Helpers
56// ---------------------------------------------------------------------------
57
58async function resolveRepoForUser(
59 owner: string,
60 repo: string
61): Promise<{ id: string; isPrivate: boolean; ownerId: string } | null> {
62 try {
63 const [row] = await db
64 .select({
65 id: repositories.id,
66 isPrivate: repositories.isPrivate,
67 ownerId: repositories.ownerId,
68 })
69 .from(repositories)
70 .innerJoin(users, eq(repositories.ownerId, users.id))
71 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
72 .limit(1);
73 return row || null;
74 } catch {
75 return null;
76 }
77}
78
79function asCitations(raw: unknown): Citation[] {
80 if (!Array.isArray(raw)) return [];
81 const out: Citation[] = [];
82 for (const item of raw) {
83 if (!item || typeof item !== "object") continue;
84 const i = item as Record<string, unknown>;
85 if (typeof i.file_path === "string" && typeof i.blob_sha === "string") {
86 out.push({ file_path: i.file_path, blob_sha: i.blob_sha });
87 }
88 }
89 return out;
90}
91
92// ---------------------------------------------------------------------------
93// Routes
94// ---------------------------------------------------------------------------
95
96repoChatRoutes.get("/:owner/:repo/chat", requireAuth, async (c) => {
97 const user = c.get("user")!;
98 const { owner, repo } = c.req.param();
99
100 const repoRow = await resolveRepoForUser(owner, repo);
101 if (!repoRow) return c.notFound();
102
103 const chats = await listChatsForRepo(user.id, repoRow.id);
104 const unread = await getUnreadCount(user.id);
105
106 return renderChatPage(c, {
107 owner,
108 repo,
109 user,
110 unread,
111 chats,
112 activeChat: null,
113 messages: [],
114 });
115});
116
117repoChatRoutes.get("/:owner/:repo/chat/:chatId", requireAuth, async (c) => {
118 const user = c.get("user")!;
119 const { owner, repo, chatId } = c.req.param();
120
121 const repoRow = await resolveRepoForUser(owner, repo);
122 if (!repoRow) return c.notFound();
123
124 const chat = await getChatForUser(chatId, user.id);
125 if (!chat || chat.repositoryId !== repoRow.id) {
126 return c.redirect(`/${owner}/${repo}/chat`);
127 }
128
129 const [chats, messages, unread] = await Promise.all([
130 listChatsForRepo(user.id, repoRow.id),
131 listMessages(chatId),
132 getUnreadCount(user.id),
133 ]);
134
135 return renderChatPage(c, {
136 owner,
137 repo,
138 user,
139 unread,
140 chats,
141 activeChat: chat,
142 messages,
143 });
144});
145
146/**
147 * No-JS form submit path. Creates a chat if needed, appends the user
148 * message, runs the full stream-and-persist pipeline (we just throw
149 * away the chunks here), and redirects to the chat page.
150 */
151repoChatRoutes.post("/:owner/:repo/chat", requireAuth, async (c) => {
152 const user = c.get("user")!;
153 const { owner, repo } = c.req.param();
154 const repoRow = await resolveRepoForUser(owner, repo);
155 if (!repoRow) return c.notFound();
156
157 const body = await c.req.parseBody();
158 const userMessage = String(body.message || "").trim();
159 let chatId = String(body.chat_id || "").trim();
160
161 if (!userMessage) return c.redirect(`/${owner}/${repo}/chat`);
162
163 // Create-if-missing.
164 if (!chatId) {
165 const created = await createChat({
166 repositoryId: repoRow.id,
167 ownerUserId: user.id,
168 title: userMessage.slice(0, 80),
169 });
170 if (!created) return c.redirect(`/${owner}/${repo}/chat`);
171 chatId = created.id;
172 } else {
173 const existing = await getChatForUser(chatId, user.id);
174 if (!existing || existing.repositoryId !== repoRow.id) {
175 return c.redirect(`/${owner}/${repo}/chat`);
176 }
177 }
178
179 await appendUserMessage(chatId, userMessage);
180 // Drain the stream synchronously — UX is a full-page reload here.
181 await streamAssistantReply({
182 chatId,
183 repoId: repoRow.id,
184 userMessage,
185 });
186
187 return c.redirect(`/${owner}/${repo}/chat/${chatId}`);
188});
189
190// ---------------------------------------------------------------------------
191// Render
192// ---------------------------------------------------------------------------
193
194function renderChatPage(
195 c: any,
196 args: {
197 owner: string;
198 repo: string;
199 user: any;
200 unread: number;
201 chats: RepoChat[];
202 activeChat: RepoChat | null;
203 messages: RepoChatMessage[];
204 }
205) {
206 const { owner, repo, user, unread, chats, activeChat, messages } = args;
207 const title = `Chat with ${owner}/${repo}`;
208 const postUrl = `/${owner}/${repo}/chat`;
209 const streamUrl = `/api/v2/repos/${owner}/${repo}/chat/messages`;
210
211 return c.html(
212 <Layout title={title} user={user} notificationCount={unread}>
213 <style dangerouslySetInnerHTML={{ __html: rchatCss }} />
214
215 <div class="rchat-page">
216 <header class="rchat-hero">
217 <div class="rchat-hero-orb" aria-hidden="true" />
218 <div class="rchat-hero-inner">
219 <div class="rchat-eyebrow">
220 <span class="rchat-eyebrow-pill" aria-hidden="true">
221 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
222 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
223 </svg>
224 </span>
225 Repo chat {"·"} Claude Sonnet 4 {"·"}{" "}
226 <a class="rchat-eyebrow-who" href={`/${owner}/${repo}`}>
227 {owner}/{repo}
228 </a>
229 {!isAiAvailable() && (
230 <span class="rchat-pill-warn">
231 AI unavailable {"—"} set ANTHROPIC_API_KEY
232 </span>
233 )}
234 </div>
235 <h1 class="rchat-title">
236 <span class="rchat-title-grad">Chat with this repo</span>
237 </h1>
238 <p class="rchat-sub">
239 Rubber-duck with Claude. Each answer is grounded in the most
240 relevant files surfaced by Gluecron's continuous semantic
241 index, and cites the sources it used.
242 </p>
243 </div>
244 </header>
245
246 <div class="rchat-layout">
247 {/* Left: chat list + new-chat button */}
248 <aside class="rchat-aside">
249 <a class="rchat-new" href={`/${owner}/${repo}/chat`}>
250 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
251 <line x1="12" y1="5" x2="12" y2="19" />
252 <line x1="5" y1="12" x2="19" y2="12" />
253 </svg>
254 New chat
255 </a>
256 <div class="rchat-aside-head">
257 <span class="rchat-aside-dot" aria-hidden="true" />
258 History
259 </div>
260 <ul class="rchat-aside-list">
261 {chats.length === 0 ? (
262 <li class="rchat-aside-empty">No chats yet.</li>
263 ) : (
264 chats.map((ch) => (
265 <li>
266 <a
267 class={`rchat-aside-link${activeChat && activeChat.id === ch.id ? " is-active" : ""}`}
268 href={`/${owner}/${repo}/chat/${ch.id}`}
269 >
270 <span class="rchat-aside-link-title">
271 {ch.title || "(untitled)"}
272 </span>
273 <span class="rchat-aside-link-when">
274 {new Date(ch.updatedAt).toLocaleDateString()}
275 </span>
276 </a>
277 </li>
278 ))
279 )}
280 </ul>
281 </aside>
282
283 {/* Center: message thread */}
284 <main class="rchat-main">
285 <div
286 class="rchat-log"
287 aria-live="polite"
288 id="rchat-log"
289 data-stream-url={streamUrl}
290 data-chat-id={activeChat ? activeChat.id : ""}
291 >
292 {messages.length === 0 ? (
293 <div class="rchat-empty">
294 <div class="rchat-empty-avatar" aria-hidden="true">
295 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
296 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
297 </svg>
298 </div>
299 <div>
300 <h2 class="rchat-empty-title">Ask anything about this repo.</h2>
301 <p class="rchat-empty-sub">
302 Try {"\""}where is auth handled?{"\""}, {"\""}why does the
303 post-receive hook fire scripts/self-deploy?{"\""}, or paste a
304 stack trace.
305 </p>
306 </div>
307 </div>
308 ) : (
309 messages.map((m) => (
310 <MessageRow
311 message={m}
312 owner={owner}
313 repo={repo}
314 username={user.username}
315 />
316 ))
317 )}
318 </div>
319
320 {/* Composer */}
321 <form method="post" action={postUrl} class="rchat-composer">
322 <input
323 type="hidden"
324 name="chat_id"
325 value={activeChat ? activeChat.id : ""}
326 />
327 <div class="rchat-composer-shell">
328 <textarea
329 class="rchat-composer-input"
330 name="message"
331 placeholder={
332 activeChat
333 ? "Continue the conversation..."
334 : `Ask about ${repo}...`
335 }
336 required
337 autofocus
338 rows={3}
339 ></textarea>
340 <div class="rchat-composer-foot">
341 <div class="rchat-hint">
342 <span class="rchat-kbd">{"⏎"}</span>
343 Enter + Ctrl/Cmd to send {"·"}
344 <span
345 class="rchat-tokens"
346 id="rchat-tokens"
347 data-token-cost="0"
348 >
349 ~0 tokens
350 </span>
351 </div>
352 <button type="submit" class="rchat-submit">
353 <span>Send</span>
354 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
355 <line x1="5" y1="12" x2="19" y2="12" />
356 <polyline points="12 5 19 12 12 19" />
357 </svg>
358 </button>
359 </div>
360 </div>
361 </form>
362 </main>
363 </div>
364 </div>
365
366 <script
367 dangerouslySetInnerHTML={{
368 __html: rchatClientJs,
369 }}
370 />
371 </Layout>
372 );
373}
374
375function MessageRow({
376 message,
377 owner,
378 repo,
379 username,
380}: {
381 message: RepoChatMessage;
382 owner: string;
383 repo: string;
384 username: string;
385}) {
386 const citations = asCitations(message.citations);
387 const isUser = message.role === "user";
388 return (
389 <div class={`rchat-msg rchat-msg-${isUser ? "user" : "assistant"}`}>
390 {!isUser && (
391 <div class="rchat-msg-avatar rchat-msg-avatar-ai" aria-hidden="true">
392 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
393 <path d="M12 2l1.8 5.5L19 9l-4.5 3.5L16 18l-4-3-4 3 1.5-5.5L5 9l5.2-1.5z" />
394 </svg>
395 </div>
396 )}
397 <div class="rchat-msg-bubble-wrap">
398 <div class="rchat-msg-role">
399 {isUser ? "You" : "Gluecron AI"}
400 </div>
401 <div class="rchat-msg-bubble">{message.content}</div>
402 {!isUser && citations.length > 0 && (
403 <details class="rchat-sources">
404 <summary class="rchat-sources-head">
405 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
406 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
407 <polyline points="14 2 14 8 20 8" />
408 </svg>
409 Sources ({citations.length})
410 </summary>
411 <ul class="rchat-sources-list">
412 {citations.map((c) => (
413 <li>
414 <a
415 class="rchat-sources-link"
416 href={`/${owner}/${repo}/blob/HEAD/${c.file_path}`}
417 >
418 {c.file_path}
419 </a>
420 </li>
421 ))}
422 </ul>
423 </details>
424 )}
425 {message.tokenCost > 0 && !isUser && (
426 <div class="rchat-msg-cost">~{message.tokenCost} tokens</div>
427 )}
428 </div>
429 {isUser && (
430 <div class="rchat-msg-avatar rchat-msg-avatar-user" aria-hidden="true">
431 {(username || "?").slice(0, 1).toUpperCase()}
432 </div>
433 )}
434 </div>
435 );
436}
437
438// ---------------------------------------------------------------------------
439// Scoped CSS — every class prefixed `.rchat-*`.
440// ---------------------------------------------------------------------------
441const rchatCss = `
442 .rchat-page {
443 max-width: 1180px;
444 margin: 0 auto;
445 padding: var(--space-6) var(--space-4) var(--space-12);
446 }
447
448 /* Hero */
449 .rchat-hero {
450 position: relative;
451 margin-bottom: var(--space-5);
452 padding: clamp(24px, 3.5vw, 40px) clamp(24px, 4vw, 40px);
453 background: var(--bg-elevated);
454 border: 1px solid var(--border);
455 border-radius: 18px;
456 overflow: hidden;
457 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
458 }
459 .rchat-hero::before {
460 content: '';
461 position: absolute;
462 top: 0; left: 0; right: 0;
463 height: 2px;
464 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
465 opacity: 0.78;
466 pointer-events: none;
467 z-index: 2;
468 }
469 .rchat-hero-orb {
470 position: absolute;
471 inset: -30% -10% auto auto;
472 width: 460px; height: 460px;
473 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
474 filter: blur(80px);
475 opacity: 0.7;
476 pointer-events: none;
477 z-index: 0;
478 }
479 .rchat-hero-inner { position: relative; z-index: 1; }
480
481 .rchat-eyebrow {
482 display: inline-flex;
483 align-items: center;
484 gap: 8px;
485 font-family: var(--font-mono);
486 font-size: 11.5px;
487 text-transform: uppercase;
488 letter-spacing: 0.14em;
489 color: var(--text-muted);
490 font-weight: 600;
491 margin-bottom: 14px;
492 flex-wrap: wrap;
493 }
494 .rchat-eyebrow-pill {
495 display: inline-flex;
496 align-items: center;
497 justify-content: center;
498 width: 18px; height: 18px;
499 border-radius: 6px;
500 background: rgba(140,109,255,0.14);
501 color: #b69dff;
502 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
503 }
504 .rchat-eyebrow-who {
505 color: var(--accent);
506 font-weight: 700;
507 text-transform: none;
508 letter-spacing: 0;
509 font-size: 12.5px;
510 text-decoration: none;
511 }
512 .rchat-eyebrow-who:hover { text-decoration: underline; }
513 .rchat-pill-warn {
514 display: inline-flex;
515 align-items: center;
516 gap: 6px;
517 padding: 3px 9px;
518 border-radius: 9999px;
519 background: rgba(251,191,36,0.10);
520 color: #fde68a;
521 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
522 font-size: 10.5px;
523 font-weight: 600;
524 text-transform: uppercase;
525 letter-spacing: 0.10em;
526 }
527 .rchat-title {
528 font-family: var(--font-display);
529 font-size: clamp(26px, 4vw, 38px);
530 font-weight: 800;
531 letter-spacing: -0.028em;
532 line-height: 1.06;
533 margin: 0 0 10px;
534 color: var(--text-strong);
535 }
536 .rchat-title-grad {
537 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
538 -webkit-background-clip: text;
539 background-clip: text;
540 -webkit-text-fill-color: transparent;
541 color: transparent;
542 }
543 .rchat-sub {
544 font-size: 14.5px;
545 color: var(--text-muted);
546 margin: 0;
547 line-height: 1.55;
548 max-width: 720px;
549 }
550
551 /* Two-column layout */
552 .rchat-layout {
553 display: grid;
554 grid-template-columns: 260px 1fr;
555 gap: var(--space-5);
556 align-items: start;
557 }
558 @media (max-width: 880px) {
559 .rchat-layout { grid-template-columns: 1fr; }
560 }
561
562 /* Aside */
563 .rchat-aside {
564 background: var(--bg-elevated);
565 border: 1px solid var(--border);
566 border-radius: 14px;
567 padding: var(--space-3) var(--space-3);
568 display: flex;
569 flex-direction: column;
570 gap: 10px;
571 position: sticky;
572 top: var(--space-3);
573 }
574 .rchat-new {
575 display: inline-flex;
576 align-items: center;
577 gap: 8px;
578 padding: 9px 12px;
579 font-size: 13px;
580 font-weight: 600;
581 color: #fff;
582 border-radius: 10px;
583 text-decoration: none;
584 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
585 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
586 }
587 .rchat-new:hover { text-decoration: none; transform: translateY(-1px); }
588 .rchat-aside-head {
589 display: inline-flex;
590 align-items: center;
591 gap: 8px;
592 font-family: var(--font-mono);
593 font-size: 11px;
594 text-transform: uppercase;
595 letter-spacing: 0.14em;
596 color: var(--text-muted);
597 font-weight: 700;
598 margin-top: 8px;
599 }
600 .rchat-aside-dot {
601 width: 7px; height: 7px;
602 border-radius: 9999px;
603 background: linear-gradient(135deg, #8c6dff, #36c5d6);
604 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
605 }
606 .rchat-aside-list {
607 list-style: none;
608 padding: 0;
609 margin: 0;
610 display: flex;
611 flex-direction: column;
612 gap: 2px;
613 max-height: 60vh;
614 overflow-y: auto;
615 }
616 .rchat-aside-empty {
617 font-size: 12.5px;
618 color: var(--text-muted);
619 padding: 8px 10px;
620 font-style: italic;
621 }
622 .rchat-aside-link {
623 display: flex;
624 flex-direction: column;
625 gap: 2px;
626 padding: 7px 10px;
627 border-radius: 8px;
628 color: var(--text);
629 text-decoration: none;
630 transition: background 120ms ease;
631 }
632 .rchat-aside-link:hover {
633 background: rgba(140,109,255,0.06);
634 text-decoration: none;
635 }
636 .rchat-aside-link.is-active {
637 background: rgba(140,109,255,0.10);
638 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
639 }
640 .rchat-aside-link-title {
641 font-size: 13px;
642 color: var(--text-strong);
643 font-weight: 500;
644 overflow: hidden;
645 text-overflow: ellipsis;
646 white-space: nowrap;
647 min-width: 0;
648 }
649 .rchat-aside-link-when {
650 font-family: var(--font-mono);
651 font-size: 10.5px;
652 color: var(--text-muted);
653 font-variant-numeric: tabular-nums;
654 }
655
656 /* Main */
657 .rchat-main {
658 display: flex;
659 flex-direction: column;
660 gap: var(--space-4);
661 min-width: 0;
662 }
663 .rchat-log {
664 display: flex;
665 flex-direction: column;
666 gap: 14px;
667 min-height: 240px;
668 }
669 .rchat-empty {
670 display: flex;
671 gap: 14px;
672 padding: clamp(24px, 4vw, 36px);
673 background: var(--bg-elevated);
674 border: 1px dashed var(--border-strong, var(--border));
675 border-radius: 14px;
676 align-items: flex-start;
677 }
678 .rchat-empty-avatar {
679 display: inline-flex;
680 align-items: center;
681 justify-content: center;
682 width: 36px; height: 36px;
683 border-radius: 10px;
684 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.10));
685 color: #b69dff;
686 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
687 flex-shrink: 0;
688 }
689 .rchat-empty-title {
690 font-family: var(--font-display);
691 font-size: 16px;
692 font-weight: 700;
693 color: var(--text-strong);
694 margin: 0 0 4px;
695 letter-spacing: -0.012em;
696 }
697 .rchat-empty-sub {
698 margin: 0;
699 color: var(--text-muted);
700 font-size: 13.5px;
701 line-height: 1.55;
702 }
703
704 /* Messages */
705 .rchat-msg {
706 display: flex;
707 gap: 10px;
708 align-items: flex-start;
709 max-width: 100%;
710 }
711 .rchat-msg-assistant { justify-content: flex-start; }
712 .rchat-msg-user { justify-content: flex-end; }
713 .rchat-msg-avatar {
714 display: inline-flex;
715 align-items: center;
716 justify-content: center;
717 width: 32px; height: 32px;
718 border-radius: 9999px;
719 flex-shrink: 0;
720 font-family: var(--font-mono);
721 font-size: 12px;
722 font-weight: 700;
723 }
724 .rchat-msg-avatar-ai {
725 background: linear-gradient(135deg, #8c6dff, #36c5d6);
726 color: #fff;
727 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 6px 18px -6px rgba(140,109,255,0.45);
728 }
729 .rchat-msg-avatar-user {
730 background: rgba(255,255,255,0.05);
731 color: var(--text-strong);
732 box-shadow: inset 0 0 0 1px var(--border-strong, var(--border));
733 }
734 .rchat-msg-bubble-wrap {
735 display: flex;
736 flex-direction: column;
737 gap: 4px;
738 max-width: min(700px, calc(100% - 48px));
739 min-width: 0;
740 }
741 .rchat-msg-user .rchat-msg-bubble-wrap { align-items: flex-end; }
742 .rchat-msg-role {
743 font-family: var(--font-mono);
744 font-size: 10.5px;
745 text-transform: uppercase;
746 letter-spacing: 0.14em;
747 color: var(--text-muted);
748 font-weight: 700;
749 padding: 0 2px;
750 }
751 .rchat-msg-bubble {
752 padding: 11px 14px;
753 border-radius: 14px;
754 font-size: 14px;
755 line-height: 1.55;
756 color: var(--text);
757 background: var(--bg-elevated);
758 border: 1px solid var(--border);
759 white-space: pre-wrap;
760 word-wrap: break-word;
761 overflow-wrap: anywhere;
762 }
763 .rchat-msg-assistant .rchat-msg-bubble {
764 border-top-left-radius: 4px;
765 background:
766 linear-gradient(180deg, rgba(140,109,255,0.04), transparent 60%),
767 var(--bg-elevated);
768 border-color: rgba(140,109,255,0.22);
769 }
770 .rchat-msg-user .rchat-msg-bubble {
771 border-top-right-radius: 4px;
772 background: rgba(140,109,255,0.06);
773 border-color: rgba(140,109,255,0.20);
774 color: var(--text-strong);
775 }
776 .rchat-msg-cost {
777 font-family: var(--font-mono);
778 font-size: 10.5px;
779 color: var(--text-muted);
780 padding: 0 2px;
781 }
782
783 /* Sources disclosure */
784 .rchat-sources {
785 margin-top: 4px;
786 }
787 .rchat-sources-head {
788 cursor: pointer;
789 list-style: none;
790 display: inline-flex;
791 align-items: center;
792 gap: 6px;
793 font-family: var(--font-mono);
794 font-size: 11px;
795 text-transform: uppercase;
796 letter-spacing: 0.12em;
797 color: var(--text-muted);
798 font-weight: 700;
799 padding: 4px 8px;
800 border-radius: 8px;
801 background: rgba(140,109,255,0.05);
802 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.18);
803 }
804 .rchat-sources-head::-webkit-details-marker { display: none; }
805 .rchat-sources-head:hover {
806 background: rgba(140,109,255,0.08);
807 }
808 .rchat-sources-list {
809 list-style: none;
810 margin: 6px 0 0;
811 padding: 0;
812 display: flex;
813 flex-direction: column;
814 gap: 2px;
815 }
816 .rchat-sources-link {
817 display: inline-block;
818 font-family: var(--font-mono);
819 font-size: 12px;
820 padding: 3px 8px;
821 border-radius: 6px;
822 color: var(--accent);
823 text-decoration: none;
824 }
825 .rchat-sources-link:hover {
826 background: rgba(140,109,255,0.08);
827 text-decoration: none;
828 }
829
830 /* Composer */
831 .rchat-composer {}
832 .rchat-composer-shell {
833 position: relative;
834 background: var(--bg-elevated);
835 border: 1px solid var(--border-strong, var(--border));
836 border-radius: 16px;
837 overflow: hidden;
838 transition: border-color 140ms ease, box-shadow 140ms ease;
839 }
840 .rchat-composer-shell:focus-within {
841 border-color: rgba(140,109,255,0.55);
842 box-shadow: 0 0 0 4px rgba(140,109,255,0.16);
843 }
844 .rchat-composer-shell::before {
845 content: '';
846 position: absolute;
847 top: 0; left: 0; right: 0;
848 height: 1px;
849 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
850 opacity: 0.6;
851 pointer-events: none;
852 }
853 .rchat-composer-input {
854 display: block;
855 width: 100%;
856 box-sizing: border-box;
857 padding: 14px 16px 10px;
858 background: transparent;
859 border: 0;
860 outline: 0;
861 resize: vertical;
862 min-height: 84px;
863 color: var(--text);
864 font-family: inherit;
865 font-size: 14.5px;
866 line-height: 1.55;
867 }
868 .rchat-composer-input::placeholder { color: var(--text-faint, var(--text-muted)); }
869 .rchat-composer-foot {
870 display: flex;
871 align-items: center;
872 justify-content: space-between;
873 gap: 12px;
874 padding: 8px 12px 10px;
875 border-top: 1px solid var(--border);
876 background: rgba(255,255,255,0.012);
877 flex-wrap: wrap;
878 }
879 .rchat-hint {
880 display: inline-flex;
881 align-items: center;
882 gap: 8px;
883 font-size: 12px;
884 color: var(--text-muted);
885 flex-wrap: wrap;
886 }
887 .rchat-kbd {
888 display: inline-flex;
889 align-items: center;
890 justify-content: center;
891 min-width: 22px;
892 padding: 1px 6px;
893 font-family: var(--font-mono);
894 font-size: 11px;
895 color: var(--text);
896 background: rgba(255,255,255,0.04);
897 border: 1px solid var(--border);
898 border-radius: 4px;
899 }
900 .rchat-tokens {
901 font-family: var(--font-mono);
902 font-size: 11px;
903 color: var(--text-muted);
904 font-variant-numeric: tabular-nums;
905 }
906 .rchat-submit {
907 display: inline-flex;
908 align-items: center;
909 gap: 8px;
910 padding: 9px 16px;
911 font-size: 13.5px;
912 font-weight: 600;
913 color: #fff;
914 border: 1px solid transparent;
915 border-radius: 10px;
916 cursor: pointer;
917 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
918 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
919 transition: transform 120ms ease, box-shadow 120ms ease;
920 font-family: inherit;
921 line-height: 1;
922 }
923 .rchat-submit:hover {
924 transform: translateY(-1px);
925 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
926 }
927 .rchat-submit:active { transform: translateY(0); }
928
929 @media (max-width: 640px) {
930 .rchat-msg-bubble-wrap { max-width: calc(100% - 44px); }
931 .rchat-composer-foot { gap: 8px; }
932 .rchat-submit { padding: 8px 14px; font-size: 13px; }
933 }
934`;
935
936// ---------------------------------------------------------------------------
937// Client-side enhancement — token estimator + Ctrl/Cmd+Enter submit.
938// (Streaming UX is intentionally tiny here — the real-time bubble append
939// can land in a follow-up that wires the SSE endpoint to a fetch reader.)
940// ---------------------------------------------------------------------------
941const rchatClientJs = `
942(function(){
943 var input = document.querySelector('.rchat-composer-input');
944 var tokensEl = document.getElementById('rchat-tokens');
945 if (input && tokensEl) {
946 var update = function(){
947 var n = Math.ceil((input.value || '').length / 4);
948 tokensEl.textContent = '~' + n + ' tokens';
949 tokensEl.setAttribute('data-token-cost', String(n));
950 };
951 input.addEventListener('input', update);
952 update();
953 }
954 // Cmd/Ctrl+Enter submits the composer.
955 if (input) {
956 input.addEventListener('keydown', function(e){
957 if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
958 var form = input.form;
959 if (form) form.submit();
960 }
961 });
962 }
963})();
964`;
965
966export default repoChatRoutes;