Merge branch 'development'

This commit is contained in:
2026-01-31 21:32:01 +00:00
7 changed files with 261 additions and 689 deletions

View File

@@ -1,452 +0,0 @@
{
"organisation": {
"id": 5,
"name": "OB",
"description": "For personal use",
"slug": "ob",
"iconURL": "https://images.sprintpm.org/org-icons/cda1e49c-486a-4a40-ace6-d63eaba79cd9.png",
"statuses": {
"TO DO": "#fafafa",
"IN PROGRESS": "#f97316",
"REVIEW": "#8952bc",
"DONE": "#22c55e",
"REJECTED": "#ef4444",
"ARCHIVED": "#a1a1a1",
"MERGED": "#a1a1a1"
},
"issueTypes": {
"Task": {
"icon": "checkBox",
"color": "#e4bd47"
},
"Bug": {
"icon": "bug",
"color": "#ef4444"
},
"Ideation": {
"icon": "edit",
"color": "#c955c5"
},
"Quality of Life": {
"icon": "moon",
"color": "#656ad3"
},
"Performance": {
"icon": "loader",
"color": "#5ed36c"
}
},
"features": {
"userAvatars": true,
"issueTypes": true,
"issueStatus": true,
"issueDescriptions": true,
"issueTimeTracking": true,
"issueAssignees": true,
"issueAssigneesShownInTable": true,
"issueCreator": true,
"issueComments": true,
"sprints": true
},
"createdAt": "2026-01-29T19:24:07.015Z",
"updatedAt": "2026-01-29T19:24:07.015Z"
},
"members": [
{
"id": 9,
"organisationId": 5,
"userId": 9,
"role": "owner",
"createdAt": "2026-01-29T19:24:07.015Z"
}
],
"projects": [
{
"id": 9,
"key": "SPNT",
"name": "SPRINT",
"organisationId": 5,
"creatorId": 9
}
],
"sprints": [
{
"id": 17,
"projectId": 9,
"name": "Development 1",
"color": "#63d379",
"startDate": "2026-01-29T00:00:00.000Z",
"endDate": "2026-02-12T23:59:00.000Z",
"createdAt": "2026-01-29T20:43:29.814Z"
}
],
"issues": [
{
"id": 80,
"projectId": 9,
"number": 12,
"type": "Task",
"status": "IN PROGRESS",
"title": "Assignee notes",
"description": "Users should be able to add assignee notes to represent an assignee's role in an issue.",
"creatorId": 9,
"sprintId": 17
},
{
"id": 74,
"projectId": 9,
"number": 6,
"type": "Ideation",
"status": "TO DO",
"title": "Subscriptions should be organisation based",
"description": "They shouldn't be user based. A subscription should be for an organisation. Any user should be able to have unlimited organisations, but they are all limited, and charged for separately.",
"creatorId": 9,
"sprintId": 17
},
{
"id": 73,
"projectId": 9,
"number": 5,
"type": "Ideation",
"status": "TO DO",
"title": "Rethink \"seats\" payment idea",
"description": "Maybe user pays for different tiers? \nSpaces:\n- Free tier: 3\n- Small Team: 10\n- Moderate Team: 50\n- Big Team: Contact us for a price",
"creatorId": 9,
"sprintId": 17
},
{
"id": 70,
"projectId": 9,
"number": 2,
"type": "Quality of Life",
"status": "TO DO",
"title": "Streamline issue/create route",
"description": "Currently each field is parsed individually (adding a new field requires much more work than it should)",
"creatorId": 9,
"sprintId": 17
},
{
"id": 71,
"projectId": 9,
"number": 3,
"type": "Task",
"status": "TO DO",
"title": "Automatically create org + project for new users",
"description": "\"ob's organisation\" and \"ob's project\" should be created upon account creation.",
"creatorId": 9,
"sprintId": 17
},
{
"id": 86,
"projectId": 9,
"number": 18,
"type": "Task",
"status": "TO DO",
"title": "Share filters button",
"description": "Copies a url with organisation, project and all issue filters for reuse or use with team members.",
"creatorId": 9,
"sprintId": null
},
{
"id": 79,
"projectId": 9,
"number": 11,
"type": "Bug",
"status": "DONE",
"title": "Filters should persist across refresh",
"description": "Use localStorage for this - even switching to the timeline tab and back resets filters, very annoying.",
"creatorId": 9,
"sprintId": null
},
{
"id": 76,
"projectId": 9,
"number": 8,
"type": "Task",
"status": "TO DO",
"title": "Issue attachments",
"description": "Users should be able to attach files and images to descriptions and comments. (restrict to images at first - resize with sharp)",
"creatorId": 9,
"sprintId": 17
},
{
"id": 75,
"projectId": 9,
"number": 7,
"type": "Task",
"status": "DONE",
"title": "Export organisation contents as JSON",
"description": "Organisation owner/admins should be able to export all of the data in an organisation as a JSON file. This can then be used to \"Create [an org] from JSON\"",
"creatorId": 9,
"sprintId": 17
},
{
"id": 78,
"projectId": 9,
"number": 10,
"type": "Task",
"status": "TO DO",
"title": "Standalone time tracking",
"description": "User should be able to create time tracking entries that are not related to an issue. For example: ideation, sprint planning, meetings.",
"creatorId": 9,
"sprintId": 17
},
{
"id": 72,
"projectId": 9,
"number": 4,
"type": "Bug",
"status": "MERGED",
"title": "Org avatar scaling weirdly",
"description": "In organisations.tsx info tab, the org avatar is shrunk horizontally to fit the text. height is correct though.\n\nThis only occurs when there is no organisation icon.",
"creatorId": 9,
"sprintId": 17
},
{
"id": 69,
"projectId": 9,
"number": 1,
"type": "Bug",
"status": "MERGED",
"title": "Unable to create an issue with type",
"description": "It chooses the first type available instead of the one chosen by the user",
"creatorId": 9,
"sprintId": 17
},
{
"id": 81,
"projectId": 9,
"number": 13,
"type": "Task",
"status": "TO DO",
"title": "User defined colour scheme",
"description": "On the backburner for now",
"creatorId": 9,
"sprintId": null
},
{
"id": 83,
"projectId": 9,
"number": 15,
"type": "Task",
"status": "TO DO",
"title": "Post-sub email",
"description": "Thank you for subscribing",
"creatorId": 9,
"sprintId": null
},
{
"id": 84,
"projectId": 9,
"number": 16,
"type": "Task",
"status": "TO DO",
"title": "Trial end warning email",
"description": "Your trial is coming to an end. Manage your subscription to renew",
"creatorId": 9,
"sprintId": null
},
{
"id": 82,
"projectId": 9,
"number": 14,
"type": "Task",
"status": "TO DO",
"title": "Welcome email",
"description": "Welcome to sprint, this is what you can do: ...",
"creatorId": 9,
"sprintId": null
},
{
"id": 85,
"projectId": 9,
"number": 17,
"type": "Task",
"status": "TO DO",
"title": "Trial system",
"description": "14 day trial of Team plan, any elevated features must be locked away again after trial ends.\n\nIf the organisation has more members than free plan allows, the members who were added after will be barred from accessing the organisation. Additional projects, issues and sprints will be locked away.",
"creatorId": 9,
"sprintId": null
},
{
"id": 77,
"projectId": 9,
"number": 9,
"type": "Task",
"status": "DONE",
"title": "Add \"assign to me by default\"",
"description": "",
"creatorId": 9,
"sprintId": 17
}
],
"issueAssignees": [
{
"id": 73,
"issueId": 69,
"userId": 9,
"assignedAt": "2026-01-29T20:33:42.827Z"
},
{
"id": 74,
"issueId": 70,
"userId": 9,
"assignedAt": "2026-01-29T20:44:51.258Z"
},
{
"id": 75,
"issueId": 71,
"userId": 9,
"assignedAt": "2026-01-29T20:46:34.959Z"
},
{
"id": 76,
"issueId": 72,
"userId": 9,
"assignedAt": "2026-01-29T20:49:12.879Z"
},
{
"id": 77,
"issueId": 73,
"userId": 9,
"assignedAt": "2026-01-29T21:21:14.628Z"
},
{
"id": 78,
"issueId": 77,
"userId": 9,
"assignedAt": "2026-01-29T21:39:58.232Z"
},
{
"id": 79,
"issueId": 75,
"userId": 9,
"assignedAt": "2026-01-29T23:09:09.537Z"
},
{
"id": 80,
"issueId": 79,
"userId": 9,
"assignedAt": "2026-01-29T23:09:12.591Z"
},
{
"id": 81,
"issueId": 80,
"userId": 9,
"assignedAt": "2026-01-29T23:09:15.541Z"
},
{
"id": 82,
"issueId": 86,
"userId": 9,
"assignedAt": "2026-01-29T23:19:57.419Z"
}
],
"issueComments": [
{
"id": 70,
"issueId": 69,
"userId": 9,
"body": "The backend wasn't reading the type field. We need to streamline that route.",
"createdAt": "2026-01-29T20:44:16.540Z",
"updatedAt": "2026-01-29T20:44:16.540Z"
},
{
"id": 71,
"issueId": 69,
"userId": 9,
"body": "Created https://sprintpm.org/issues?o=org&p=spnt&i=2&modal=true for the previously mentioned issue",
"createdAt": "2026-01-29T20:45:23.285Z",
"updatedAt": "2026-01-29T20:45:23.285Z"
},
{
"id": 72,
"issueId": 69,
"userId": 9,
"body": "SPNT-002",
"createdAt": "2026-01-29T20:45:38.161Z",
"updatedAt": "2026-01-29T20:45:38.161Z"
},
{
"id": 73,
"issueId": 80,
"userId": 9,
"body": "ugh",
"createdAt": "2026-01-30T00:34:45.745Z",
"updatedAt": "2026-01-30T00:34:45.745Z"
}
],
"timedSessions": [
{
"id": 1,
"userId": 9,
"issueId": 69,
"timestamps": [
"2026-01-29T20:37:38.485Z",
"2026-01-29T20:42:58.198Z"
],
"endedAt": "2026-01-29T20:42:59.566Z",
"createdAt": "2026-01-29T20:37:38.486Z"
},
{
"id": 2,
"userId": 9,
"issueId": 72,
"timestamps": [
"2026-01-29T20:49:45.879Z",
"2026-01-29T21:16:34.178Z"
],
"endedAt": "2026-01-29T21:16:34.178Z",
"createdAt": "2026-01-29T20:49:45.879Z"
},
{
"id": 3,
"userId": 9,
"issueId": 77,
"timestamps": [
"2026-01-29T21:40:01.096Z",
"2026-01-29T23:09:19.693Z"
],
"endedAt": "2026-01-29T23:09:19.693Z",
"createdAt": "2026-01-29T21:40:01.096Z"
},
{
"id": 6,
"userId": 9,
"issueId": 80,
"timestamps": [
"2026-01-29T23:09:16.096Z",
"2026-01-30T00:34:30.575Z"
],
"endedAt": "2026-01-30T00:34:30.575Z",
"createdAt": "2026-01-29T23:09:16.096Z"
},
{
"id": 5,
"userId": 9,
"issueId": 79,
"timestamps": [
"2026-01-29T23:09:13.291Z",
"2026-01-30T00:34:30.923Z"
],
"endedAt": "2026-01-30T00:34:30.923Z",
"createdAt": "2026-01-29T23:09:13.291Z"
},
{
"id": 4,
"userId": 9,
"issueId": 75,
"timestamps": [
"2026-01-29T23:09:10.375Z",
"2026-01-30T00:34:31.391Z"
],
"endedAt": "2026-01-30T00:34:31.391Z",
"createdAt": "2026-01-29T23:09:10.376Z"
}
],
"_metadata": {
"exportedAt": "2026-01-30T00:42:40.425Z",
"exportedBy": 9,
"version": "1.0"
}
}

