Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit38d31d3unknown_key

feat(repo-chat): /:owner/:repo/chat — rubber-duck w/ semantic index + streaming

Claude committed on May 25, 2026Parent: 6493d91
7 files changed+2233038d31d383a1c4c99ed674ff94f03c6408b74d48f
7 changed files+2233−0
Addeddrizzle/0060_repo_chats.sql+69−0View fileUnifiedSplit
1-- Gluecron migration 0060: AI repo rubber-duck chat tables.
2--
3-- Per-repo, per-user conversational threads grounded in the semantic
4-- index (see src/lib/semantic-index.ts). Distinct from the older
5-- `ai_chats` table, which is a JSON-blob single-row design that pre-
6-- dates streaming. The new model stores one row per message so we
7-- can stream partials, attach per-message citations, and track token
8-- cost without rewriting the whole blob on each turn.
9--
10-- Wrapped in DO blocks so the migration is safe to re-run and
11-- gracefully ignores duplicates / missing parents on partial replays.
12
13--> statement-breakpoint
14DO $$
15BEGIN
16 CREATE TABLE IF NOT EXISTS "repo_chats" (
17 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
18 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
19 "owner_user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
20 "title" text,
21 "created_at" timestamptz NOT NULL DEFAULT now(),
22 "updated_at" timestamptz NOT NULL DEFAULT now()
23 );
24EXCEPTION WHEN OTHERS THEN
25 RAISE NOTICE 'repo_chats create failed (%); repo chat will be unavailable', SQLERRM;
26END $$;
27
28--> statement-breakpoint
29DO $$
30BEGIN
31 CREATE INDEX IF NOT EXISTS "repo_chats_owner_updated"
32 ON "repo_chats" ("owner_user_id", "updated_at" DESC);
33EXCEPTION WHEN OTHERS THEN
34 RAISE NOTICE 'repo_chats_owner_updated index failed (%)', SQLERRM;
35END $$;
36
37--> statement-breakpoint
38DO $$
39BEGIN
40 CREATE INDEX IF NOT EXISTS "repo_chats_repo"
41 ON "repo_chats" ("repository_id");
42EXCEPTION WHEN OTHERS THEN
43 RAISE NOTICE 'repo_chats_repo index failed (%)', SQLERRM;
44END $$;
45
46--> statement-breakpoint
47DO $$
48BEGIN
49 CREATE TABLE IF NOT EXISTS "repo_chat_messages" (
50 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
51 "chat_id" uuid NOT NULL REFERENCES "repo_chats"("id") ON DELETE CASCADE,
52 "role" text NOT NULL,
53 "content" text NOT NULL,
54 "citations" jsonb NOT NULL DEFAULT '[]'::jsonb,
55 "token_cost" integer NOT NULL DEFAULT 0,
56 "created_at" timestamptz NOT NULL DEFAULT now()
57 );
58EXCEPTION WHEN OTHERS THEN
59 RAISE NOTICE 'repo_chat_messages create failed (%); repo chat will be unavailable', SQLERRM;
60END $$;
61
62--> statement-breakpoint
63DO $$
64BEGIN
65 CREATE INDEX IF NOT EXISTS "repo_chat_messages_chat_created"
66 ON "repo_chat_messages" ("chat_id", "created_at" ASC);
67EXCEPTION WHEN OTHERS THEN
68 RAISE NOTICE 'repo_chat_messages_chat_created index failed (%)', SQLERRM;
69END $$;
Addedsrc/__tests__/repo-chat.test.ts+417−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/repo-chat.ts — the AI rubber-duck chat helpers.
3 *
4 * Layered:
5 *
6 * 1. Pure helpers — no DB, no network. Always run.
7 * - extractTextDelta unpacks Anthropic stream events correctly.
8 * - The streamer test-seam swaps the real Claude call.
9 *
10 * 2. DB-backed pipeline — gated on HAS_DB so the suite stays green on
11 * machines without Postgres. Uses the test-seam to inject canned
12 * tokens and a stub semantic index so the assistant reply is
13 * deterministic.
14 *
15 * 3. AI-key validation surface — gated on HAS_AI. The lib itself
16 * degrades gracefully without a key, so the "AI-required" path is
17 * only meaningfully exercised when ANTHROPIC_API_KEY is set; we
18 * still assert the no-key fallback emits a recognisable message.
19 */
20
21import {
22 afterAll,
23 afterEach,
24 beforeAll,
25 beforeEach,
26 describe,
27 expect,
28 it,
29} from "bun:test";
30import { join } from "path";
31import { mkdir, rm } from "fs/promises";
32import { randomBytes } from "crypto";
33
34import {
35 __setStreamerForTests,
36 __test,
37 appendUserMessage,
38 createChat,
39 getChatForUser,
40 listChatsForRepo,
41 listMessages,
42 streamAssistantReply,
43} from "../lib/repo-chat";
44import {
45 __setEmbedderForTests,
46 EMBEDDING_DIM,
47} from "../lib/semantic-index";
48import { initBareRepo, getRepoPath } from "../git/repository";
49
50const HAS_DB = Boolean(process.env.DATABASE_URL);
51const HAS_AI = Boolean(process.env.ANTHROPIC_API_KEY);
52
53const TEST_REPOS = join(
54 import.meta.dir,
55 "../../.test-repos-repo-chat-" + Date.now()
56);
57
58beforeAll(async () => {
59 process.env.GIT_REPOS_PATH = TEST_REPOS;
60 process.env.GLUECRON_SEMANTIC_CACHE_DIR = join(TEST_REPOS, "_cache");
61 await rm(TEST_REPOS, { recursive: true, force: true });
62 await mkdir(TEST_REPOS, { recursive: true });
63});
64
65afterAll(async () => {
66 __setStreamerForTests(null);
67 __setEmbedderForTests(null);
68 await rm(TEST_REPOS, { recursive: true, force: true });
69});
70
71beforeEach(() => {
72 __setStreamerForTests(null);
73 __setEmbedderForTests(null);
74});
75
76afterEach(() => {
77 __setStreamerForTests(null);
78 __setEmbedderForTests(null);
79});
80
81// ---------------------------------------------------------------------------
82// 1. Pure helpers
83// ---------------------------------------------------------------------------
84
85describe("repo-chat — extractTextDelta", () => {
86 it("unpacks a content_block_delta text_delta", () => {
87 const out = __test.extractTextDelta({
88 type: "content_block_delta",
89 index: 0,
90 delta: { type: "text_delta", text: "hello" },
91 });
92 expect(out).toBe("hello");
93 });
94
95 it("returns '' for non-text events", () => {
96 expect(__test.extractTextDelta({ type: "message_start" })).toBe("");
97 expect(
98 __test.extractTextDelta({
99 type: "content_block_delta",
100 delta: { type: "input_json_delta", partial_json: "x" },
101 })
102 ).toBe("");
103 });
104
105 it("returns '' for malformed input", () => {
106 expect(__test.extractTextDelta(null)).toBe("");
107 expect(__test.extractTextDelta("not an object")).toBe("");
108 expect(__test.extractTextDelta({})).toBe("");
109 });
110});
111
112// ---------------------------------------------------------------------------
113// 2. Helpers — graceful no-ops without DB
114// ---------------------------------------------------------------------------
115
116describe("repo-chat — graceful no-ops", () => {
117 it("createChat returns null for missing repo id", async () => {
118 const out = await createChat({
119 repositoryId: "",
120 ownerUserId: "00000000-0000-0000-0000-000000000000",
121 });
122 expect(out).toBeNull();
123 });
124
125 it("appendUserMessage returns null for missing chat id", async () => {
126 const out = await appendUserMessage("", "hi");
127 expect(out).toBeNull();
128 });
129
130 it("listMessages returns [] for missing chat id", async () => {
131 const out = await listMessages("");
132 expect(out).toEqual([]);
133 });
134
135 it("listChatsForRepo returns [] for missing ids", async () => {
136 const out = await listChatsForRepo("", "");
137 expect(out).toEqual([]);
138 });
139
140 it("getChatForUser returns null for missing ids", async () => {
141 const out = await getChatForUser("", "");
142 expect(out).toBeNull();
143 });
144});
145
146// ---------------------------------------------------------------------------
147// 3. DB-backed pipeline with stubbed streamer + semantic index.
148// ---------------------------------------------------------------------------
149
150describe.skipIf(!HAS_DB)("repo-chat — DB-backed pipeline", () => {
151 it.skipIf(!HAS_DB)(
152 "createChat → appendUserMessage → streamAssistantReply persists with citations",
153 async () => {
154 const { db } = await import("../db");
155 const {
156 users,
157 repositories,
158 repoChats,
159 repoChatMessages,
160 codeEmbeddings,
161 } = await import("../db/schema");
162 const { eq } = await import("drizzle-orm");
163
164 const stamp = randomBytes(4).toString("hex");
165 const username = `rchat-${stamp}`;
166 const reponame = `rchat-${stamp}`;
167
168 const [u] = await db
169 .insert(users)
170 .values({
171 username,
172 email: `${username}@test.local`,
173 passwordHash: "x",
174 })
175 .returning();
176 if (!u) return;
177
178 // Real bare repo so the optional getBlob lookup in
179 // buildGroundingContext can succeed if pgvector + a HEAD exist.
180 // We don't strictly need that path here; the canned semantic
181 // index hits below carry their own snippet text.
182 await initBareRepo(username, reponame);
183
184 const [r] = await db
185 .insert(repositories)
186 .values({
187 name: reponame,
188 ownerId: u.id,
189 diskPath: getRepoPath(username, reponame),
190 defaultBranch: "main",
191 })
192 .returning();
193 if (!r) return;
194
195 // Stub the semantic index by pre-inserting code_embeddings rows.
196 // The searchSemantic path will rank these via pgvector; if
197 // pgvector is missing on the test host we still get a tree-of-
198 // paths fallback (bare repo → empty tree → empty fallback),
199 // which is also fine for asserting the persistence pipeline.
200 const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0);
201 fakeVec[0] = 1;
202 try {
203 await db.insert(codeEmbeddings).values([
204 {
205 repositoryId: r.id,
206 filePath: "src/auth.ts",
207 blobSha: "deadbeef",
208 commitSha: "deadbeef",
209 contentSnippet: "export function login() {}",
210 embedding: fakeVec,
211 embeddingModel: "stub",
212 },
213 {
214 repositoryId: r.id,
215 filePath: "src/db.ts",
216 blobSha: "cafebabe",
217 commitSha: "cafebabe",
218 contentSnippet: "export function connect() {}",
219 embedding: fakeVec,
220 embeddingModel: "stub",
221 },
222 ]);
223 } catch {
224 // pgvector missing — search will return [] and the lib will
225 // fall back to a tree-of-paths summary. That's fine: the
226 // assertions below only check persistence + citations<=N.
227 }
228
229 // Force the embedder to a stable vector so any real searchSemantic
230 // call inside the lib uses something deterministic.
231 __setEmbedderForTests(async () => ({
232 vector: fakeVec,
233 model: "stub-1024",
234 }));
235
236 // Canned assistant tokens — splits "Hello world!" into 3 chunks.
237 const tokens = ["Hel", "lo ", "world!"];
238 __setStreamerForTests(async function* () {
239 for (const t of tokens) yield t;
240 });
241
242 // 1. Create chat.
243 const chat = await createChat({
244 repositoryId: r.id,
245 ownerUserId: u.id,
246 title: "test chat",
247 });
248 expect(chat).not.toBeNull();
249 if (!chat) return;
250 expect(chat.repositoryId).toBe(r.id);
251 expect(chat.ownerUserId).toBe(u.id);
252
253 // 2. Append user message.
254 const userMsg = await appendUserMessage(chat.id, "Where is auth?");
255 expect(userMsg).not.toBeNull();
256 if (!userMsg) return;
257 expect(userMsg.role).toBe("user");
258 expect(userMsg.content).toBe("Where is auth?");
259
260 // 3. Stream assistant reply — collect chunks via onChunk.
261 const seen: string[] = [];
262 const reply = await streamAssistantReply({
263 chatId: chat.id,
264 repoId: r.id,
265 userMessage: "Where is auth?",
266 onChunk: (chunk) => {
267 seen.push(chunk);
268 },
269 });
270
271 // Chunks delivered in order, fully concatenated to "Hello world!".
272 expect(seen).toEqual(tokens);
273 expect(reply).not.toBeNull();
274 if (!reply) return;
275 expect(reply.role).toBe("assistant");
276 expect(reply.content).toBe("Hello world!");
277 expect(reply.tokenCost).toBeGreaterThan(0);
278
279 // 4. Citations: shape is array of { file_path, blob_sha }. Length
280 // depends on whether pgvector was available on this host. We
281 // only assert the contract, not the count.
282 const citations = reply.citations;
283 expect(Array.isArray(citations)).toBe(true);
284 for (const c of citations) {
285 expect(typeof c.file_path).toBe("string");
286 expect(typeof c.blob_sha).toBe("string");
287 }
288
289 // 5. listMessages includes both rows in order.
290 const all = await listMessages(chat.id);
291 expect(all.length).toBe(2);
292 expect(all[0].role).toBe("user");
293 expect(all[1].role).toBe("assistant");
294
295 // 6. listChatsForRepo surfaces the chat with refreshed updatedAt.
296 const chats = await listChatsForRepo(u.id, r.id);
297 expect(chats.length).toBeGreaterThanOrEqual(1);
298 expect(chats.find((ch) => ch.id === chat.id)).toBeDefined();
299
300 // 7. getChatForUser authorises by owner.
301 const ownerHit = await getChatForUser(chat.id, u.id);
302 expect(ownerHit?.id).toBe(chat.id);
303 const otherMiss = await getChatForUser(
304 chat.id,
305 "00000000-0000-0000-0000-000000000000"
306 );
307 expect(otherMiss).toBeNull();
308
309 // Cleanup.
310 await db
311 .delete(repoChatMessages)
312 .where(eq(repoChatMessages.chatId, chat.id));
313 await db.delete(repoChats).where(eq(repoChats.id, chat.id));
314 try {
315 await db
316 .delete(codeEmbeddings)
317 .where(eq(codeEmbeddings.repositoryId, r.id));
318 } catch {
319 /* may not exist if pgvector was missing */
320 }
321 await db.delete(repositories).where(eq(repositories.id, r.id));
322 await db.delete(users).where(eq(users.id, u.id));
323 }
324 );
325
326 it.skipIf(!HAS_DB)(
327 "streamAssistantReply caps reply length + persists fallback when streamer throws",
328 async () => {
329 const { db } = await import("../db");
330 const {
331 users,
332 repositories,
333 repoChats,
334 repoChatMessages,
335 } = await import("../db/schema");
336 const { eq } = await import("drizzle-orm");
337
338 const stamp = randomBytes(4).toString("hex");
339 const username = `rchatx-${stamp}`;
340 const reponame = `rchatx-${stamp}`;
341
342 const [u] = await db
343 .insert(users)
344 .values({
345 username,
346 email: `${username}@test.local`,
347 passwordHash: "x",
348 })
349 .returning();
350 if (!u) return;
351
352 await initBareRepo(username, reponame);
353 const [r] = await db
354 .insert(repositories)
355 .values({
356 name: reponame,
357 ownerId: u.id,
358 diskPath: getRepoPath(username, reponame),
359 defaultBranch: "main",
360 })
361 .returning();
362 if (!r) return;
363
364 const chat = await createChat({
365 repositoryId: r.id,
366 ownerUserId: u.id,
367 });
368 if (!chat) return;
369
370 // Streamer throws — lib must still persist an advisory reply.
371 __setStreamerForTests(async function* () {
372 // Yield nothing then throw — exercises the catch path.
373 if (false as boolean) yield "";
374 throw new Error("synthetic stream failure");
375 });
376
377 const reply = await streamAssistantReply({
378 chatId: chat.id,
379 repoId: r.id,
380 userMessage: "test",
381 });
382 expect(reply).not.toBeNull();
383 if (!reply) return;
384 expect(reply.content.length).toBeGreaterThan(0);
385 // Advisory copy contains "couldn't" or similar — be lenient.
386 expect(reply.content.toLowerCase()).toContain("ai");
387
388 // Cleanup.
389 await db
390 .delete(repoChatMessages)
391 .where(eq(repoChatMessages.chatId, chat.id));
392 await db.delete(repoChats).where(eq(repoChats.id, chat.id));
393 await db.delete(repositories).where(eq(repositories.id, r.id));
394 await db.delete(users).where(eq(users.id, u.id));
395 }
396 );
397});
398
399// ---------------------------------------------------------------------------
400// 4. HAS_AI-gated — smoke test that the lib accepts a real key without
401// actually calling Anthropic (still routed through the test-seam).
402// ---------------------------------------------------------------------------
403
404describe.skipIf(!HAS_AI)("repo-chat — HAS_AI surface", () => {
405 it("test-seam wins over the real Claude call when set", async () => {
406 __setStreamerForTests(async function* () {
407 yield "seam-only";
408 });
409
410 // We can't easily invoke streamAssistantReply here without a chat
411 // row + DB, but we can call the helper indirectly via the test seam
412 // contract: the override is what runs, not the SDK. Verified by
413 // exercising one of the DB-backed tests above when HAS_DB is also
414 // set; this case is the no-DB no-network sanity check.
415 expect(typeof __setStreamerForTests).toBe("function");
416 });
417});
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
8989import aiExplainRoutes from "./routes/ai-explain";
9090import aiTestsRoutes from "./routes/ai-tests";
9191import askRoutes from "./routes/ask";
92import repoChatRoutes from "./routes/repo-chat";
9293import billingRoutes from "./routes/billing";
9394import stripeWebhookRoutes from "./routes/stripe-webhook";
9495import codeScanningRoutes from "./routes/code-scanning";
532533app.route("/", aiExplainRoutes);
533534app.route("/", aiTestsRoutes);
534535app.route("/", askRoutes);
536app.route("/", repoChatRoutes);
535537app.route("/", billingRoutes);
536538app.route("/", stripeWebhookRoutes);
537539app.route("/", codeScanningRoutes);
Modifiedsrc/db/schema.ts+58−0View fileUnifiedSplit
30643064export type AgentLease = typeof agentLeases.$inferSelect;
30653065export type NewAgentLease = typeof agentLeases.$inferInsert;
30663066
3067// ---------------------------------------------------------------------------
3068// 0060 — AI repo rubber-duck chat.
3069// One row per chat thread + one row per message. Distinct from the older
3070// `ai_chats` JSON-blob design so streaming partials, per-message citations,
3071// and token-cost accounting all stay first-class. See src/lib/repo-chat.ts
3072// for the helpers + src/routes/repo-chat.tsx for the UI.
3073// ---------------------------------------------------------------------------
3074export const repoChats = pgTable(
3075 "repo_chats",
3076 {
3077 id: uuid("id").primaryKey().defaultRandom(),
3078 repositoryId: uuid("repository_id")
3079 .notNull()
3080 .references(() => repositories.id, { onDelete: "cascade" }),
3081 ownerUserId: uuid("owner_user_id")
3082 .notNull()
3083 .references(() => users.id, { onDelete: "cascade" }),
3084 title: text("title"),
3085 createdAt: timestamp("created_at", { withTimezone: true })
3086 .defaultNow()
3087 .notNull(),
3088 updatedAt: timestamp("updated_at", { withTimezone: true })
3089 .defaultNow()
3090 .notNull(),
3091 },
3092 (table) => [
3093 index("repo_chats_owner_updated").on(table.ownerUserId, table.updatedAt),
3094 index("repo_chats_repo").on(table.repositoryId),
3095 ]
3096);
3097
3098export const repoChatMessages = pgTable(
3099 "repo_chat_messages",
3100 {
3101 id: uuid("id").primaryKey().defaultRandom(),
3102 chatId: uuid("chat_id")
3103 .notNull()
3104 .references(() => repoChats.id, { onDelete: "cascade" }),
3105 // 'user' | 'assistant' | 'system'
3106 role: text("role").notNull(),
3107 content: text("content").notNull(),
3108 // Array of { file_path, blob_sha }. jsonb so we can index later if needed.
3109 citations: jsonb("citations").$type<Array<{ file_path: string; blob_sha: string }>>().notNull().default([]),
3110 tokenCost: integer("token_cost").notNull().default(0),
3111 createdAt: timestamp("created_at", { withTimezone: true })
3112 .defaultNow()
3113 .notNull(),
3114 },
3115 (table) => [
3116 index("repo_chat_messages_chat_created").on(table.chatId, table.createdAt),
3117 ]
3118);
3119
3120export type RepoChat = typeof repoChats.$inferSelect;
3121export type NewRepoChat = typeof repoChats.$inferInsert;
3122export type RepoChatMessage = typeof repoChatMessages.$inferSelect;
3123export type NewRepoChatMessage = typeof repoChatMessages.$inferInsert;
3124
Addedsrc/lib/repo-chat.ts+576−0View fileUnifiedSplit
1/**
2 * AI rubber-duck chat — repo-grounded conversation backed by the
3 * continuous semantic index (src/lib/semantic-index.ts) and Claude
4 * streaming.
5 *
6 * Why a new module instead of extending `ai-chat.ts`?
7 *
8 * - `ai-chat.ts` is built around a single-blob JSON history in
9 * `ai_chats.messages`. Streaming partials, per-message citations,
10 * and token-cost accounting would require rewriting the whole
11 * blob on each turn. The 0060 migration introduces dedicated
12 * `repo_chats` + `repo_chat_messages` tables, one row per
13 * message, which makes streaming and citations first-class.
14 * - Repo chat retrieves grounding context via the per-push semantic
15 * index, which is fundamentally different from the static
16 * README+tree summarisation in `ai-chat.ts`. Keeping the two
17 * codepaths separate avoids muddling the abstraction.
18 *
19 * Public surface:
20 *
21 * - `createChat({ repositoryId, ownerUserId, title? })` →
22 * creates a row in `repo_chats` and returns it.
23 * - `appendUserMessage(chatId, content)` → stores a `role:'user'`
24 * row in `repo_chat_messages` and returns it.
25 * - `streamAssistantReply({ chatId, repoId, userMessage, onChunk })`
26 * → resolves grounding context, streams Claude tokens via the
27 * `onChunk` callback, persists the final assistant message with
28 * citations, returns the stored row.
29 * - `__setStreamerForTests` → test seam so unit tests can replace
30 * the Claude streaming call with a deterministic generator.
31 *
32 * Hard rules:
33 * - Never throw at the boundary. Every external dependency
34 * (semantic index, git blob fetch, Claude API, DB) is wrapped;
35 * on failure we fall back to a safe default and continue.
36 * - Graceful when the semantic index is empty: fall back to a
37 * truncated tree-of-paths summary so the assistant still has
38 * *some* signal about the repo's shape.
39 * - All DB writes are best-effort; a DB outage degrades the chat
40 * to ephemeral mode rather than throwing.
41 */
42
43import { and, asc, desc, eq } from "drizzle-orm";
44import { db } from "../db";
45import {
46 repoChats,
47 repoChatMessages,
48 repositories,
49 users,
50 type RepoChat,
51 type RepoChatMessage,
52} from "../db/schema";
53import { searchSemantic, type SemanticHit } from "./semantic-index";
54import {
55 getBlob,
56 getDefaultBranch,
57 getTreeRecursive,
58} from "../git/repository";
59import { getAnthropic, isAiAvailable, MODEL_SONNET } from "./ai-client";
60
61// ---------------------------------------------------------------------------
62// Constants
63// ---------------------------------------------------------------------------
64
65/** Max semantic hits we feed the assistant. */
66const DEFAULT_TOP_K = 8;
67
68/** Max chars of file content per snippet (after path + heading). */
69const MAX_SNIPPET_CHARS = 1500;
70
71/** Max paths in the empty-index fallback tree summary. */
72const FALLBACK_TREE_CAP = 120;
73
74/** Hard cap on stored title length. */
75const TITLE_LIMIT = 80;
76
77/** Hard cap on the assistant reply we'll persist. */
78const ASSISTANT_REPLY_CAP = 32_000;
79
80// ---------------------------------------------------------------------------
81// Types
82// ---------------------------------------------------------------------------
83
84export interface Citation {
85 file_path: string;
86 blob_sha: string;
87}
88
89export interface CreateChatOpts {
90 repositoryId: string;
91 ownerUserId: string;
92 title?: string | null;
93}
94
95export interface StreamReplyOpts {
96 chatId: string;
97 repoId: string;
98 userMessage: string;
99 /**
100 * Called for each token / text delta emitted by the assistant.
101 * Implementations may ignore the chunk (e.g. tests that only care
102 * about the final reply) — the helper still accumulates and stores
103 * the full reply regardless.
104 */
105 onChunk?: (chunk: string) => void;
106 /**
107 * Optional override of the top-K semantic hits. Useful for tests
108 * + the SSE endpoint where the caller wants to thin the context
109 * for cost reasons.
110 */
111 topK?: number;
112}
113
114/**
115 * Test seam — replace the streaming call with a deterministic generator.
116 * Pass `null` to reset. Each chunk yielded becomes one token in the
117 * persisted reply (and one `onChunk` invocation).
118 */
119export type StreamerFn = (args: {
120 systemPrompt: string;
121 userMessage: string;
122}) => AsyncIterable<string>;
123
124let _streamerOverride: StreamerFn | null = null;
125
126export function __setStreamerForTests(fn: StreamerFn | null): void {
127 _streamerOverride = fn;
128}
129
130// ---------------------------------------------------------------------------
131// Public API
132// ---------------------------------------------------------------------------
133
134/**
135 * Create a new chat row scoped to (repository, owner_user). The title
136 * is optional; if absent we leave it null and the route layer can fill
137 * it in from the first user message.
138 */
139export async function createChat(opts: CreateChatOpts): Promise<RepoChat | null> {
140 if (!opts.repositoryId || !opts.ownerUserId) return null;
141 try {
142 const [row] = await db
143 .insert(repoChats)
144 .values({
145 repositoryId: opts.repositoryId,
146 ownerUserId: opts.ownerUserId,
147 title: (opts.title || "").slice(0, TITLE_LIMIT) || null,
148 })
149 .returning();
150 return row || null;
151 } catch (err) {
152 if (process.env.DEBUG_REPO_CHAT === "1") {
153 console.error("[repo-chat] createChat failed:", err);
154 }
155 return null;
156 }
157}
158
159/**
160 * Append a `role:'user'` row to the chat and bump `repo_chats.updated_at`
161 * so the chat list ordering stays fresh.
162 */
163export async function appendUserMessage(
164 chatId: string,
165 content: string
166): Promise<RepoChatMessage | null> {
167 if (!chatId || !content) return null;
168 try {
169 const [row] = await db
170 .insert(repoChatMessages)
171 .values({
172 chatId,
173 role: "user",
174 content,
175 citations: [],
176 tokenCost: 0,
177 })
178 .returning();
179 // Best-effort: keep the parent chat's `updated_at` warm.
180 try {
181 await db
182 .update(repoChats)
183 .set({ updatedAt: new Date() })
184 .where(eq(repoChats.id, chatId));
185 } catch {
186 /* tolerate */
187 }
188 return row || null;
189 } catch (err) {
190 if (process.env.DEBUG_REPO_CHAT === "1") {
191 console.error("[repo-chat] appendUserMessage failed:", err);
192 }
193 return null;
194 }
195}
196
197/**
198 * The full pipeline: resolve repo context (semantic hits → snippets →
199 * system prompt), stream Claude's reply token-by-token via `onChunk`,
200 * persist the final assistant row with citations + a coarse token
201 * cost estimate, and return it.
202 *
203 * Never throws. On any failure inside grounding/streaming we still
204 * try to persist a short advisory assistant message so the UI never
205 * shows a "phantom" user message with no reply.
206 */
207export async function streamAssistantReply(
208 opts: StreamReplyOpts
209): Promise<RepoChatMessage | null> {
210 const { chatId, repoId, userMessage } = opts;
211 const topK = Math.max(1, Math.min(opts.topK ?? DEFAULT_TOP_K, 20));
212
213 // 1. Resolve grounding context.
214 const { citations, contextBlock } = await buildGroundingContext({
215 repoId,
216 userMessage,
217 topK,
218 });
219
220 // 2. Build the system prompt.
221 const systemPrompt = await buildSystemPrompt({
222 repoId,
223 contextBlock,
224 });
225
226 // 3. Stream Claude's reply (or canned tokens in tests).
227 let reply = "";
228 try {
229 const stream = _streamerOverride
230 ? _streamerOverride({ systemPrompt, userMessage })
231 : claudeStream({ systemPrompt, userMessage });
232
233 for await (const chunk of stream) {
234 if (!chunk) continue;
235 reply += chunk;
236 if (opts.onChunk) {
237 try {
238 opts.onChunk(chunk);
239 } catch {
240 // Caller-supplied callback errors mustn't kill the stream.
241 }
242 }
243 // Safety cap — Claude can produce very long outputs; we don't
244 // want a runaway response to blow up our row.
245 if (reply.length >= ASSISTANT_REPLY_CAP) break;
246 }
247 } catch (err) {
248 if (process.env.DEBUG_REPO_CHAT === "1") {
249 console.error("[repo-chat] stream failed:", err);
250 }
251 if (!reply) {
252 reply =
253 "Sorry — I couldn't reach the AI service to answer that. Please retry in a moment.";
254 }
255 }
256
257 // Cap the persisted reply.
258 if (reply.length > ASSISTANT_REPLY_CAP) {
259 reply = reply.slice(0, ASSISTANT_REPLY_CAP);
260 }
261
262 // 4. Coarse token-cost estimate: ~4 chars/token. Stored as integer.
263 const tokenCost = Math.ceil(
264 (systemPrompt.length + userMessage.length + reply.length) / 4
265 );
266
267 // 5. Persist.
268 try {
269 const [row] = await db
270 .insert(repoChatMessages)
271 .values({
272 chatId,
273 role: "assistant",
274 content: reply,
275 citations,
276 tokenCost,
277 })
278 .returning();
279 // Bump parent chat freshness so list ordering reflects the answer.
280 try {
281 await db
282 .update(repoChats)
283 .set({ updatedAt: new Date() })
284 .where(eq(repoChats.id, chatId));
285 } catch {
286 /* tolerate */
287 }
288 return row || null;
289 } catch (err) {
290 if (process.env.DEBUG_REPO_CHAT === "1") {
291 console.error("[repo-chat] persist assistant failed:", err);
292 }
293 return null;
294 }
295}
296
297/**
298 * List all chats for a (user, repo) pair, ordered by most-recently
299 * updated. Returns [] on any DB failure.
300 */
301export async function listChatsForRepo(
302 ownerUserId: string,
303 repositoryId: string,
304 limit = 30
305): Promise<RepoChat[]> {
306 if (!ownerUserId || !repositoryId) return [];
307 try {
308 return await db
309 .select()
310 .from(repoChats)
311 .where(
312 and(
313 eq(repoChats.ownerUserId, ownerUserId),
314 eq(repoChats.repositoryId, repositoryId)
315 )
316 )
317 .orderBy(desc(repoChats.updatedAt))
318 .limit(Math.max(1, Math.min(limit, 100)));
319 } catch {
320 return [];
321 }
322}
323
324/**
325 * Fetch all messages in a chat, oldest first. Empty array on DB error.
326 */
327export async function listMessages(
328 chatId: string
329): Promise<RepoChatMessage[]> {
330 if (!chatId) return [];
331 try {
332 return await db
333 .select()
334 .from(repoChatMessages)
335 .where(eq(repoChatMessages.chatId, chatId))
336 .orderBy(asc(repoChatMessages.createdAt));
337 } catch {
338 return [];
339 }
340}
341
342/**
343 * Verify the chat belongs to the user. Returns the chat row or null.
344 * Used by the route handlers + the SSE endpoint to authorise access.
345 */
346export async function getChatForUser(
347 chatId: string,
348 ownerUserId: string
349): Promise<RepoChat | null> {
350 if (!chatId || !ownerUserId) return null;
351 try {
352 const [row] = await db
353 .select()
354 .from(repoChats)
355 .where(
356 and(eq(repoChats.id, chatId), eq(repoChats.ownerUserId, ownerUserId))
357 )
358 .limit(1);
359 return row || null;
360 } catch {
361 return null;
362 }
363}
364
365// ---------------------------------------------------------------------------
366// Grounding context — semantic hits + snippet fetch, with tree fallback.
367// ---------------------------------------------------------------------------
368
369async function buildGroundingContext(args: {
370 repoId: string;
371 userMessage: string;
372 topK: number;
373}): Promise<{ citations: Citation[]; contextBlock: string }> {
374 const { repoId, userMessage, topK } = args;
375
376 let hits: SemanticHit[] = [];
377 try {
378 hits = await searchSemantic({
379 repositoryId: repoId,
380 query: userMessage,
381 limit: topK,
382 });
383 } catch {
384 hits = [];
385 }
386
387 if (!hits.length) {
388 // Fallback: tree-of-paths summary so Claude still has *some*
389 // sense of repo shape. Best-effort — empty string on failure.
390 const treeSummary = await buildTreeFallback(repoId);
391 return {
392 citations: [],
393 contextBlock: treeSummary
394 ? `No semantic index is available for this repo (yet). Repo layout:\n\n${treeSummary}`
395 : "",
396 };
397 }
398
399 // Resolve owner/name for getBlob lookups.
400 const repoMeta = await loadRepoMeta(repoId);
401
402 const citations: Citation[] = [];
403 const sections: string[] = [];
404
405 for (const hit of hits) {
406 // The semantic-index already stores a snippet; prefer that for
407 // cost, but fetch a slightly larger window via getBlob when meta
408 // is available so the model sees real code, not a 500-char preview.
409 let snippet = hit.snippet || "";
410 if (repoMeta) {
411 try {
412 const blob = await getBlob(
413 repoMeta.owner,
414 repoMeta.name,
415 repoMeta.defaultBranch,
416 hit.filePath
417 );
418 if (blob && !blob.isBinary && blob.content) {
419 snippet = blob.content.slice(0, MAX_SNIPPET_CHARS);
420 }
421 } catch {
422 // tolerate; fall back to whatever snippet we already have
423 }
424 }
425 if (!snippet) continue;
426
427 citations.push({
428 file_path: hit.filePath,
429 blob_sha: hit.blobSha,
430 });
431 sections.push(`### ${hit.filePath}\n\`\`\`\n${snippet}\n\`\`\``);
432 }
433
434 return {
435 citations,
436 contextBlock: sections.join("\n\n"),
437 };
438}
439
440async function buildTreeFallback(repoId: string): Promise<string> {
441 const meta = await loadRepoMeta(repoId);
442 if (!meta) return "";
443 try {
444 const tree = await getTreeRecursive(
445 meta.owner,
446 meta.name,
447 meta.defaultBranch,
448 FALLBACK_TREE_CAP * 2
449 );
450 if (!tree) return "";
451 const blobs = tree.tree
452 .filter((e) => e.type === "blob")
453 .slice(0, FALLBACK_TREE_CAP)
454 .map((e) => `- ${e.path}`);
455 return blobs.join("\n");
456 } catch {
457 return "";
458 }
459}
460
461async function buildSystemPrompt(args: {
462 repoId: string;
463 contextBlock: string;
464}): Promise<string> {
465 const meta = await loadRepoMeta(args.repoId);
466 const owner = meta?.owner || "unknown";
467 const name = meta?.name || "repo";
468
469 const header = [
470 `You are Gluecron's repo chat for ${owner}/${name}.`,
471 `Here are the most relevant files based on the user's question:`,
472 "",
473 args.contextBlock || "(no grounding context available)",
474 "",
475 `Answer concisely. Cite files as [path](/${owner}/${name}/blob/HEAD/path).`,
476 `Prefer code snippets over prose when explaining concrete behaviour.`,
477 `If the grounding context doesn't cover the question, say so plainly rather than guessing.`,
478 ];
479 return header.join("\n");
480}
481
482// ---------------------------------------------------------------------------
483// Repo metadata helper — owner/name/defaultBranch for git lookups.
484// ---------------------------------------------------------------------------
485
486interface RepoMeta {
487 owner: string;
488 name: string;
489 defaultBranch: string;
490}
491
492async function loadRepoMeta(repoId: string): Promise<RepoMeta | null> {
493 if (!repoId) return null;
494 try {
495 const [row] = await db
496 .select({
497 name: repositories.name,
498 defaultBranch: repositories.defaultBranch,
499 owner: users.username,
500 })
501 .from(repositories)
502 .innerJoin(users, eq(repositories.ownerId, users.id))
503 .where(eq(repositories.id, repoId))
504 .limit(1);
505 if (!row) return null;
506 let defaultBranch = row.defaultBranch || "main";
507 // If the configured default branch isn't actually resolvable on
508 // disk, fall back to whatever HEAD points at.
509 try {
510 const real = await getDefaultBranch(row.owner, row.name);
511 if (real) defaultBranch = real;
512 } catch {
513 /* tolerate */
514 }
515 return { owner: row.owner, name: row.name, defaultBranch };
516 } catch {
517 return null;
518 }
519}
520
521// ---------------------------------------------------------------------------
522// Claude streaming — thin wrapper that yields text deltas.
523// ---------------------------------------------------------------------------
524
525async function* claudeStream(args: {
526 systemPrompt: string;
527 userMessage: string;
528}): AsyncGenerator<string, void, unknown> {
529 if (!isAiAvailable()) {
530 yield "AI is not configured on this Gluecron instance — set ANTHROPIC_API_KEY to enable rubber-duck chat.";
531 return;
532 }
533
534 const client = getAnthropic();
535 // Anthropic SDK exposes `.stream(...)` returning an event emitter
536 // with an async iterator over RawMessageStreamEvent chunks. We
537 // extract `content_block_delta` text deltas.
538 const stream = client.messages.stream({
539 model: MODEL_SONNET,
540 max_tokens: 2048,
541 system: args.systemPrompt,
542 messages: [{ role: "user", content: args.userMessage }],
543 });
544
545 // The SDK's stream object is itself async-iterable over events.
546 for await (const event of stream as AsyncIterable<unknown>) {
547 const delta = extractTextDelta(event);
548 if (delta) yield delta;
549 }
550}
551
552function extractTextDelta(event: unknown): string {
553 if (!event || typeof event !== "object") return "";
554 const e = event as Record<string, unknown>;
555 if (e.type !== "content_block_delta") return "";
556 const delta = e.delta as Record<string, unknown> | undefined;
557 if (!delta) return "";
558 if (delta.type === "text_delta" && typeof delta.text === "string") {
559 return delta.text;
560 }
561 return "";
562}
563
564// ---------------------------------------------------------------------------
565// Test-only exports.
566// ---------------------------------------------------------------------------
567
568export const __test = {
569 buildGroundingContext,
570 buildSystemPrompt,
571 buildTreeFallback,
572 extractTextDelta,
573 ASSISTANT_REPLY_CAP,
574 MAX_SNIPPET_CHARS,
575 FALLBACK_TREE_CAP,
576};
Modifiedsrc/routes/api-v2.ts+145−0View fileUnifiedSplit
616616 return c.json(payload);
617617});
618618
619// ─── Repo chat — streaming SSE ──────────────────────────────────────────────
620//
621// POST /api/v2/repos/:owner/:repo/chat/messages
622//
623// Body (application/json): { chat_id?: string, message: string }
624//
625// Behaviour:
626// - If no `chat_id`, creates a new chat row scoped to the caller.
627// - Streams assistant text deltas via SSE (`event: token`, data is the
628// raw delta). When the stream closes, a final `event: done` carries
629// `{ chat_id, message_id, citations, token_cost }`.
630// - On any internal failure we emit `event: error` and end the stream
631// cleanly so the client can render an inline message.
632//
633// Auth: requireApiAuth + requireScope("repo") so PATs need the right
634// scope, and session-cookie auth (web UI) works out of the box.
635
636apiv2.post(
637 "/repos/:owner/:repo/chat/messages",
638 requireApiAuth,
639 requireScope("repo"),
640 async (c) => {
641 const { owner, repo } = c.req.param();
642 const user = c.get("user")!;
643 const resolved = await resolveRepo(owner, repo);
644 if (!resolved) return c.json({ error: "Not found" }, 404);
645
646 // Private-repo: only the owner (or admin scope) may chat.
647 if ((resolved.repo as any).isPrivate && user.id !== resolved.owner.id) {
648 const scopes = (c.get("tokenScopes") as string[] | undefined) || [];
649 if (!scopes.includes("admin")) {
650 return c.json({ error: "Not found" }, 404);
651 }
652 }
653
654 let body: { chat_id?: string; message?: string };
655 try {
656 body = (await c.req.json()) as { chat_id?: string; message?: string };
657 } catch {
658 return c.json({ error: "Invalid JSON body" }, 400);
659 }
660
661 const userMessage = String(body?.message || "").trim();
662 if (!userMessage) {
663 return c.json({ error: "message is required" }, 400);
664 }
665
666 const {
667 appendUserMessage,
668 createChat,
669 getChatForUser,
670 streamAssistantReply,
671 } = await import("../lib/repo-chat");
672
673 let chatId = String(body?.chat_id || "").trim();
674 const repoId = (resolved.repo as any).id as string;
675
676 if (!chatId) {
677 const created = await createChat({
678 repositoryId: repoId,
679 ownerUserId: user.id,
680 title: userMessage.slice(0, 80),
681 });
682 if (!created) {
683 return c.json({ error: "Failed to create chat" }, 500);
684 }
685 chatId = created.id;
686 } else {
687 const existing = await getChatForUser(chatId, user.id);
688 if (!existing || existing.repositoryId !== repoId) {
689 return c.json({ error: "Not found" }, 404);
690 }
691 }
692
693 await appendUserMessage(chatId, userMessage);
694
695 const encoder = new TextEncoder();
696 const stream = new ReadableStream<Uint8Array>({
697 async start(controller) {
698 let closed = false;
699 const safeEnqueue = (chunk: string) => {
700 if (closed) return;
701 try {
702 controller.enqueue(encoder.encode(chunk));
703 } catch {
704 closed = true;
705 }
706 };
707 const sseEvent = (event: string, data: string) => {
708 let payload = `event: ${event}\n`;
709 for (const line of data.split("\n")) {
710 payload += `data: ${line}\n`;
711 }
712 payload += "\n";
713 safeEnqueue(payload);
714 };
715
716 // Initial comment flushes proxy headers.
717 safeEnqueue(": open\n\n");
718
719 try {
720 const stored = await streamAssistantReply({
721 chatId,
722 repoId,
723 userMessage,
724 onChunk: (chunk) => {
725 sseEvent("token", chunk);
726 },
727 });
728
729 sseEvent(
730 "done",
731 JSON.stringify({
732 chat_id: chatId,
733 message_id: stored?.id || null,
734 citations: stored?.citations ?? [],
735 token_cost: stored?.tokenCost ?? 0,
736 })
737 );
738 } catch (err) {
739 const msg = err instanceof Error ? err.message : "unknown error";
740 sseEvent("error", JSON.stringify({ error: msg }));
741 } finally {
742 closed = true;
743 try {
744 controller.close();
745 } catch {
746 /* already closed */
747 }
748 }
749 },
750 });
751
752 return new Response(stream, {
753 status: 200,
754 headers: {
755 "Content-Type": "text/event-stream; charset=utf-8",
756 "Cache-Control": "no-cache, no-transform",
757 Connection: "keep-alive",
758 "X-Accel-Buffering": "no",
759 },
760 });
761 }
762);
763
619764// ─── Issues ─────────────────────────────────────────────────────────────────
620765
621766apiv2.get("/repos/:owner/:repo/issues", async (c) => {
Addedsrc/routes/repo-chat.tsx+966−0View fileUnifiedSplit
1/**
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;
0967