From 76e71d1f8a8dd1b94c738d0ac0d5c4e42929f8d1 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sat, 31 Jan 2026 11:01:30 +0000 Subject: [PATCH] opencode chat frontend implementation --- .../backend/src/routes/ai/context-builders.ts | 5 + packages/backend/src/routes/ai/opencode.ts | 10 +- packages/frontend/src/components/chat.tsx | 102 ++++++++++++++++++ packages/frontend/src/components/ui/field.tsx | 3 + .../src/components/ui/icon-button.tsx | 5 + packages/frontend/src/components/ui/icon.tsx | 4 + packages/frontend/src/lib/query/hooks/chat.ts | 15 +++ .../frontend/src/lib/query/hooks/index.ts | 1 + packages/frontend/src/pages/Issues.tsx | 3 + packages/shared/src/api-schemas.ts | 11 ++ packages/shared/src/contract.ts | 13 +++ packages/shared/src/index.ts | 5 +- 12 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/src/components/chat.tsx create mode 100644 packages/frontend/src/lib/query/hooks/chat.ts diff --git a/packages/backend/src/routes/ai/context-builders.ts b/packages/backend/src/routes/ai/context-builders.ts index 9001b1d..0e9e7f9 100644 --- a/packages/backend/src/routes/ai/context-builders.ts +++ b/packages/backend/src/routes/ai/context-builders.ts @@ -50,6 +50,11 @@ ${projects.map((p) => ` `).join("\n")} + +${issues.map((i) => ` `).join("\n")} + + + ${assignedIssues.map((i) => ` `).join("\n")} diff --git a/packages/backend/src/routes/ai/opencode.ts b/packages/backend/src/routes/ai/opencode.ts index cb4b8fe..79eac82 100644 --- a/packages/backend/src/routes/ai/opencode.ts +++ b/packages/backend/src/routes/ai/opencode.ts @@ -6,7 +6,15 @@ export type AIResponse = { }; export const callAI = async (prompt: string): Promise => { - const result = Bun.spawn(["opencode", "run", prompt, "--model", "opencode/kimi-k2.5-free"], { + const models = [ + "opencode/glm-4.7-free", + "opencode/kimi-k2.5-free", + "opencode/minimax-m2.1-free", + "opencode/trinity-large-preview-free", + ]; + const model = models[3]!; + + const result = Bun.spawn(["opencode", "run", prompt, "--model", model], { stdout: "pipe", stderr: "pipe", }); diff --git a/packages/frontend/src/components/chat.tsx b/packages/frontend/src/components/chat.tsx new file mode 100644 index 0000000..0d2d2c3 --- /dev/null +++ b/packages/frontend/src/components/chat.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import Icon from "@/components/ui/icon"; +import { useChatMutation, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks"; +import { parseError } from "@/lib/server"; +import { IconButton } from "./ui/icon-button"; +import { Input } from "./ui/input"; + +export function Chat() { + const selectedOrganisation = useSelectedOrganisation(); + const selectedProject = useSelectedProject(); + const chatMutation = useChatMutation(); + + const [isOpen, setIsOpen] = useState(false); + const [message, setMessage] = useState(""); + const [response, setResponse] = useState(""); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!message.trim()) return; + if (!selectedOrganisation || !selectedProject) { + setError("Please select an organisation and project first"); + return; + } + + setError(null); + setResponse(""); + + try { + const data = await chatMutation.mutateAsync({ + orgId: selectedOrganisation.Organisation.id, + projectId: selectedProject.Project.id, + message: message.trim(), + }); + setResponse(data.text); + setMessage(""); + } catch (err) { + const errorMessage = parseError(err as Error); + setError(errorMessage); + } + }; + + return ( + <> + setIsOpen(!isOpen)} + className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 rounded-full" + size="lg" + variant="outline" + > + + + + {isOpen && ( +
+
+
+ {response && ( +
+

{response}

+
+ )} + + {chatMutation.isPending && ( +
+ +
+ )} + +
+ ) => setMessage(e.target.value)} + placeholder={`Ask me anything about the ${selectedProject?.Project.name || "..."} project...`} + disabled={chatMutation.isPending} + showCounter={false} + /> + + +
+
+ + {error && ( +
+

{error}

+
+ )} +
+
+ )} + + ); +} diff --git a/packages/frontend/src/components/ui/field.tsx b/packages/frontend/src/components/ui/field.tsx index d30b1ca..4deb8b0 100644 --- a/packages/frontend/src/components/ui/field.tsx +++ b/packages/frontend/src/components/ui/field.tsx @@ -15,6 +15,7 @@ export function Field({ spellcheck, maxLength, showCounter = true, + disabled = false, }: { label: string; value?: string; @@ -28,6 +29,7 @@ export function Field({ spellcheck?: boolean; maxLength?: number; showCounter?: boolean; + disabled?: boolean; }) { const [internalTouched, setInternalTouched] = useState(false); const isTouched = submitAttempted || internalTouched; @@ -62,6 +64,7 @@ export function Field({ spellCheck={spellcheck} maxLength={maxLength} showCounter={showCounter} + disabled={disabled} />
{error || invalidMessage !== "" ? ( diff --git a/packages/frontend/src/components/ui/icon-button.tsx b/packages/frontend/src/components/ui/icon-button.tsx index cd11f81..a116f9a 100644 --- a/packages/frontend/src/components/ui/icon-button.tsx +++ b/packages/frontend/src/components/ui/icon-button.tsx @@ -22,6 +22,11 @@ const iconButtonVariants = cva( sm: "w-5 h-5", md: "w-9 h-9", lg: "w-10 h-10", + xl: "w-12 h-12", + "2xl": "w-14 h-14", + "3xl": "w-16 h-16", + "4xl": "w-18 h-18", + "5xl": "w-20 h-20", }, }, defaultVariants: { diff --git a/packages/frontend/src/components/ui/icon.tsx b/packages/frontend/src/components/ui/icon.tsx index f659017..7040e66 100644 --- a/packages/frontend/src/components/ui/icon.tsx +++ b/packages/frontend/src/components/ui/icon.tsx @@ -20,6 +20,7 @@ import { Edit as PixelEdit, EyeClosed as PixelEyeClosed, AddGrid as PixelGridAdd, + HumanHandsup as PixelHandsUp, Home as PixelHome, InfoBox as PixelInfo, Link as PixelLink, @@ -61,6 +62,7 @@ import { DotsThreeVerticalIcon as PhosphorDotsThreeVertical, PencilSimpleIcon as PhosphorEdit, EyeClosedIcon as PhosphorEyeClosed, + PersonArmsSpreadIcon as PhosphorHandsUp, HashIcon as PhosphorHash, HashStraightIcon as PhosphorHashStraight, HouseIcon as PhosphorHome, @@ -123,6 +125,7 @@ import { Moon, OctagonXIcon, Pause, + PersonStanding, Play, Plus, Rocket, @@ -181,6 +184,7 @@ const icons = { phosphor: PhosphorDotsSixVertical, }, hash: { lucide: Hash, pixel: PhosphorHashStraight, phosphor: PhosphorHash }, + handsUp: { lucide: PersonStanding, pixel: PixelHandsUp, phosphor: PhosphorHandsUp }, home: { lucide: LucideHome, pixel: PixelHome, phosphor: PhosphorHome }, info: { lucide: InfoIcon, pixel: PixelInfo, phosphor: PhosphorInfo }, layoutDashboard: { lucide: LayoutDashboard, pixel: PixelDashboard, phosphor: PhosphorLayout }, diff --git a/packages/frontend/src/lib/query/hooks/chat.ts b/packages/frontend/src/lib/query/hooks/chat.ts new file mode 100644 index 0000000..78f19bc --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/chat.ts @@ -0,0 +1,15 @@ +import type { ChatRequest, ChatResponse } from "@sprint/shared"; +import { useMutation } from "@tanstack/react-query"; +import { apiClient } from "@/lib/server"; + +export function useChatMutation() { + return useMutation({ + mutationKey: ["ai", "chat"], + mutationFn: async (input) => { + const { data, error } = await apiClient.aiChat({ query: input }); + if (error) throw new Error(error); + if (!data) throw new Error("failed to get chat response"); + return data as ChatResponse; + }, + }); +} diff --git a/packages/frontend/src/lib/query/hooks/index.ts b/packages/frontend/src/lib/query/hooks/index.ts index 7c0564f..942d4d0 100644 --- a/packages/frontend/src/lib/query/hooks/index.ts +++ b/packages/frontend/src/lib/query/hooks/index.ts @@ -1,3 +1,4 @@ +export * from "@/lib/query/hooks/chat"; export * from "@/lib/query/hooks/derived"; export * from "@/lib/query/hooks/issue-comments"; export * from "@/lib/query/hooks/issues"; diff --git a/packages/frontend/src/pages/Issues.tsx b/packages/frontend/src/pages/Issues.tsx index e9834a0..47cdfe0 100644 --- a/packages/frontend/src/pages/Issues.tsx +++ b/packages/frontend/src/pages/Issues.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import Avatar from "@/components/avatar"; +import { Chat } from "@/components/chat"; import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssueModal } from "@/components/issue-modal"; import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table"; @@ -694,6 +695,8 @@ export default function Issues() { }} /> )} + + ); } diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index ca05624..18b4042 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -672,3 +672,14 @@ export const ChatRequestSchema = z.object({ projectId: z.coerce.number().int().positive("projectId must be a positive integer"), message: z.string().min(1, "Message is required"), }); + +export type ChatRequest = z.infer; + +export const ChatResponseSchema = z.object({ + text: z.string(), + highlighted_issues: z.array(z.number()), + suggested_actions: z.array(z.string()).nullable(), + raw: z.string(), +}); + +export type ChatResponse = z.infer; diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 460fb70..05f3efa 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -4,6 +4,8 @@ import { ApiErrorSchema, AuthResponseSchema, CancelSubscriptionResponseSchema, + ChatRequestSchema, + ChatResponseSchema, CreateCheckoutSessionRequestSchema, CreateCheckoutSessionResponseSchema, CreatePortalSessionResponseSchema, @@ -683,6 +685,17 @@ export const apiContract = c.router({ }, headers: csrfHeaderSchema, }, + + aiChat: { + method: "GET", + path: "/ai/chat", + query: ChatRequestSchema, + responses: { + 200: ChatResponseSchema, + 400: ApiErrorSchema, + 404: ApiErrorSchema, + }, + }, }); export type ApiContract = typeof apiContract; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 428bdd2..78d0824 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,8 @@ export type { ApiError, AuthResponse, CancelSubscriptionResponse, + ChatRequest, + ChatResponse, CreateCheckoutSessionRequest, CreateCheckoutSessionResponse, CreatePortalSessionResponse, @@ -70,6 +72,8 @@ export { ApiErrorSchema, AuthResponseSchema, CancelSubscriptionResponseSchema, + ChatRequestSchema, + ChatResponseSchema, CreateCheckoutSessionRequestSchema, CreateCheckoutSessionResponseSchema, CreatePortalSessionResponseSchema, @@ -135,7 +139,6 @@ export { UserResponseSchema, UserUpdateRequestSchema, VerifyEmailRequestSchema, - ChatRequestSchema, } from "./api-schemas"; export { ISSUE_COMMENT_MAX_LENGTH,