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

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