View File

@@ -34,70 +34,71 @@ export const buildContext = async (orgId: number, projectId: number, user: UserR
<current_context>
<user id="${user.id}" name="${user.name}" username="${user.username}" />
<organisation name="${organisation.name}" slug="${organisation.slug}">
<current_organisation name="${organisation.name}" slug="${organisation.slug}">
<statuses>
${Object.entries(organisation.statuses)
.map(([name, color]) => ` <status name="${name}" color="${color}" />`)
.join("\n")}
</statuses>
</organisation>
</current_organisation>
<projects>
<organisation_projects id="${organisation.id}_projects" name="Projects for ${organisation.name}" count="${projects.length}">
${projects.map((p) => ` <project key="${p.Project.key}" name="${p.Project.name}" />`).join("\n")}
</projects>
</organisation_projects>
<sprints>
${sprints.map((s) => ` <sprint id="${s.id}" name="${s.name}" start="${s.startDate.toUTCString()?.split("T")[0]}" end="${s.endDate?.toUTCString().split("T")[0]}" />`).join("\n")}
</sprints>
<current_project key="${project.key}" name="${project.name}">
<sprints>
${sprints.map((s) => ` <sprint id="${s.id}" name="${s.name}" start="${s.startDate.toUTCString()?.split("T")[0]}" end="${s.endDate?.toUTCString().split("T")[0]}" />`).join("\n")}
</sprints>
<all_issues count="${issues.length}">
${issues.map((i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}" type="${i.Issue.type}" status="${i.Issue.status}" title="${i.Issue.title.replace(/"/g, "&quot;")}" sprint="${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "Unassigned"}" />`).join("\n")}
</all_issues>
<all_issues count="${issues.length}">
${issues.map((i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}" type="${i.Issue.type}" status="${i.Issue.status}" title="${i.Issue.title.replace(/"/g, "&quot;")}" sprint="${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "Unassigned"}" />`).join("\n")}
</all_issues>
<my_issues count="${assignedIssues.length}">
${assignedIssues.map((i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}" type="${i.Issue.type}" status="${i.Issue.status}" title="${i.Issue.title.replace(/"/g, "&quot;")}" sprint="${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "Unassigned"}" />`).join("\n")}
</my_issues>
<my_issues count="${assignedIssues.length}">
${assignedIssues.map((i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}" type="${i.Issue.type}" status="${i.Issue.status}" title="${i.Issue.title.replace(/"/g, "&quot;")}" sprint="${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "Unassigned"}" />`).join("\n")}
</my_issues>
<issues_by_status>
<status name="TO DO" count="${byStatus("TO DO").length}">
${byStatus("TO DO")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
<status name="IN PROGRESS" count="${byStatus("IN PROGRESS").length}">
${byStatus("IN PROGRESS")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
<status name="DONE" count="${byStatus("DONE").length}">
${byStatus("DONE")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
</issues_by_status>
<issues_by_status>
<status name="TO DO" count="${byStatus("TO DO").length}">
${byStatus("TO DO")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
<status name="IN PROGRESS" count="${byStatus("IN PROGRESS").length}">
${byStatus("IN PROGRESS")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
<status name="DONE" count="${byStatus("DONE").length}">
${byStatus("DONE")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
</issues_by_status>
<issue_details>
${assignedIssues
.map(
(i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}">
<title>${i.Issue.title.replace(/"/g, "&quot;")}</title>
<description>${(i.Issue.description || "").replace(/"/g, "&quot;")}</description>
<status>${i.Issue.status}</status>
<type>${i.Issue.type}</type>
<sprint>${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "None"}</sprint>
</issue>`,
)
.join("\n")}
</issue_details>
<issue_details>
${assignedIssues
.map(
(i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}">
<title>${i.Issue.title.replace(/"/g, "&quot;")}</title>
<description>${(i.Issue.description || "").replace(/"/g, "&quot;")}</description>
<status>${i.Issue.status}</status>
<type>${i.Issue.type}</type>
<sprint>${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "None"}</sprint>
</issue>`,
)
.join("\n")}
</issue_details>
</current_project>
</current_context>`;
};

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<system_prompt>
<identity>
<name>Sprinter</name>
<name>Sprint</name>
<role>AI assistant in a project management tool</role>
</identity>
@@ -49,6 +49,15 @@
<rule>NEVER output internal IDs (id, userId, creatorId, organisationId, projectId, sprintId) in the text field</rule>
<rule>ALWAYS use #&lt;number&gt; format when referring to issues in the text field</rule>
<rule>Every issue mentioned in text MUST have its id in the highlighted_issues array</rule>
<rule>Unless the user explicitly refers to themselves with first-person language ("my", "for me", "assigned to me", etc.), interpret their query as being about the project in general, not specific to them</rule>
<rule>If the user asks about "this project", they are referring to their own project (the one they are working on and tracking issues for), NOT the Sprint application itself</rule>
<rule>NEVER introduce yourself, explain what Sprint is, or describe the tool. Users already know they're using a project management tool</rule>
<rule>NEVER start responses with "This is a project management tool called..." or similar self-descriptive phrases</rule>
<rule>the ai must not read/write files</rule>
<rule>the ai must not use any shell commands</rule>
<rule>the ai must only use the context provided to it by the system prompt and the user's prompt</rule>
<rule>prioritise speed over thoroughness</rule>
<rule>be concise and clear</rule>
</critical_rules>
<output_format>
@@ -110,6 +119,15 @@
"text": "3 IN PROGRESS issues:\n\n#12 \"Assignee notes\" - IN PROGRESS\nAdd functionality for assignees to write notes on issues.\n\n#8 \"Dark mode toggle\" - IN PROGRESS\nImplement system-wide dark mode.\n\n#15 \"API rate limiting\" - IN PROGRESS\nAdd rate limiting to public endpoints.",
"highlighted_issues": [76, 55, 89],
"suggested_actions": []
}</output>
</example>
<example>
<user_query>what is the name of this project</user_query>
<output>{
"text": "API Platform",
"highlighted_issues": [],
"suggested_actions": []
}</output>
</example>
</examples>

View File

@@ -2,6 +2,8 @@ import { Fragment, type SubmitEvent, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { BREATHING_ROOM } from "@/lib/layout";
import { useChat, useModels, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import Avatar from "./avatar";
@@ -14,7 +16,6 @@ export function Chat({ setHighlighted }: { setHighlighted: (ids: number[]) => vo
const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject();
const chat = useChat();
const models = useModels();
const [isOpen, setIsOpen] = useState(false);
const [message, setMessage] = useState("");
@@ -23,11 +24,7 @@ export function Chat({ setHighlighted }: { setHighlighted: (ids: number[]) => vo
const [error, setError] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string>("");
useEffect(() => {
if (isOpen && !models.data) {
models.mutate();
}
}, [isOpen, models]);
const models = useModels(isOpen);
useEffect(() => {
if (models.data && models.data.length > 0 && !selectedModel) {
@@ -78,10 +75,10 @@ export function Chat({ setHighlighted }: { setHighlighted: (ids: number[]) => vo
{isOpen && (
<div className="fixed bottom-18 left-1/2 -translate-x-1/2 z-40 w-full max-w-2xl mx-4 bg-background border shadow-xl">
<div className="flex flex-col p-2 gap-2">
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<div className={`flex flex-col p-${BREATHING_ROOM} gap-${BREATHING_ROOM}`}>
<form onSubmit={handleSubmit} className={`flex flex-col gap-${BREATHING_ROOM}`}>
{lastUserMessage && (
<div className="p-2 border flex items-center gap-2 text-sm">
<div className={`p-2 border flex items-center gap-2 text-sm`}>
<Avatar
name={user.name}
username={user.username}
@@ -93,32 +90,35 @@ export function Chat({ setHighlighted }: { setHighlighted: (ids: number[]) => vo
<p className="whitespace-pre-wrap">{lastUserMessage}</p>
</div>
)}
{response && (
<div className="p-2 border flex items-center gap-2 text-sm">
<p className="whitespace-pre-wrap">
{response.split("\n").map((line, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <>
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</p>
{(chat.isPending || response) && (
<div className={`p-2 border flex items-center gap-2 text-sm`}>
<img src={"/favicon.svg"} className="w-9" alt={"sprint icon"} />
{!response && (
<div className="flex justify-center">
<Icon
icon="loader"
size={24}
className="animate-[spin_3s_linear_infinite]"
color={"var(--personality"}
/>
</div>
)}
{response && (
<p className="whitespace-pre-wrap flex-1">
{response.split("\n").map((line, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <>
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</p>
)}
</div>
)}
{chat.isPending && (
<div className="flex justify-center py-4">
<Icon
icon="loader"
size={24}
className="animate-[spin_3s_linear_infinite]"
color={"var(--personality"}
/>
</div>
)}
<div className="flex items-center gap-2">
<div className={`flex items-center gap-${BREATHING_ROOM}`}>
{models.data && models.data.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-fit text-[12px]" chevronClassName="hidden">

View File

@@ -3,7 +3,6 @@ import Avatar from "@/components/avatar";
import { useSelection } from "@/components/selection-provider";
import StatusTag from "@/components/status-tag";
import Icon, { type IconName } from "@/components/ui/icon";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks";
import { cn } from "@/lib/utils";
@@ -155,144 +154,149 @@ export function IssuesTable({
const showAssignee = columns.assignee == null || columns.assignee === true;
return (
<Table className={cn("table-fixed", className)}>
<TableHeader>
<TableRow hoverEffect={false} className="bg-secondary">
{showId && (
<TableHead className="text-right w-10 border-r text-xs font-medium text-muted-foreground">
ID
</TableHead>
)}
{showTitle && <TableHead className="text-xs font-medium text-muted-foreground">Title</TableHead>}
{showDescription && (
<TableHead className="text-xs font-medium text-muted-foreground">Description</TableHead>
)}
{/* below is kept blank to fill the space, used as the "Assignee" column */}
{showAssignee && <TableHead className="w-[1%]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{issues.map((issueData) => {
const isSelected = issueData.Issue.id === selectedIssueId;
return (
<TableRow
key={issueData.Issue.id}
className={cn("cursor-pointer max-w-full")}
onClick={() => {
if (isSelected) {
selectIssue(null);
return;
}
selectIssue(issueData);
}}
>
{showId && (
<TableCell
className={cn(
"font-medium border-r text-right p-0",
(isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_1px_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)}
>
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
<div className={cn("h-full overflow-auto overflow-x-hidden border-t", className)}>
<table className="w-full table-fixed border-collapse text-sm border-l border-r ">
<thead className="sticky top-0 z-10 bg-secondary">
<tr className="border-b h-[25px]">
{showId && (
<th className="text-right w-10 border-r text-xs font-medium text-muted-foreground px-2 align-middle">
ID
</th>
)}
{showTitle && (
<th className="text-xs font-medium text-muted-foreground px-2 align-middle text-left">Title</th>
)}
{showDescription && (
<th className="text-xs font-medium text-muted-foreground px-2 align-middle text-left">
Description
</th>
)}
{showAssignee && <th className="w-[1%] px-2"></th>}
</tr>
</thead>
<tbody>
{issues.map((issueData) => {
const isSelected = issueData.Issue.id === selectedIssueId;
return (
<tr
key={issueData.Issue.id}
className={cn("cursor-pointer h-[25px] border-b hover:bg-muted/40")}
onClick={() => {
if (isSelected) {
selectIssue(null);
return;
}
selectIssue(issueData);
}}
>
{showId && (
<td
className={cn(
"font-medium border-r text-right p-0 align-middle",
(isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_1px_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)}
>
{issueData.Issue.number.toString().padStart(3, "0")}
</a>
</TableCell>
)}
{showTitle && (
<TableCell
className={cn(
"min-w-0 p-0",
(isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_0_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)}
>
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
>
{issueData.Issue.number.toString().padStart(3, "0")}
</a>
</td>
)}
{showTitle && (
<td
className={cn(
"min-w-0 p-0 align-middle",
(isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_0_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)}
>
{selectedOrganisation?.Organisation.features.issueTypes &&
issueTypes[issueData.Issue.type] && (
<Icon
icon={issueTypes[issueData.Issue.type].icon as IconName}
size={16}
color={issueTypes[issueData.Issue.type].color}
/>
)}
{selectedOrganisation?.Organisation.features.issueStatus &&
(columns.status == null || columns.status === true) && (
<StatusTag
status={issueData.Issue.status}
colour={statuses[issueData.Issue.status]}
/>
)}
<span className="truncate">{issueData.Issue.title}</span>
</a>
</TableCell>
)}
{showDescription && (
<TableCell
className={cn(
"overflow-hidden p-0",
(isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_0_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)}
>
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
>
{selectedOrganisation?.Organisation.features.issueTypes &&
issueTypes[issueData.Issue.type] && (
<Icon
icon={issueTypes[issueData.Issue.type].icon as IconName}
size={16}
color={issueTypes[issueData.Issue.type].color}
/>
)}
{selectedOrganisation?.Organisation.features.issueStatus &&
(columns.status == null || columns.status === true) && (
<StatusTag
status={issueData.Issue.status}
colour={statuses[issueData.Issue.status]}
/>
)}
<span className="truncate">{issueData.Issue.title}</span>
</a>
</td>
)}
{showDescription && (
<td
className={cn(
"overflow-hidden p-0 align-middle",
(isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_0_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)}
>
{issueData.Issue.description}
</a>
</TableCell>
)}
{showAssignee && (
<TableCell
className={cn(
"h-[32px] p-0",
(isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_0_1px_0_0_var(--personality),inset_-1px_0_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)}
>
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
className="flex items-center justify-end w-full h-full px-2"
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
>
{issueData.Issue.description}
</a>
</td>
)}
{showAssignee && (
<td
className={cn(
"h-[32px] p-0 align-middle",
(isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_0_1px_0_0_var(--personality),inset_-1px_0_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)}
>
{selectedOrganisation?.Organisation.features.issueAssigneesShownInTable &&
issueData.Assignees &&
issueData.Assignees.length > 0 && (
<div className="flex items-center -space-x-2 pr-1.5">
{issueData.Assignees.slice(0, 3).map((assignee) => (
<Avatar
key={assignee.id}
name={assignee.name}
username={assignee.username}
avatarURL={assignee.avatarURL}
textClass="text-xs"
className="ring-1 ring-background"
/>
))}
{issueData.Assignees.length > 3 && (
<span className="flex items-center justify-center w-6 h-6 text-[10px] font-medium bg-muted text-muted-foreground rounded-full ring-1 ring-background">
+{issueData.Assignees.length - 3}
</span>
)}
</div>
)}
</a>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
className="flex items-center justify-end w-full h-full px-2"
>
{selectedOrganisation?.Organisation.features.issueAssigneesShownInTable &&
issueData.Assignees &&
issueData.Assignees.length > 0 && (
<div className="flex items-center -space-x-2 pr-1.5">
{issueData.Assignees.slice(0, 3).map((assignee) => (
<Avatar
key={assignee.id}
name={assignee.name}
username={assignee.username}
avatarURL={assignee.avatarURL}
textClass="text-xs"
className="ring-1 ring-background"
/>
))}
{issueData.Assignees.length > 3 && (
<span className="flex items-center justify-center w-6 h-6 text-[10px] font-medium bg-muted text-muted-foreground rounded-full ring-1 ring-background">
+{issueData.Assignees.length - 3}
</span>
)}
</div>
)}
</a>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import type { ChatRequest, ChatResponse, ModelsResponse } from "@sprint/shared";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/server";
export function useChat() {
@@ -14,14 +14,17 @@ export function useChat() {
});
}
export function useModels() {
return useMutation<ModelsResponse, Error>({
mutationKey: ["ai", "models"],
mutationFn: async () => {
export function useModels(enabled: boolean) {
return useQuery<ModelsResponse, Error>({
queryKey: ["ai", "models"],
queryFn: async () => {
const { data, error } = await apiClient.aiModels();
if (error) throw new Error(error);
if (!data) throw new Error("failed to get models");
return data as ModelsResponse;
},
enabled,
retry: false,
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -667,15 +667,13 @@ export default function Issues() {
{selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
<ResizablePanelGroup className={`flex-1`}>
<ResizablePanel id={"left"} minSize={400}>
<div className="border w-full flex-shrink">
<IssuesTable
columns={{ description: false }}
className="w-full"
filters={issueFilters}
highlighted={highlighted}
/>
</div>
<ResizablePanel id={"left"} minSize={400} className="h-full overflow-hidden">
<IssuesTable
columns={{ description: false }}
className="w-full"
filters={issueFilters}
highlighted={highlighted}
/>
</ResizablePanel>
{selectedIssue && !showIssueModal && (