From b53e0a1a513178ec7ad9cffa84799cd54405e535 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:21:29 -0800 Subject: [PATCH] feat(web): add the Issues page and issue detail panel The Issues page lists issues from every project's tracker, grouped per tracker account, with state, involvement and search filters. An issue opens in the right panel beside the list, where it can be edited, commented on, closed and reacted to. New issue files one from the repository's templates. The reaction bar is shared with pull requests rather than copied. Co-Authored-By: Claude Fable 5.2 --- .../src/environment/ServerEnvironment.ts | 1 + apps/web/src/closedViewStore.ts | 10 + apps/web/src/components/CommandPalette.tsx | 19 + .../components/ReopenClosedViewShortcut.tsx | 11 +- apps/web/src/components/RightPanelTabs.tsx | 5 + .../components/issue/IssueCreateDialog.tsx | 270 ++++++ .../src/components/issue/IssueDetailGhost.tsx | 104 ++ .../src/components/issue/IssueDetailPanel.tsx | 844 +++++++++++++++++ apps/web/src/components/issue/IssueRow.tsx | 117 +++ apps/web/src/components/issue/IssuesPage.tsx | 886 ++++++++++++++++++ .../components/issue/issueListPreferences.ts | 74 ++ .../components/issue/issuePresentation.tsx | 75 ++ .../pullRequest/PullRequestGhosts.tsx | 2 +- .../pullRequest/PullRequestListFilters.tsx | 8 +- .../pullRequest/PullRequestReactions.tsx | 85 +- .../pullRequest/PullRequestsPage.tsx | 2 + .../src/components/sidebar/SidebarChrome.tsx | 16 +- .../src/components/sidebar/mainAppLocation.ts | 4 +- apps/web/src/reopenClosedView.ts | 3 + apps/web/src/rightPanelStore.ts | 106 ++- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.issues.tsx | 38 + apps/web/src/state/environments.ts | 4 + apps/web/src/state/issues.ts | 67 ++ docs/user/source-control.md | 19 + packages/client-runtime/package.json | 4 + .../client-runtime/src/state/issues.test.ts | 92 ++ packages/client-runtime/src/state/issues.ts | 175 ++++ .../client-runtime/src/state/presentation.ts | 8 + packages/contracts/src/environment.ts | 1 + packages/contracts/src/issue.ts | 27 +- 31 files changed, 3037 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/issue/IssueCreateDialog.tsx create mode 100644 apps/web/src/components/issue/IssueDetailGhost.tsx create mode 100644 apps/web/src/components/issue/IssueDetailPanel.tsx create mode 100644 apps/web/src/components/issue/IssueRow.tsx create mode 100644 apps/web/src/components/issue/IssuesPage.tsx create mode 100644 apps/web/src/components/issue/issueListPreferences.ts create mode 100644 apps/web/src/components/issue/issuePresentation.tsx create mode 100644 apps/web/src/routes/_chat.issues.tsx create mode 100644 apps/web/src/state/issues.ts create mode 100644 packages/client-runtime/src/state/issues.test.ts create mode 100644 packages/client-runtime/src/state/issues.ts diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 813efbe53ea30..f59d6be362985 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -227,6 +227,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, pullRequestChecks: true, + issues: true, linkedThreadRefreshes: true, sideChats: true, vcsBranchStatus: true, diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index b5db7bd741bef..2578922db3d11 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -95,6 +95,16 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { (surface.host === undefined || typeof surface.host === "string") && (surface.url === undefined || typeof surface.url === "string") ); + case "issue": + return ( + typeof surface.projectId === "string" && + typeof surface.tracker === "string" && + typeof surface.scope === "string" && + typeof surface.key === "string" && + (surface.environmentId === undefined || typeof surface.environmentId === "string") && + (surface.host === undefined || typeof surface.host === "string") && + (surface.url === undefined || typeof surface.url === "string") + ); default: return false; } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0ea2cd7a2015d..d9435b5dee4d3 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -277,6 +277,8 @@ import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-gro import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; +import { readIssueListPreferences } from "~/components/issue/issueListPreferences"; +import { IssueGlyph } from "~/components/issue/issuePresentation"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; import { threadRecentUse } from "@t3tools/shared/paletteSearch"; @@ -1653,6 +1655,23 @@ function OpenCommandPaletteDialog(props: { }); } + if ( + environments.some( + (environment) => environment.serverConfig?.environment.capabilities.issues === true, + ) + ) { + actionItems.push({ + kind: "action", + value: "action:issues", + searchTerms: ["issues", "issue", "bugs", "tracker", "github", "triage"], + title: "Open issues", + icon: , + run: async () => { + await navigate({ to: "/issues", search: readIssueListPreferences() }); + }, + }); + } + actionItems.push({ kind: "action", value: "action:use-in-another-app", diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index dfb68fe27e80a..3895f272d20f5 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -23,6 +23,7 @@ import { reopenClosedView, } from "../reopenClosedView"; import { + ISSUES_PANEL_REF, PULL_REQUESTS_PANEL_REF, selectActiveRightPanel, selectSelectedRightPanelSurface, @@ -42,6 +43,7 @@ import { resolveThreadRouteTarget, } from "../threadRoutes"; import { toastManager } from "./ui/toast"; +import { readIssueListPreferences } from "./issue/issueListPreferences"; const PREVIEW_FORWARDED_COMMANDS = [ "workspace.focusNext", @@ -58,6 +60,8 @@ const PREVIEW_FORWARDED_COMMANDS = [ const isGlobalPullRequests = (ref: ScopedThreadRef) => scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF); +const isGlobalIssues = (ref: ScopedThreadRef) => + scopedThreadKey(ref) === scopedThreadKey(ISSUES_PANEL_REF); export function ReopenClosedViewShortcut() { const navigate = useNavigate(); @@ -91,7 +95,7 @@ export function ReopenClosedViewShortcut() { const { drop, restore } = planNextReopen(useClosedViewStore.getState().entries, (entry) => { const ref = entry.threadRef; const panel = selectThreadRightPanelState(panelsByThread, ref); - if (isGlobalPullRequests(ref)) { + if (isGlobalPullRequests(ref) || isGlobalIssues(ref)) { return { environmentKnown: true, catalogReady: true, @@ -119,7 +123,8 @@ export function ReopenClosedViewShortcut() { const ref = restore.threadRef; const globalPullRequests = isGlobalPullRequests(ref); - const thread = globalPullRequests ? null : readThreadShell(ref); + const globalIssues = isGlobalIssues(ref); + const thread = globalPullRequests || globalIssues ? null : readThreadShell(ref); const owner = thread ?? drafts.getDraftThreadByRef(ref); const project = owner ? readProject(scopeProjectRef(ref.environmentId, owner.projectId)) : null; if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable: project !== null }))) { @@ -136,6 +141,8 @@ export function ReopenClosedViewShortcut() { to: "/pull-requests", search: (previous) => pullRequestsSearchForRestore(previous, selected), }); + } else if (globalIssues) { + await navigate({ to: "/issues", search: readIssueListPreferences() }); } else { const draftId = thread === null ? drafts.getDraftIdByRef(ref) : null; if (draftId !== null) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 7976616286735..c210bf658fae7 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -36,6 +36,7 @@ import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dn import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { + CircleDotIcon, Smartphone, ChevronDown, ChevronLeft, @@ -592,6 +593,8 @@ function surfaceTitle( return `#${surface.number}`; case "pull-requests": return "Pull requests"; + case "issue": + return surface.key; case "device": return surface.title ?? surface.target?.name ?? "Device"; case "side-chat": @@ -684,6 +687,8 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "issue": + return ; case "side-chat": return ; case "device": diff --git a/apps/web/src/components/issue/IssueCreateDialog.tsx b/apps/web/src/components/issue/IssueCreateDialog.tsx new file mode 100644 index 0000000000000..e85775bbb5643 --- /dev/null +++ b/apps/web/src/components/issue/IssueCreateDialog.tsx @@ -0,0 +1,270 @@ +import { useAtomValue } from "@effect/atom-react"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, IssueCreateResult, IssueScopeRef } from "@t3tools/contracts"; +import { useState } from "react"; + +import { issueEnvironment } from "~/state/issues"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Textarea } from "../ui/textarea"; +import { toastManager } from "../ui/toast"; +import { readableFailure } from "../pullRequest/pullRequestDetail.logic"; + +export interface IssueCreateScope { + readonly environmentId: EnvironmentId; + readonly projectTitle: string; + readonly scope: IssueScopeRef; + readonly templates: boolean; +} + +const BLANK = "__blank__"; + +/** + * One text box with a draft in it, on any tracker: a chosen template fills the title, body, + * labels and assignees, and the writer takes it from there. Templates are read when the dialog + * opens, for the scope it opens on. + */ +export function IssueCreateDialog({ + open, + scopes, + onOpenChange, + onCreated, +}: { + open: boolean; + /** Every writable scope the page knows about; the first is preselected. */ + scopes: ReadonlyArray; + onOpenChange: (open: boolean) => void; + onCreated: (scope: IssueCreateScope, result: IssueCreateResult) => void; +}) { + const [scopeIndex, setScopeIndex] = useState(0); + const scope = scopes[Math.min(scopeIndex, scopes.length - 1)] ?? null; + const [templateKey, setTemplateKey] = useState(BLANK); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [extras, setExtras] = useState<{ + labels: ReadonlyArray; + assignees: ReadonlyArray; + }>({ + labels: [], + assignees: [], + }); + const [pending, setPending] = useState(false); + const create = useAtomCommand(issueEnvironment.create, { reportFailure: false }); + const canWrite = useAtomValue( + issueEnvironment.create.permissionAtom(scope?.environmentId ?? null), + ); + const templatesQuery = useEnvironmentQuery( + open && scope !== null && scope.templates + ? issueEnvironment.templates({ environmentId: scope.environmentId, input: scope.scope }) + : null, + ); + const templates = templatesQuery.data?.templates ?? []; + const blankAllowed = templatesQuery.data?.blankIssuesEnabled ?? true; + + const reset = () => { + setTemplateKey(BLANK); + setTitle(""); + setBody(""); + setExtras({ labels: [], assignees: [] }); + }; + + const chooseTemplate = (key: string | null) => { + const next = key ?? BLANK; + setTemplateKey(next); + const template = templates.find((entry) => entry.key === next); + if (!template) { + setExtras({ labels: [], assignees: [] }); + return; + } + // A template is a starting point: it only overwrites what the writer has not typed yet. + if (title.trim().length === 0) setTitle(template.title); + if (body.trim().length === 0) setBody(template.body); + setExtras({ labels: template.labels, assignees: template.assignees }); + }; + + const submit = async () => { + if (scope === null || pending) return; + const trimmedTitle = title.trim(); + if (trimmedTitle.length === 0) return; + setPending(true); + const result = await create({ + environmentId: scope.environmentId, + input: { + ...scope.scope, + title: trimmedTitle, + body, + labels: extras.labels, + assignees: extras.assignees, + }, + }); + setPending(false); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: "The issue could not be created", + description: readableFailure(squashAtomCommandFailure(result), "The tracker refused it."), + }); + return; + } + toastManager.add({ type: "success", title: `Created ${result.value.displayKey}` }); + reset(); + onCreated(scope, result.value); + onOpenChange(false); + }; + + const blocked = + scope === null + ? "No project here can file issues." + : !canWrite + ? "Creating issues needs the source-control write scope." + : !blankAllowed && templateKey === BLANK && templates.length > 0 + ? "This tracker requires a template." + : null; + + return ( + (pending ? undefined : onOpenChange(next))}> + + + New issue + File an issue on the project's tracker. + + +
+ {scopes.length > 1 ? ( +
+ + +
+ ) : null} + {scope?.templates && (templates.length > 0 || templatesQuery.isPending) ? ( +
+ + +
+ ) : null} +
+
+ + setTitle(event.target.value)} + placeholder="A short summary" + /> +
+
+ +