Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

personal-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.

personal-chat.tsxBlame1137 lines · 2 contributors
ee7e577Claude1/**
2 * Personal cross-repo Claude chat — `/chat` (user-scoped, no repo
3 * segment in the URL).
4 *
5 * Sibling of `src/routes/repo-chat.tsx`. The crucial difference: this
6 * route never resolves a single repo — the user just opens `/chat` and
7 * Claude can pull context from any repo the user owns OR is an accepted
8 * collaborator on. Retrieval is gated on
9 * `users.personal_semantic_index_enabled`; when off, the page shows an
10 * opt-in banner instead of a composer.
11 *
12 * GET /chat — chat home (new chat shell)
13 * GET /chat/:chatId — resume an existing thread
14 * POST /chat — no-JS fallback form submit
15 * POST /settings/personal-semantic-toggle — flip the opt-in flag
16 *
17 * The streaming endpoint lives at `POST /api/v2/me/chat/messages` in
18 * `src/routes/api-v2.ts`.
19 *
20 * Visual recipe (mirrors `rchat-*` but namespaced `pchat-*`):
21 * - Gradient hairline strip on the hero
22 * - Soft radial orb in the corner
23 * - Opt-in banner with a polished toggle if disabled
24 * - History sidebar + threaded log + composer with focus ring
25 * - Citations show `owner/repo · path` so the user knows which repo
26 * the snippet came from
27 * - "Cross-repo citation" caution banner on each assistant bubble whose
28 * citations span multiple repos — small reminder to be mindful when
29 * screen-sharing.
30 * - Scoped CSS — every class prefixed `.pchat-*`.
31 */
32
33import { Hono } from "hono";
34import { eq } from "drizzle-orm";
35import { db } from "../db";
36import { users } from "../db/schema";
37import type { PersonalChat, PersonalChatMessage } from "../db/schema";
38import { requireAuth, softAuth } from "../middleware/auth";
39import type { AuthEnv } from "../middleware/auth";
40import { Layout } from "../views/layout";
41import { getUnreadCount } from "../lib/unread";
42import { isAiAvailable } from "../lib/ai-client";
43import { audit } from "../lib/notify";
e9a4574ccantynz-alt44import { safeRedirect } from "../lib/safe-redirect";
ee7e577Claude45import {
46 appendPersonalUserMessage,
47 createPersonalChat,
48 getPersonalChatForUser,
49 listPersonalChatsForUser,
50 listPersonalMessages,
51 streamPersonalAssistantReply,
52} from "../lib/personal-chat";
53import {
54 isPersonalSemanticEnabled,
55 setPersonalSemanticEnabled,
56} from "../lib/personal-semantic";
57
58const personalChatRoutes = new Hono<AuthEnv>();
59personalChatRoutes.use("*", softAuth);
60
61// ---------------------------------------------------------------------------
62// Helpers
63// ---------------------------------------------------------------------------
64
65interface CitationShape {
66 file_path: string;
67 blob_sha: string;
68 repo_name: string;
69}
70
71function asPersonalCitations(raw: unknown): CitationShape[] {
72 if (!Array.isArray(raw)) return [];
73 const out: CitationShape[] = [];
74 for (const item of raw) {
75 if (!item || typeof item !== "object") continue;
76 const i = item as Record<string, unknown>;
77 if (
78 typeof i.file_path === "string" &&
79 typeof i.blob_sha === "string" &&
80 typeof i.repo_name === "string"
81 ) {
82 out.push({
83 file_path: i.file_path,
84 blob_sha: i.blob_sha,
85 repo_name: i.repo_name,
86 });
87 }
88 }
89 return out;
90}
91
92// ---------------------------------------------------------------------------
93// Routes
94// ---------------------------------------------------------------------------
95
96personalChatRoutes.get("/chat", requireAuth, async (c) => {
97 const user = c.get("user")!;
98 const [chats, unread, enabled] = await Promise.all([
99 listPersonalChatsForUser(user.id),
100 getUnreadCount(user.id),
101 isPersonalSemanticEnabled(user.id),
102 ]);
103 return renderPersonalChatPage(c, {
104 user,
105 unread,
106 chats,
107 activeChat: null,
108 messages: [],
109 enabled,
110 });
111});
112
113personalChatRoutes.get("/chat/:chatId", requireAuth, async (c) => {
114 const user = c.get("user")!;
115 const { chatId } = c.req.param();
116 const chat = await getPersonalChatForUser(chatId, user.id);
117 if (!chat) {
118 return c.redirect("/chat");
119 }
120 const [chats, messages, unread, enabled] = await Promise.all([
121 listPersonalChatsForUser(user.id),
122 listPersonalMessages(chatId),
123 getUnreadCount(user.id),
124 isPersonalSemanticEnabled(user.id),
125 ]);
126 return renderPersonalChatPage(c, {
127 user,
128 unread,
129 chats,
130 activeChat: chat,
131 messages,
132 enabled,
133 });
134});
135
136/**
137 * No-JS POST fallback. Creates a chat if needed, appends the user
138 * message, drains the assistant stream synchronously, and redirects.
139 */
140personalChatRoutes.post("/chat", requireAuth, async (c) => {
141 const user = c.get("user")!;
142 const body = await c.req.parseBody();
143 const userMessage = String(body.message || "").trim();
144 let chatId = String(body.chat_id || "").trim();
145 if (!userMessage) return c.redirect("/chat");
146
147 // Hard refusal when the opt-in flag is off — we don't even create a
148 // chat row in this state. The page already shows a banner explaining
149 // why; this is the no-JS equivalent.
150 const enabled = await isPersonalSemanticEnabled(user.id);
151 if (!enabled) {
152 return c.redirect("/chat?error=opt-in-required");
153 }
154
155 if (!chatId) {
156 const created = await createPersonalChat({
157 ownerUserId: user.id,
158 title: userMessage.slice(0, 80),
159 });
160 if (!created) return c.redirect("/chat");
161 chatId = created.id;
162 } else {
163 const existing = await getPersonalChatForUser(chatId, user.id);
164 if (!existing) return c.redirect("/chat");
165 }
166
167 await appendPersonalUserMessage(chatId, userMessage);
168 await streamPersonalAssistantReply({
169 chatId,
170 userId: user.id,
171 userMessage,
172 });
173
174 // Per-message audit log — privacy requirement.
175 void audit({
176 userId: user.id,
177 action: "ai.personal.chat",
178 targetType: "personal_chat",
179 targetId: chatId,
180 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip"),
181 userAgent: c.req.header("user-agent"),
182 metadata: { surface: "no-js" },
183 });
184
185 return c.redirect(`/chat/${chatId}`);
186});
187
188/**
189 * Flip the opt-in flag. Audited under `ai.personal.toggle`.
190 */
191personalChatRoutes.post(
192 "/settings/personal-semantic-toggle",
193 requireAuth,
194 async (c) => {
195 const user = c.get("user")!;
196 const body = await c.req.parseBody();
197 const requested = String(body.enabled || "").toLowerCase();
198 const enable = requested === "true" || requested === "1" || requested === "on";
199
200 const newValue = await setPersonalSemanticEnabled(user.id, enable);
201
202 void audit({
203 userId: user.id,
204 action: "ai.personal.toggle",
205 targetType: "user",
206 targetId: user.id,
207 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip"),
208 userAgent: c.req.header("user-agent"),
209 metadata: { enabled: !!newValue, requested },
210 });
211
e9a4574ccantynz-alt212 const redirectTo = safeRedirect(body.redirect, "/chat");
ee7e577Claude213 return c.redirect(
214 redirectTo +
215 (redirectTo.includes("?") ? "&" : "?") +
216 (newValue ? "success=personal-semantic-enabled" : "success=personal-semantic-disabled")
217 );
218 }
219);
220
221// ---------------------------------------------------------------------------
222// Render
223// ---------------------------------------------------------------------------
224
225function renderPersonalChatPage(
226 c: any,
227 args: {
228 user: any;
229 unread: number;
230 chats: PersonalChat[];
231 activeChat: PersonalChat | null;
232 messages: PersonalChatMessage[];
233 enabled: boolean;
234 }
235) {
236 const { user, unread, chats, activeChat, messages, enabled } = args;
237 const title = "Personal chat";
238 const postUrl = "/chat";
239 const streamUrl = "/api/v2/me/chat/messages";
240 const showError = c.req.query("error") === "opt-in-required";
241
242 return c.html(
243 <Layout title={title} user={user} notificationCount={unread}>
244 <style dangerouslySetInnerHTML={{ __html: pchatCss }} />
245
246 <div class="pchat-page">
247 <header class="pchat-hero">
248 <div class="pchat-hero-orb" aria-hidden="true" />
249 <div class="pchat-hero-inner">
250 <div class="pchat-eyebrow">
251 <span class="pchat-eyebrow-pill" aria-hidden="true">
252 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
253 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
254 </svg>
255 </span>
256 Personal chat {"·"} Claude Sonnet 4 {"·"}{" "}
257 <a class="pchat-eyebrow-who" href={`/${user.username}`}>
258 {user.username}
259 </a>
260 {!isAiAvailable() && (
261 <span class="pchat-pill-warn">
262 AI unavailable {"—"} set ANTHROPIC_API_KEY
263 </span>
264 )}
265 </div>
266 <h1 class="pchat-title">
267 <span class="pchat-title-grad">Chat across all your code</span>
268 </h1>
269 <p class="pchat-sub">
270 Ask Claude anything. Answers are grounded in the continuous
271 semantic index across every repo you own or collaborate on,
272 and citations name the source repo alongside the file path.
273 </p>
274 </div>
275 </header>
276
277 {/* Opt-in banner — only when the flag is OFF */}
278 {!enabled && (
279 <section class="pchat-optin" role="region" aria-label="Opt-in required">
280 <div class="pchat-optin-head">
281 <span class="pchat-optin-dot" aria-hidden="true" />
282 <h2 class="pchat-optin-title">Enable personal semantic index</h2>
283 </div>
284 <p class="pchat-optin-body">
285 Personal chat is <strong>off by default</strong>. When you
286 enable it, Claude can search the continuous semantic index
287 across every repo you own and every repo you've been accepted
288 as a collaborator on. You can turn it off at any time and we
289 stop touching your data from this surface immediately.
290 </p>
291 <form
292 method="post"
293 action="/settings/personal-semantic-toggle"
294 class="pchat-optin-form"
295 >
296 <input type="hidden" name="enabled" value="true" />
297 <input type="hidden" name="redirect" value="/chat" />
298 <button type="submit" class="pchat-optin-btn">
299 Enable personal cross-repo chat
300 </button>
301 <a class="pchat-optin-learn" href="/settings">
302 Manage in settings
303 </a>
304 </form>
305 </section>
306 )}
307
308 {showError && (
309 <div class="pchat-toast" role="alert">
310 Enable personal cross-repo chat below before sending messages.
311 </div>
312 )}
313
314 <div class="pchat-layout">
315 <aside class="pchat-aside">
316 <a class="pchat-new" href="/chat">
317 <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">
318 <line x1="12" y1="5" x2="12" y2="19" />
319 <line x1="5" y1="12" x2="19" y2="12" />
320 </svg>
321 New chat
322 </a>
323 <div class="pchat-aside-head">
324 <span class="pchat-aside-dot" aria-hidden="true" />
325 History
326 </div>
327 <ul class="pchat-aside-list">
328 {chats.length === 0 ? (
329 <li class="pchat-aside-empty">No chats yet.</li>
330 ) : (
331 chats.map((ch) => (
332 <li>
333 <a
334 class={`pchat-aside-link${activeChat && activeChat.id === ch.id ? " is-active" : ""}`}
335 href={`/chat/${ch.id}`}
336 >
337 <span class="pchat-aside-link-title">
338 {ch.title || "(untitled)"}
339 </span>
340 <span class="pchat-aside-link-when">
341 {new Date(ch.updatedAt).toLocaleDateString()}
342 </span>
343 </a>
344 </li>
345 ))
346 )}
347 </ul>
348
349 {enabled && (
350 <form
351 method="post"
352 action="/settings/personal-semantic-toggle"
353 class="pchat-aside-toggle"
354 >
355 <input type="hidden" name="enabled" value="false" />
356 <input type="hidden" name="redirect" value="/chat" />
357 <button type="submit" class="pchat-aside-toggle-btn">
358 Disable personal index
359 </button>
360 </form>
361 )}
362 </aside>
363
364 <main class="pchat-main">
365 <div
366 class="pchat-log"
367 aria-live="polite"
368 id="pchat-log"
369 data-stream-url={streamUrl}
370 data-chat-id={activeChat ? activeChat.id : ""}
371 >
372 {messages.length === 0 ? (
373 <div class="pchat-empty">
374 <div class="pchat-empty-avatar" aria-hidden="true">
375 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
376 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
377 </svg>
378 </div>
379 <div>
380 <h2 class="pchat-empty-title">
381 {enabled
382 ? "Ask anything across all your repos."
383 : "Enable personal chat to ask anything across all your repos."}
384 </h2>
385 <p class="pchat-empty-sub">
386 {enabled
387 ? `Try "where do we handle webhook retries?", "what's the audit-log shape across these projects?", or paste a stack trace.`
388 : "When enabled, Claude can search every repo you own or collaborate on. Off until you say so."}
389 </p>
390 </div>
391 </div>
392 ) : (
393 messages.map((m) => (
394 <PersonalMessageRow message={m} username={user.username} />
395 ))
396 )}
397 </div>
398
399 <form method="post" action={postUrl} class="pchat-composer">
400 <input
401 type="hidden"
402 name="chat_id"
403 value={activeChat ? activeChat.id : ""}
404 />
405 <div class="pchat-composer-shell">
406 <textarea
407 class="pchat-composer-input"
408 name="message"
409 placeholder={
410 enabled
411 ? activeChat
412 ? "Continue the conversation..."
413 : "Ask across all your code..."
414 : "Enable personal chat above to start asking."
415 }
416 required
417 autofocus
418 rows={3}
419 disabled={!enabled}
420 ></textarea>
421 <div class="pchat-composer-foot">
422 <div class="pchat-hint">
423 <span class="pchat-kbd">{"⏎"}</span>
424 Enter + Ctrl/Cmd to send {"·"}
425 <span
426 class="pchat-tokens"
427 id="pchat-tokens"
428 data-token-cost="0"
429 >
430 ~0 tokens
431 </span>
432 </div>
433 <button type="submit" class="pchat-submit" disabled={!enabled}>
434 <span>Send</span>
435 <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">
436 <line x1="5" y1="12" x2="19" y2="12" />
437 <polyline points="12 5 19 12 12 19" />
438 </svg>
439 </button>
440 </div>
441 </div>
442 </form>
443 </main>
444 </div>
445 </div>
446
447 <script
448 dangerouslySetInnerHTML={{
449 __html: pchatClientJs,
450 }}
451 />
452 </Layout>
453 );
454}
455
456function PersonalMessageRow({
457 message,
458 username,
459}: {
460 message: PersonalChatMessage;
461 username: string;
462}) {
463 const citations = asPersonalCitations(message.citations);
464 const isUser = message.role === "user";
465 const distinctRepos = new Set(citations.map((c) => c.repo_name));
466 const crossRepo = distinctRepos.size > 1;
467
468 return (
469 <div class={`pchat-msg pchat-msg-${isUser ? "user" : "assistant"}`}>
470 {!isUser && (
471 <div class="pchat-msg-avatar pchat-msg-avatar-ai" aria-hidden="true">
472 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
473 <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" />
474 </svg>
475 </div>
476 )}
477 <div class="pchat-msg-bubble-wrap">
478 <div class="pchat-msg-role">
479 {isUser ? "You" : "Gluecron AI"}
480 </div>
481 <div class="pchat-msg-bubble">{message.content}</div>
482 {!isUser && crossRepo && (
483 <div class="pchat-caution" role="note">
484 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
485 <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
486 <line x1="12" y1="9" x2="12" y2="13" />
487 <line x1="12" y1="17" x2="12.01" y2="17" />
488 </svg>
489 Citations span {distinctRepos.size} repos — redact when
490 screen-sharing.
491 </div>
492 )}
493 {!isUser && citations.length > 0 && (
494 <details class="pchat-sources">
495 <summary class="pchat-sources-head">
496 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
497 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
498 <polyline points="14 2 14 8 20 8" />
499 </svg>
500 Sources ({citations.length})
501 </summary>
502 <ul class="pchat-sources-list">
503 {citations.map((cit) => (
504 <li>
505 <a
506 class="pchat-sources-link"
507 href={`/${cit.repo_name}/blob/HEAD/${cit.file_path}`}
508 >
509 <span class="pchat-sources-repo">{cit.repo_name}</span>
510 <span class="pchat-sources-sep">{" · "}</span>
511 <span class="pchat-sources-path">{cit.file_path}</span>
512 </a>
513 </li>
514 ))}
515 </ul>
516 </details>
517 )}
518 {message.tokenCost > 0 && !isUser && (
519 <div class="pchat-msg-cost">~{message.tokenCost} tokens</div>
520 )}
521 </div>
522 {isUser && (
523 <div class="pchat-msg-avatar pchat-msg-avatar-user" aria-hidden="true">
524 {(username || "?").slice(0, 1).toUpperCase()}
525 </div>
526 )}
527 </div>
528 );
529}
530
531// ---------------------------------------------------------------------------
532// Touch: expose `isPersonalSemanticEnabled` via a tiny inline helper so
533// the user menu can decide whether to badge the "Personal chat" link.
534// We keep this lib-export here (not in a separate util) so the route file
535// + the layout's user-menu snippet (locked) don't have to import a new
536// module just to read a single bool.
537// ---------------------------------------------------------------------------
538export async function readPersonalSemanticFlag(userId: string): Promise<boolean> {
539 try {
540 const [row] = await db
541 .select({ enabled: users.personalSemanticIndexEnabled })
542 .from(users)
543 .where(eq(users.id, userId))
544 .limit(1);
545 return !!row?.enabled;
546 } catch {
547 return false;
548 }
549}
550
551// ---------------------------------------------------------------------------
552// Scoped CSS — every class prefixed `.pchat-*`.
553// ---------------------------------------------------------------------------
554const pchatCss = `
555 .pchat-page {
556 max-width: 1180px;
557 margin: 0 auto;
558 padding: var(--space-6) var(--space-4) var(--space-12);
559 }
560
561 .pchat-hero {
562 position: relative;
563 margin-bottom: var(--space-5);
564 padding: clamp(24px, 3.5vw, 40px) clamp(24px, 4vw, 40px);
565 background: var(--bg-elevated);
566 border: 1px solid var(--border);
567 border-radius: 18px;
568 overflow: hidden;
569 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
570 }
571 .pchat-hero::before {
572 content: '';
573 position: absolute;
574 top: 0; left: 0; right: 0;
575 height: 2px;
6fd5915Claude576 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
ee7e577Claude577 opacity: 0.78;
578 pointer-events: none;
579 z-index: 2;
580 }
581 .pchat-hero-orb {
582 position: absolute;
583 inset: -30% -10% auto auto;
584 width: 460px; height: 460px;
6fd5915Claude585 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
ee7e577Claude586 filter: blur(80px);
587 opacity: 0.7;
588 pointer-events: none;
589 z-index: 0;
590 }
591 .pchat-hero-inner { position: relative; z-index: 1; }
592
593 .pchat-eyebrow {
594 display: inline-flex; align-items: center; gap: 8px;
595 font-family: var(--font-mono);
596 font-size: 11.5px;
597 text-transform: uppercase;
598 letter-spacing: 0.14em;
599 color: var(--text-muted);
600 font-weight: 600;
601 margin-bottom: 14px;
602 flex-wrap: wrap;
603 }
604 .pchat-eyebrow-pill {
605 display: inline-flex; align-items: center; justify-content: center;
606 width: 18px; height: 18px;
607 border-radius: 6px;
6fd5915Claude608 background: rgba(91,110,232,0.14);
e589f77ccantynz-alt609 color: var(--accent);
6fd5915Claude610 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
ee7e577Claude611 }
612 .pchat-eyebrow-who {
613 color: var(--accent);
614 font-weight: 700;
615 text-transform: none;
616 letter-spacing: 0;
617 font-size: 12.5px;
618 text-decoration: none;
619 }
620 .pchat-eyebrow-who:hover { text-decoration: underline; }
621 .pchat-pill-warn {
622 display: inline-flex; align-items: center; gap: 6px;
623 padding: 3px 9px;
624 border-radius: 9999px;
625 background: rgba(251,191,36,0.10);
626 color: #fde68a;
627 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
628 font-size: 10.5px;
629 font-weight: 600;
630 text-transform: uppercase;
631 letter-spacing: 0.10em;
632 }
633 .pchat-title {
634 font-family: var(--font-display);
635 font-size: clamp(26px, 4vw, 38px);
636 font-weight: 800;
637 letter-spacing: -0.028em;
638 line-height: 1.06;
639 margin: 0 0 10px;
640 color: var(--text-strong);
641 }
642 .pchat-title-grad {
6fd5915Claude643 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
ee7e577Claude644 -webkit-background-clip: text;
645 background-clip: text;
646 -webkit-text-fill-color: transparent;
647 color: transparent;
648 }
649 .pchat-sub {
650 font-size: 14.5px;
651 color: var(--text-muted);
652 margin: 0;
653 line-height: 1.55;
654 max-width: 720px;
655 }
656
657 /* Opt-in banner */
658 .pchat-optin {
659 position: relative;
660 margin-bottom: var(--space-5);
661 padding: var(--space-4) var(--space-5);
6fd5915Claude662 background: linear-gradient(180deg, rgba(91,110,232,0.06), rgba(95,143,160,0.02));
663 border: 1px solid rgba(91,110,232,0.30);
ee7e577Claude664 border-radius: 14px;
665 }
666 .pchat-optin-head {
667 display: flex; align-items: center; gap: 10px;
668 margin-bottom: 8px;
669 }
670 .pchat-optin-dot {
671 width: 9px; height: 9px;
672 border-radius: 9999px;
6fd5915Claude673 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
674 box-shadow: 0 0 0 4px rgba(91,110,232,0.20);
ee7e577Claude675 }
676 .pchat-optin-title {
677 font-family: var(--font-display);
678 font-size: 17px;
679 font-weight: 700;
680 color: var(--text-strong);
681 margin: 0;
682 letter-spacing: -0.012em;
683 }
684 .pchat-optin-body {
685 margin: 0 0 14px;
686 font-size: 13.5px;
687 color: var(--text-muted);
688 line-height: 1.55;
689 max-width: 720px;
690 }
691 .pchat-optin-form {
692 display: inline-flex; align-items: center; gap: 14px;
693 flex-wrap: wrap;
694 }
695 .pchat-optin-btn {
696 display: inline-flex; align-items: center; gap: 8px;
697 padding: 9px 16px;
698 font-size: 13.5px;
699 font-weight: 600;
700 color: #fff;
701 border: 1px solid transparent;
702 border-radius: 10px;
703 cursor: pointer;
6fd5915Claude704 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
705 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
ee7e577Claude706 font-family: inherit;
707 line-height: 1;
708 }
709 .pchat-optin-btn:hover { transform: translateY(-1px); }
710 .pchat-optin-learn {
711 font-size: 12.5px;
712 color: var(--text-muted);
713 text-decoration: none;
714 }
715 .pchat-optin-learn:hover { color: var(--accent); }
716
717 .pchat-toast {
718 margin-bottom: var(--space-4);
719 padding: 10px 14px;
720 border-radius: 10px;
721 background: rgba(251,191,36,0.10);
722 color: #fde68a;
723 border: 1px solid rgba(251,191,36,0.30);
724 font-size: 13px;
725 }
726
727 .pchat-layout {
728 display: grid;
729 grid-template-columns: 260px 1fr;
730 gap: var(--space-5);
731 align-items: start;
732 }
733 @media (max-width: 880px) {
734 .pchat-layout { grid-template-columns: 1fr; }
735 }
736
737 .pchat-aside {
738 background: var(--bg-elevated);
739 border: 1px solid var(--border);
740 border-radius: 14px;
741 padding: var(--space-3) var(--space-3);
742 display: flex;
743 flex-direction: column;
744 gap: 10px;
745 position: sticky;
746 top: var(--space-3);
747 }
748 .pchat-new {
749 display: inline-flex; align-items: center; gap: 8px;
750 padding: 9px 12px;
751 font-size: 13px;
752 font-weight: 600;
753 color: #fff;
754 border-radius: 10px;
755 text-decoration: none;
6fd5915Claude756 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
757 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
ee7e577Claude758 }
759 .pchat-new:hover { text-decoration: none; transform: translateY(-1px); }
760 .pchat-aside-head {
761 display: inline-flex; align-items: center; gap: 8px;
762 font-family: var(--font-mono);
763 font-size: 11px;
764 text-transform: uppercase;
765 letter-spacing: 0.14em;
766 color: var(--text-muted);
767 font-weight: 700;
768 margin-top: 8px;
769 }
770 .pchat-aside-dot {
771 width: 7px; height: 7px;
772 border-radius: 9999px;
6fd5915Claude773 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
774 box-shadow: 0 0 0 3px rgba(91,110,232,0.16);
ee7e577Claude775 }
776 .pchat-aside-list {
777 list-style: none; padding: 0; margin: 0;
778 display: flex; flex-direction: column; gap: 2px;
779 max-height: 50vh; overflow-y: auto;
780 }
781 .pchat-aside-empty {
782 font-size: 12.5px;
783 color: var(--text-muted);
784 padding: 8px 10px;
785 font-style: italic;
786 }
787 .pchat-aside-link {
788 display: flex; flex-direction: column; gap: 2px;
789 padding: 7px 10px;
790 border-radius: 8px;
791 color: var(--text);
792 text-decoration: none;
793 transition: background 120ms ease;
794 }
795 .pchat-aside-link:hover {
6fd5915Claude796 background: rgba(91,110,232,0.06);
ee7e577Claude797 text-decoration: none;
798 }
799 .pchat-aside-link.is-active {
6fd5915Claude800 background: rgba(91,110,232,0.10);
801 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
ee7e577Claude802 }
803 .pchat-aside-link-title {
804 font-size: 13px;
805 color: var(--text-strong);
806 font-weight: 500;
807 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
808 min-width: 0;
809 }
810 .pchat-aside-link-when {
811 font-family: var(--font-mono);
812 font-size: 10.5px;
813 color: var(--text-muted);
814 font-variant-numeric: tabular-nums;
815 }
816 .pchat-aside-toggle {
817 margin-top: 8px;
818 padding-top: 8px;
819 border-top: 1px dashed var(--border);
820 }
821 .pchat-aside-toggle-btn {
822 width: 100%;
823 padding: 7px 10px;
824 background: transparent;
825 border: 1px solid var(--border);
826 border-radius: 8px;
827 color: var(--text-muted);
828 font-size: 12px;
829 cursor: pointer;
830 font-family: inherit;
831 }
832 .pchat-aside-toggle-btn:hover {
833 color: var(--text);
834 border-color: var(--border-strong, var(--border));
835 }
836
837 .pchat-main { display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; }
838 .pchat-log { display: flex; flex-direction: column; gap: 14px; min-height: 240px; }
839 .pchat-empty {
840 display: flex; gap: 14px;
841 padding: clamp(24px, 4vw, 36px);
842 background: var(--bg-elevated);
843 border: 1px dashed var(--border-strong, var(--border));
844 border-radius: 14px;
845 align-items: flex-start;
846 }
847 .pchat-empty-avatar {
848 display: inline-flex; align-items: center; justify-content: center;
849 width: 36px; height: 36px;
850 border-radius: 10px;
6fd5915Claude851 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.10));
e589f77ccantynz-alt852 color: var(--accent);
6fd5915Claude853 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
ee7e577Claude854 flex-shrink: 0;
855 }
856 .pchat-empty-title {
857 font-family: var(--font-display);
858 font-size: 16px;
859 font-weight: 700;
860 color: var(--text-strong);
861 margin: 0 0 4px;
862 letter-spacing: -0.012em;
863 }
864 .pchat-empty-sub {
865 margin: 0;
866 color: var(--text-muted);
867 font-size: 13.5px;
868 line-height: 1.55;
869 }
870
871 .pchat-msg { display: flex; gap: 10px; align-items: flex-start; max-width: 100%; }
872 .pchat-msg-assistant { justify-content: flex-start; }
873 .pchat-msg-user { justify-content: flex-end; }
874 .pchat-msg-avatar {
875 display: inline-flex; align-items: center; justify-content: center;
876 width: 32px; height: 32px;
877 border-radius: 9999px;
878 flex-shrink: 0;
879 font-family: var(--font-mono);
880 font-size: 12px;
881 font-weight: 700;
882 }
883 .pchat-msg-avatar-ai {
6fd5915Claude884 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
ee7e577Claude885 color: #fff;
6fd5915Claude886 box-shadow: 0 0 0 1px rgba(91,110,232,0.18), 0 6px 18px -6px rgba(91,110,232,0.45);
ee7e577Claude887 }
888 .pchat-msg-avatar-user {
889 background: rgba(255,255,255,0.05);
890 color: var(--text-strong);
891 box-shadow: inset 0 0 0 1px var(--border-strong, var(--border));
892 }
893 .pchat-msg-bubble-wrap {
894 display: flex; flex-direction: column; gap: 4px;
895 max-width: min(700px, calc(100% - 48px));
896 min-width: 0;
897 }
898 .pchat-msg-user .pchat-msg-bubble-wrap { align-items: flex-end; }
899 .pchat-msg-role {
900 font-family: var(--font-mono);
901 font-size: 10.5px;
902 text-transform: uppercase;
903 letter-spacing: 0.14em;
904 color: var(--text-muted);
905 font-weight: 700;
906 padding: 0 2px;
907 }
908 .pchat-msg-bubble {
909 padding: 11px 14px;
910 border-radius: 14px;
911 font-size: 14px;
912 line-height: 1.55;
913 color: var(--text);
914 background: var(--bg-elevated);
915 border: 1px solid var(--border);
916 white-space: pre-wrap;
917 word-wrap: break-word;
918 overflow-wrap: anywhere;
919 }
920 .pchat-msg-assistant .pchat-msg-bubble {
921 border-top-left-radius: 4px;
922 background:
6fd5915Claude923 linear-gradient(180deg, rgba(91,110,232,0.04), transparent 60%),
ee7e577Claude924 var(--bg-elevated);
6fd5915Claude925 border-color: rgba(91,110,232,0.22);
ee7e577Claude926 }
927 .pchat-msg-user .pchat-msg-bubble {
928 border-top-right-radius: 4px;
6fd5915Claude929 background: rgba(91,110,232,0.06);
930 border-color: rgba(91,110,232,0.20);
ee7e577Claude931 color: var(--text-strong);
932 }
933 .pchat-msg-cost {
934 font-family: var(--font-mono);
935 font-size: 10.5px;
936 color: var(--text-muted);
937 padding: 0 2px;
938 }
939
940 .pchat-caution {
941 display: inline-flex; align-items: center; gap: 6px;
942 margin-top: 2px;
943 padding: 4px 10px;
944 border-radius: 8px;
945 background: rgba(251,191,36,0.08);
946 color: #fde68a;
947 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.22);
948 font-size: 11.5px;
949 line-height: 1.4;
950 }
951
952 .pchat-sources { margin-top: 4px; }
953 .pchat-sources-head {
954 cursor: pointer;
955 list-style: none;
956 display: inline-flex; align-items: center; gap: 6px;
957 font-family: var(--font-mono);
958 font-size: 11px;
959 text-transform: uppercase;
960 letter-spacing: 0.12em;
961 color: var(--text-muted);
962 font-weight: 700;
963 padding: 4px 8px;
964 border-radius: 8px;
6fd5915Claude965 background: rgba(91,110,232,0.05);
966 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.18);
ee7e577Claude967 }
968 .pchat-sources-head::-webkit-details-marker { display: none; }
6fd5915Claude969 .pchat-sources-head:hover { background: rgba(91,110,232,0.08); }
ee7e577Claude970 .pchat-sources-list {
971 list-style: none; margin: 6px 0 0; padding: 0;
972 display: flex; flex-direction: column; gap: 2px;
973 }
974 .pchat-sources-link {
975 display: inline-flex; align-items: baseline; gap: 4px;
976 font-family: var(--font-mono);
977 font-size: 12px;
978 padding: 3px 8px;
979 border-radius: 6px;
980 color: var(--accent);
981 text-decoration: none;
982 }
983 .pchat-sources-link:hover {
6fd5915Claude984 background: rgba(91,110,232,0.08);
ee7e577Claude985 text-decoration: none;
986 }
987 .pchat-sources-repo {
e589f77ccantynz-alt988 color: var(--accent);
ee7e577Claude989 font-weight: 700;
990 }
991 .pchat-sources-sep { color: var(--text-muted); }
992 .pchat-sources-path { color: var(--accent); }
993
994 .pchat-composer-shell {
995 position: relative;
996 background: var(--bg-elevated);
997 border: 1px solid var(--border-strong, var(--border));
998 border-radius: 16px;
999 overflow: hidden;
1000 transition: border-color 140ms ease, box-shadow 140ms ease;
1001 }
1002 .pchat-composer-shell:focus-within {
6fd5915Claude1003 border-color: rgba(91,110,232,0.55);
1004 box-shadow: 0 0 0 4px rgba(91,110,232,0.16);
ee7e577Claude1005 }
1006 .pchat-composer-shell::before {
1007 content: '';
1008 position: absolute;
1009 top: 0; left: 0; right: 0;
1010 height: 1px;
6fd5915Claude1011 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
ee7e577Claude1012 opacity: 0.6;
1013 pointer-events: none;
1014 }
1015 .pchat-composer-input {
1016 display: block;
1017 width: 100%;
1018 box-sizing: border-box;
1019 padding: 14px 16px 10px;
1020 background: transparent;
1021 border: 0;
1022 outline: 0;
1023 resize: vertical;
1024 min-height: 84px;
1025 color: var(--text);
1026 font-family: inherit;
1027 font-size: 14.5px;
1028 line-height: 1.55;
1029 }
1030 .pchat-composer-input:disabled { opacity: 0.5; cursor: not-allowed; }
1031 .pchat-composer-input::placeholder { color: var(--text-faint, var(--text-muted)); }
1032 .pchat-composer-foot {
1033 display: flex; align-items: center; justify-content: space-between;
1034 gap: 12px;
1035 padding: 8px 12px 10px;
1036 border-top: 1px solid var(--border);
1037 background: rgba(255,255,255,0.012);
1038 flex-wrap: wrap;
1039 }
1040 .pchat-hint {
1041 display: inline-flex; align-items: center; gap: 8px;
1042 font-size: 12px;
1043 color: var(--text-muted);
1044 flex-wrap: wrap;
1045 }
1046 .pchat-kbd {
1047 display: inline-flex; align-items: center; justify-content: center;
1048 min-width: 22px;
1049 padding: 1px 6px;
1050 font-family: var(--font-mono);
1051 font-size: 11px;
1052 color: var(--text);
1053 background: rgba(255,255,255,0.04);
1054 border: 1px solid var(--border);
1055 border-radius: 4px;
1056 }
1057 .pchat-tokens {
1058 font-family: var(--font-mono);
1059 font-size: 11px;
1060 color: var(--text-muted);
1061 font-variant-numeric: tabular-nums;
1062 }
1063 .pchat-submit {
1064 display: inline-flex; align-items: center; gap: 8px;
1065 padding: 9px 16px;
1066 font-size: 13.5px;
1067 font-weight: 600;
1068 color: #fff;
1069 border: 1px solid transparent;
1070 border-radius: 10px;
1071 cursor: pointer;
6fd5915Claude1072 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
1073 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
ee7e577Claude1074 transition: transform 120ms ease, box-shadow 120ms ease;
1075 font-family: inherit;
1076 line-height: 1;
1077 }
1078 .pchat-submit:hover:not(:disabled) {
1079 transform: translateY(-1px);
6fd5915Claude1080 box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
ee7e577Claude1081 }
1082 .pchat-submit:disabled { opacity: 0.55; cursor: not-allowed; }
1083 .pchat-submit:active { transform: translateY(0); }
1084
1085 /* Floating "Personal chat" link — surfaced because the global nav user
1086 * menu is a locked component (src/views/layout.tsx) we can't modify in
1087 * this block. The link is fixed bottom-right; visible only on / and
1088 * dashboard-style pages to keep it discoverable without ever overlapping
1089 * the chat surface itself.
1090 */
1091 .pchat-floating-link {
1092 position: fixed;
1093 bottom: 24px; right: 24px;
1094 display: inline-flex; align-items: center; gap: 8px;
1095 padding: 10px 14px;
1096 font-size: 13px;
1097 font-weight: 600;
1098 color: #fff;
1099 border-radius: 9999px;
1100 text-decoration: none;
6fd5915Claude1101 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
1102 box-shadow: 0 10px 28px -8px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.16);
ee7e577Claude1103 z-index: 90;
1104 }
1105
1106 @media (max-width: 640px) {
1107 .pchat-msg-bubble-wrap { max-width: calc(100% - 44px); }
1108 .pchat-composer-foot { gap: 8px; }
1109 .pchat-submit { padding: 8px 14px; font-size: 13px; }
1110 }
1111`;
1112
1113const pchatClientJs = `
1114(function(){
1115 var input = document.querySelector('.pchat-composer-input');
1116 var tokensEl = document.getElementById('pchat-tokens');
1117 if (input && tokensEl) {
1118 var update = function(){
1119 var n = Math.ceil((input.value || '').length / 4);
1120 tokensEl.textContent = '~' + n + ' tokens';
1121 tokensEl.setAttribute('data-token-cost', String(n));
1122 };
1123 input.addEventListener('input', update);
1124 update();
1125 }
1126 if (input) {
1127 input.addEventListener('keydown', function(e){
1128 if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
1129 var form = input.form;
1130 if (form) form.submit();
1131 }
1132 });
1133 }
1134})();
1135`;
1136
1137export default personalChatRoutes;