diff --git a/cmd/web/frontend/docs/ui-design-system.ja.md b/cmd/web/frontend/docs/ui-design-system.ja.md index 63f968b8..3224c19f 100644 --- a/cmd/web/frontend/docs/ui-design-system.ja.md +++ b/cmd/web/frontend/docs/ui-design-system.ja.md @@ -402,11 +402,12 @@ UI primitive は `src/components/ui/` 配下に置きます。domain に依存 ### Button (`ui/button`) -- 既定の primitive: `Button` は `--primary-black` 背景、白文字、 - 高さ `40px`、radius `6px`、横 padding `0 16px` の primary アクションを - 描画します。 -- 同伴の `.splitButton` セレクターは、末尾の `40px` 正方形 chevron スロットで - 同じ見た目を再利用します。 +- `Button` は `primary`、`positive`、`secondary`、`tertiary` variant を提供します。 + Primary は黒塗り、positive は ready 状態の緑塗り、secondary は neutral な + アウトライン、tertiary は visible border のない muted text を使います。 +- default size は高さ `40px`、横 padding `0 16px` です。`compact` size は + Issue Card のクイックアクションに合わせた高さ `32px`、横 padding `0 12px` です。 +- Storybook で各 variant の個別表示と一覧比較を確認できます。 - グローバルな `button` リセットは、それ以外のすべての native button に 中立の surface、`1px` border、radius `6px`、padding `8px 10px`、 `--muted` の disabled 状態を与えます。 @@ -577,10 +578,11 @@ gap: 14px; - radius `8px` のカード、`0 12px 32px rgb(0 0 0 / 8%)` の濃いめの shadow。 - タイトル行: `16px / 500` リンク + `32px` の menu トリガー。 - メトリック行: icon + value ペア。各メトリックを `1px` 左 border で区切ります。 -- クイックアクションボタンはスコープ付き変数 (`--quick-action-bg`、 - `--quick-action-border`、`--quick-action-text`) を使い、各バリアント - (`quickAction-ready`、`quickAction-done`) は色だけを上書きします。 +- クイックアクションは共有の compact `Button` を使います。Ready は `positive`、 + Done は `primary`、Resolve は `secondary` です。 - blocked のカードでは、`Ready` への直接遷移を `Resolve` に置き換えます。 + neutral なアウトラインを使ったテキストのみのボタンにより、ダイアログを開く + この操作を、緑色で直接遷移する `Ready` と区別します。 この操作は comment の全ページから最新の blocker comment を取得し、 Continue with Comment ダイアログを開きます。blocker は読み取り専用の文脈として 表示し、フリーテキストまたは組み込みの `Ok` / `Retry` ショートカットで @@ -632,6 +634,8 @@ gap: 14px; の上に積みます。 - `basic-info-panel` は issue ID、project、priority、status、assignee、 作成日、更新日を 1 カラム meta grid で表示します。 +- `artifacts-section` は pull request を、種別 icon、label、repository と PR 番号、 + を横並びにした compact な text link として表示します。 - `meta-item` は各エントリを top rule、`12px` muted `dt`、通常 weight の `dd`、ID のための `overflow-wrap: anywhere` で描画します。 - `runs-section` は run 行を `8px` border のグループで囲みます。 @@ -643,12 +647,13 @@ gap: 14px; `コメントを入力…` はエディターを開き、設定済みショートカットは指示本文を 即時送信します。将来、短い表示名に長い agent 指示を割り当てられるよう、 ショートカットの表示名と本文は別々の値として扱います。 -- Reject 操作は split button を使用します。主ボタンはエディターを開き、 - chevron 側はショートカットメニューを開きます。両方で disabled と送信中の - 状態を共有します。 +- Reject 操作は primary の Done より主張を弱めた neutral な secondary split button を + 使用します。主ボタンはエディターを開き、chevron 側はショートカットメニューを + 開きます。両方で disabled と送信中の状態を共有します。 - Issue 詳細 sidebar の status shortcut には `Quick Action` の見出しを付けます。 - backlog では `Ready`、ready では `Draft`、review では `Done` と赤色の Reject - split button を表示します。 + backlog では positive の `Ready`、ready では secondary の `Draft`、review では + secondary の Reject split button、primary の `Done` の順で表示します。blocked では + secondary の `Resolve` を表示します。 - `status-actions` は同じカード内の wrap-flex アクション行です。 - `issue-description` は共有 markdown レンダラーを使い、line-height `1.65`、アンカー色 `--primary-black`、インライン画像に border を付けます。 diff --git a/cmd/web/frontend/docs/ui-design-system.md b/cmd/web/frontend/docs/ui-design-system.md index fe4032f0..c2ee71df 100644 --- a/cmd/web/frontend/docs/ui-design-system.md +++ b/cmd/web/frontend/docs/ui-design-system.md @@ -404,11 +404,14 @@ must remain reusable. ### Button (`ui/button`) -- Default primitive: `Button` renders a primary action button with - `--primary-black` background, white text, `40px` height, `6px` radius, and a - `0 16px` horizontal padding. -- A companion `.splitButton` selector reuses the same look in a `40px` square - trailing chevron slot. +- `Button` exposes `primary`, `positive`, `secondary`, and `tertiary` variants. + Primary uses a black fill, positive uses the ready-state green fill, + secondary uses a neutral outline, and tertiary uses muted text without a + visible border. +- The default size is `40px` high with `0 16px` horizontal padding. The + `compact` size matches Issue Card quick actions at `32px` high with `0 12px` + horizontal padding. +- Storybook exposes each variant independently and together for visual comparison. - The global `button` reset gives every other native button a neutral surface with `1px` border, `6px` radius, `8px 10px` padding, and a disabled state in `--muted`. @@ -579,14 +582,14 @@ This is the recurring shape for `runs-section`, `comment-list`, - `8px` radius card with a heavier `0 12px 32px rgb(0 0 0 / 8%)` shadow. - Title row: `16px / 500` link plus a `32px` menu trigger. - Metric row: icon + value pairs separated by `1px` left border per metric. -- Quick action button uses scoped variables (`--quick-action-bg`, - `--quick-action-border`, `--quick-action-text`) so each variant - (`quickAction-ready`, `quickAction-done`) only overrides colors. +- Quick actions use the shared compact `Button`: Ready uses `positive`, Done + uses `primary`, and Resolve uses `secondary`. - A blocked card replaces the direct `Ready` transition with `Resolve`. The - action loads the latest blocker comment across all comment pages and opens a - continue-with-comment dialog. The blocker is read-only context; free text or - the built-in `Ok` / `Retry` shortcuts create the change request before the - issue moves to `ready`. + text-only neutral outlined button distinguishes this dialog-opening action + from the direct green `Ready` transition. The action loads the latest blocker + comment across all comment pages and opens a continue-with-comment dialog. + The blocker is read-only context; free text or the built-in `Ok` / `Retry` + shortcuts create the change request before the issue moves to `ready`. ### Issue Board @@ -633,6 +636,8 @@ palette: content under `900px`. - `basic-info-panel` uses a one-column meta grid for issue ID, project, priority, status, assignee, created date, and updated date. +- `artifacts-section` presents a pull request as a compact text link with a type + icon and an inline label, repository, and PR number reference. - `meta-item` renders each entry with a top rule, `12px` muted `dt`, normal weight `dd`, and `overflow-wrap: anywhere` for IDs. - `runs-section` wraps run rows in a single `8px` bordered group. @@ -644,12 +649,14 @@ palette: opens the editor, while configured shortcuts submit their instruction body immediately. Shortcut labels and bodies are separate values so future configuration can keep compact labels for longer agent instructions. -- Reject actions use a split button: the primary segment opens the editor and - the chevron segment opens the shortcut menu. Both segments share disabled - and submitting states. +- Reject actions use a neutral secondary split button so they remain weaker + than the primary Done action. The main segment opens the editor and the + chevron segment opens the shortcut menu. Both segments share disabled and + submitting states. - The issue-detail sidebar labels its status shortcuts as `Quick Action`: - backlog shows `Ready`, ready shows `Draft`, and review shows `Done` together - with the red Reject split button. + backlog shows positive `Ready`, ready shows secondary `Draft`, and review + shows the secondary Reject split button before primary `Done`. Blocked issues + show secondary `Resolve`. - `status-actions` is a wrap-flex action row inside the same card. - `issue-description` uses the shared markdown renderer with `1.65` line height, anchor color `--primary-black`, and bordered inline images. diff --git a/cmd/web/frontend/src/components/layout/sidebar/index.module.css b/cmd/web/frontend/src/components/layout/sidebar/index.module.css index 22cd0b99..511ac06d 100644 --- a/cmd/web/frontend/src/components/layout/sidebar/index.module.css +++ b/cmd/web/frontend/src/components/layout/sidebar/index.module.css @@ -112,7 +112,7 @@ } .version { - align-self: flex-end; + align-self: flex-start; color: var(--medium-gray); font-size: 11px; line-height: 1; diff --git a/cmd/web/frontend/src/components/ui/button/index.module.css b/cmd/web/frontend/src/components/ui/button/index.module.css index 2fa39077..a4b20a7a 100644 --- a/cmd/web/frontend/src/components/ui/button/index.module.css +++ b/cmd/web/frontend/src/components/ui/button/index.module.css @@ -1,22 +1,68 @@ -.primaryButton, -.splitButton { - background: var(--primary-black); - border-color: var(--primary-black); - color: var(--white); - height: 40px; -} - -.primaryButton { +.button { align-items: center; + border: 1px solid transparent; border-radius: var(--radius-sm); + cursor: pointer; display: inline-flex; + flex-shrink: 0; + font-weight: 500; + justify-content: center; + line-height: 1; +} + +.default { + block-size: 40px; + font-size: 14px; gap: var(--space-2); padding: var(--space-0) var(--space-4); } -.splitButton { - border-left-color: var(--dark-gray); - border-radius: var(--space-0) var(--radius-sm) var(--radius-sm) var(--space-0); - margin-left: calc(var(--space-2) * -1); - width: 40px; +.compact { + block-size: 32px; + font-size: 13px; + gap: 6px; + padding: var(--space-0) var(--space-3); +} + +.primary { + background: var(--primary-black); + border-color: var(--primary-black); + color: var(--white); +} + +.positive { + background: var(--status-ready-text); + border-color: var(--status-ready-text); + color: var(--white); +} + +.secondary { + background: transparent; + border-color: var(--border); + color: var(--primary-black); +} + +.tertiary { + background: transparent; + border-color: transparent; + color: var(--muted); +} + +.button:hover:not(:disabled), +.button:focus-visible:not(:disabled) { + opacity: 0.9; +} + +.secondary:hover:not(:disabled), +.secondary:focus-visible:not(:disabled), +.tertiary:hover:not(:disabled), +.tertiary:focus-visible:not(:disabled) { + background: var(--surface-hover); + color: var(--primary-black); + opacity: 1; +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.6; } diff --git a/cmd/web/frontend/src/components/ui/button/index.stories.tsx b/cmd/web/frontend/src/components/ui/button/index.stories.tsx index 4fa39b8e..6ac5ee24 100644 --- a/cmd/web/frontend/src/components/ui/button/index.stories.tsx +++ b/cmd/web/frontend/src/components/ui/button/index.stories.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { noop } from "@/stories/fixtures"; import { Button } from "./index"; @@ -8,6 +9,15 @@ const meta = { args: { children: "Create task", onClick: noop, + size: "default", + variant: "primary", + }, + argTypes: { + size: { control: "inline-radio", options: ["default", "compact"] }, + variant: { + control: "inline-radio", + options: ["primary", "positive", "secondary", "tertiary"], + }, }, } satisfies Meta; @@ -16,3 +26,45 @@ export default meta; type Story = StoryObj; export const Primary: Story = {}; + +export const Positive: Story = { + args: { children: "Ready", variant: "positive" }, +}; + +export const Secondary: Story = { + args: { children: "Resolve", variant: "secondary" }, +}; + +export const Tertiary: Story = { + args: { children: "Reject", variant: "tertiary" }, +}; + +export const Compact: Story = { + args: { children: "Done", size: "compact" }, +}; + +export const Variants: Story = { + render: (args) => ( +
+ + + + +
+ ), +}; + +const variantGridStyle: CSSProperties = { + alignItems: "center", + display: "flex", + flexWrap: "wrap", + gap: "12px", +}; diff --git a/cmd/web/frontend/src/components/ui/button/index.tsx b/cmd/web/frontend/src/components/ui/button/index.tsx index 6c30e7bc..706e82b9 100644 --- a/cmd/web/frontend/src/components/ui/button/index.tsx +++ b/cmd/web/frontend/src/components/ui/button/index.tsx @@ -1,13 +1,30 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; import styles from "./index.module.css"; -type Props = { - children?: React.ReactNode; - onClick: () => void; +export type ButtonVariant = "primary" | "positive" | "secondary" | "tertiary"; +export type ButtonSize = "default" | "compact"; + +type Props = Omit, "type"> & { + children?: ReactNode; + size?: ButtonSize; + type?: "button" | "submit" | "reset"; + variant?: ButtonVariant; }; -export function Button({ children, onClick }: Props) { +export function Button({ + children, + className, + size = "default", + type = "button", + variant = "primary", + ...buttonProps +}: Props) { return ( - ); diff --git a/cmd/web/frontend/src/features/issues/components/card/index.module.css b/cmd/web/frontend/src/features/issues/components/card/index.module.css index 38a451a9..f2f712c4 100644 --- a/cmd/web/frontend/src/features/issues/components/card/index.module.css +++ b/cmd/web/frontend/src/features/issues/components/card/index.module.css @@ -140,40 +140,6 @@ min-width: 0; } -.quickActionButton { - align-items: center; - block-size: 32px; - background: var(--quick-action-bg); - border: 1px solid var(--quick-action-border); - border-radius: var(--radius-sm); - color: var(--quick-action-text); - display: inline-flex; - flex-shrink: 0; - font-size: 13px; - font-weight: 500; - gap: 6px; - justify-content: center; - line-height: 1; - padding: 0 12px; -} - -.quickActionButton:hover, -.quickActionButton:focus-visible { - opacity: 0.9; -} - -.quickAction-ready { - --quick-action-bg: var(--status-ready-text); - --quick-action-border: transparent; - --quick-action-text: var(--white); -} - -.quickAction-done { - --quick-action-bg: transparent; - --quick-action-border: var(--border); - --quick-action-text: var(--primary-black); -} - .quickActionIcon { color: currentcolor; flex-shrink: 0; diff --git a/cmd/web/frontend/src/features/issues/components/card/index.test.tsx b/cmd/web/frontend/src/features/issues/components/card/index.test.tsx index f4ec5de6..743f5cbf 100644 --- a/cmd/web/frontend/src/features/issues/components/card/index.test.tsx +++ b/cmd/web/frontend/src/features/issues/components/card/index.test.tsx @@ -3,6 +3,7 @@ import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import buttonStyles from "@/components/ui/button/index.module.css"; import * as api from "@/lib/api"; import { createAppQueryClient } from "@/lib/query-client"; import { toastStore } from "@/lib/toast"; @@ -247,6 +248,7 @@ describe("IssueCard", () => { const quickAction = screen.getByRole("button", { name: "Ready" }); expect(quickAction).toHaveTextContent("Ready"); + expect(quickAction).toHaveClass(buttonStyles.positive, buttonStyles.compact); expect(quickAction.querySelector("svg")).toBeInTheDocument(); await user.click(quickAction); @@ -264,6 +266,9 @@ describe("IssueCard", () => { const quickAction = screen.getByRole("button", { name: "Resolve" }); + expect(quickAction).toHaveClass(buttonStyles.secondary, buttonStyles.compact); + expect(quickAction.querySelector("svg")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Issue actions for Wire issue board to generated client", })); @@ -292,6 +297,7 @@ describe("IssueCard", () => { const quickAction = screen.getByRole("button", { name: "Done" }); expect(quickAction).toHaveTextContent("Done"); + expect(quickAction).toHaveClass(buttonStyles.primary, buttonStyles.compact); expect(quickAction.querySelector("svg")).not.toBeInTheDocument(); await user.click(quickAction); diff --git a/cmd/web/frontend/src/features/issues/components/card/index.tsx b/cmd/web/frontend/src/features/issues/components/card/index.tsx index fea28795..0d9cd1d6 100644 --- a/cmd/web/frontend/src/features/issues/components/card/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/card/index.tsx @@ -9,6 +9,7 @@ import { ContextMenuItem, ContextMenuSeparator, } from "@/components/ui/context-menu"; +import { Button, type ButtonVariant } from "@/components/ui/button"; import { IconProxy, type IconProxyName } from "@/components/ui/icon-proxy"; import { toast } from "@/lib/toast"; import { PriorityBadge } from "@/features/issues/components/priority-badge"; @@ -233,18 +234,17 @@ export function IssueCard({ /> ) : null} {canResolve ? ( - + ) : quickStatusTarget ? ( - + ) : null} ) : null} @@ -295,8 +295,8 @@ function statusIconClassName(status: IssueStatus): string { return styles.statusIconMuted; } -function quickActionClassName(status: IssueStatus): string { - return `${styles.quickActionButton} ${styles[`quickAction-${status}`]}`; +function quickActionVariant(status: IssueStatus): ButtonVariant { + return status === "ready" ? "positive" : "primary"; } function quickActionShowsIcon(status: IssueStatus): boolean { diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.module.css b/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.module.css index d23b34f1..3ae21af8 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.module.css +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.module.css @@ -9,18 +9,57 @@ } .heading { - font-size: 16px; + font-size: 12px; + font-weight: 400; margin: var(--space-0); } .link { + align-items: center; + display: grid; + gap: var(--space-2); + grid-template-columns: auto minmax(0, 1fr); + min-width: 0; color: var(--markdown-link); overflow-wrap: anywhere; - text-decoration-line: underline; - text-underline-offset: 2px; + text-decoration: none; } -.link:hover, -.link:focus-visible { +.link:hover { color: var(--markdown-link-hover); } + +.link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.iconFrame { + align-items: center; + display: inline-flex; + justify-content: center; + color: currentColor; +} + +.linkCopy { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + min-width: 0; +} + +.linkLabel { + font-size: 13px; + font-weight: 600; + line-height: 1.2; + text-decoration: underline; + text-underline-offset: 2px; +} + +.reference { + color: var(--muted); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.3; +} diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.test.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.test.tsx index add3d003..46372648 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.test.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.test.tsx @@ -27,6 +27,10 @@ describe("ArtifactsSection", () => { "rel", "noopener noreferrer", ); + expect(screen.getByText("version-1/tasq #14")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Pull request" }).querySelector(".lucide-git-pull-request"), + ).toBeInTheDocument(); }); it("does not render an empty section when no pull request artifact exists", () => { diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.tsx index 555093a5..a1d73489 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/artifacts-section/index.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { IconProxy } from "@/components/ui/icon-proxy"; import type { Artifact } from "@/lib/types"; import { pullRequestArtifact } from "@/features/issues/artifacts"; import styles from "./index.module.css"; @@ -15,13 +16,30 @@ export function ArtifactsSection({ artifacts }: { artifacts: readonly Artifact[]

{t("issues.detailPage.artifacts")}

- {t("issues.detailPage.pullRequest")} + + + + + {t("issues.detailPage.pullRequest")} + {pullRequestReference(pullRequest.data_value)} +
); } + +function pullRequestReference(value: string): string { + try { + const url = new URL(value); + const match = url.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/); + return match ? `${match[1]}/${match[2]} #${match[3]}` : url.hostname; + } catch { + return value; + } +} diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/basic-info-panel/index.module.css b/cmd/web/frontend/src/features/issues/components/issue-detail-page/basic-info-panel/index.module.css index 51b7344c..c7f42e37 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/basic-info-panel/index.module.css +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/basic-info-panel/index.module.css @@ -18,9 +18,8 @@ .quickActions h3 { color: var(--muted); font-size: 12px; - font-weight: 700; + font-weight: 400; margin: 0; - text-transform: uppercase; } .panelActions { @@ -30,28 +29,6 @@ justify-content: flex-end; } -.quickActionButton { - block-size: 32px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--primary-black); - font-size: 13px; - font-weight: 500; - line-height: 1; - padding: 0 12px; -} - -.quickActionButton:hover:not(:disabled), -.quickActionButton:focus-visible:not(:disabled) { - background: var(--surface-hover); -} - -.quickActionButton:disabled { - cursor: not-allowed; - opacity: 0.6; -} - .metaGrid { display: grid; gap: 12px; diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/basic-info-panel/index.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/basic-info-panel/index.tsx index 78b541ee..6f9dc675 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/basic-info-panel/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/basic-info-panel/index.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useEffect, useRef, useState } from "react"; +import { Button, type ButtonVariant } from "@/components/ui/button"; import { IconProxy } from "@/components/ui/icon-proxy"; import type { Issue, IssueStatus, IssueSummary } from "@/lib/types"; import { issueStatuses } from "@/lib/types"; @@ -19,6 +20,7 @@ export function BasicInfoPanel({ issueOptions = [], onRejectIssue, onRejectShortcut, + onResolveIssue, onStatusChange, }: { disabled: boolean; @@ -26,12 +28,14 @@ export function BasicInfoPanel({ issueOptions?: IssueSummary[]; onRejectIssue?: () => void; onRejectShortcut?: (shortcut: ChangeRequestShortcut) => Promise; + onResolveIssue?: () => void; onStatusChange: (status: IssueStatus) => Promise; }) { const { t } = useTranslation(); const dependencyIssues = dependencyIssueLinks(issue.dependency_ids, issueOptions); const quickStatusAction = quickStatusActionFor(issue.status); const canReject = issue.status === "review" && onRejectIssue && onRejectShortcut; + const canResolve = issue.status === "blocked" && onResolveIssue; return (
@@ -69,20 +73,10 @@ export function BasicInfoPanel({ - {quickStatusAction || canReject ? ( + {quickStatusAction || canReject || canResolve ? (

{t("issues.detailPage.quickActions")}

- {quickStatusAction ? ( - - ) : null} {canReject ? ( ) : null} + {quickStatusAction ? ( + + ) : null} + {canResolve ? ( + + ) : null}
) : null} @@ -100,10 +109,17 @@ export function BasicInfoPanel({ function quickStatusActionFor(status: IssueStatus): { status: IssueStatus; labelKey: "statuses.ready" | "issues.board.draft" | "statuses.done"; + variant: ButtonVariant; } | null { - if (status === "backlog") return { status: "ready", labelKey: "statuses.ready" }; - if (status === "ready") return { status: "backlog", labelKey: "issues.board.draft" }; - if (status === "review") return { status: "done", labelKey: "statuses.done" }; + if (status === "backlog") { + return { status: "ready", labelKey: "statuses.ready", variant: "positive" }; + } + if (status === "ready") { + return { status: "backlog", labelKey: "issues.board.draft", variant: "secondary" }; + } + if (status === "review") { + return { status: "done", labelKey: "statuses.done", variant: "primary" }; + } return null; } diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/index.test.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/index.test.tsx index 7679ccdf..bc518ced 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/index.test.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/index.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import buttonStyles from "@/components/ui/button/index.module.css"; import type { AttachmentListResponse, ChangeRequestListResponse, @@ -147,8 +148,48 @@ describe("IssueDetailPage", () => { renderIssueDetail(); - expect(await screen.findByRole("button", { name: "Done" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument(); + const done = await screen.findByRole("button", { name: "Done" }); + const reject = screen.getByRole("button", { name: "Reject" }); + + expect(done).toHaveClass(buttonStyles.primary, buttonStyles.compact); + expect(reject).toHaveClass(buttonStyles.secondary, buttonStyles.compact); + expect(reject.compareDocumentPosition(done)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it("shows Resolve as the blocked issue quick action", async () => { + const user = userEvent.setup(); + api.fetchIssue.mockResolvedValueOnce({ ...issue, status: "blocked" }); + api.fetchComments.mockResolvedValueOnce({ + data: [{ + id: 7, + issueId: 42, + author: "runner", + type: "blocker", + body: "Configure credentials before continuing.", + createdAt: "2026-06-21T03:00:00.000Z", + }], + meta: { cursor: 0, limit: 20, direction: "forward", nextCursor: null }, + } satisfies CommentListResponse); + api.updateIssueStatus.mockResolvedValueOnce({ ...issue, status: "ready" }); + + renderIssueDetail(); + + const resolve = await screen.findByRole("button", { name: "Resolve" }); + expect(resolve).toHaveClass(buttonStyles.secondary, buttonStyles.compact); + + await user.click(resolve); + + const dialog = await screen.findByRole("dialog", { name: "Continue with comment #42" }); + expect(await within(dialog).findByText("Configure credentials before continuing.")).toBeInTheDocument(); + await user.type( + within(dialog).getByRole("textbox", { name: "Change request" }), + "Credentials are configured", + ); + await user.click(within(dialog).getByRole("button", { name: "Continue with comment" })); + + await waitFor(() => { + expect(api.fetchChangeRequests).toHaveBeenCalledWith(42, 100, { silent: true }); + }); }); it("rejects a review issue with a change request", async () => { diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/index.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/index.tsx index 37c10675..f205b11c 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/index.tsx @@ -35,6 +35,7 @@ import { IssueDescription } from "./issue-description"; import { BasicInfoPanel } from "./basic-info-panel"; import { RunsSection } from "./runs-section"; import { ChangeRequestDialog } from "../change-request-dialog"; +import { ResolveIssueDialog } from "../resolve-issue-dialog"; import type { ChangeRequestShortcut, ChangeRequestVariant, @@ -88,6 +89,7 @@ export function IssueDetailPage() { const [isUpdatingDescription, setIsUpdatingDescription] = useState(false); const [changeRequestDialog, setChangeRequestDialog] = useState(null); + const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false); const [changeRequestError, setChangeRequestError] = useState(""); const updateSearchParams = useCallback( @@ -477,6 +479,10 @@ export function IssueDetailPage() { setChangeRequestDialog({ variant: "reject" }); }} onRejectShortcut={(shortcut) => handleChangeRequestShortcut("reject", shortcut)} + onResolveIssue={() => { + setChangeRequestError(""); + setIsResolveDialogOpen(true); + }} onStatusChange={handleStatusChange} /> @@ -558,6 +564,24 @@ export function IssueDetailPage() { variant={changeRequestDialog.variant} /> ) : null} + {issueState.kind === "ready" && isResolveDialogOpen ? ( + { + setChangeRequestError(""); + setIsResolveDialogOpen(false); + }} + onMoveIssueReady={() => handleMoveIssueReady("continue")} + onSuccess={() => { + setChangeRequestError(""); + setIsResolveDialogOpen(false); + void loadChangeRequests(); + }} + /> + ) : null} ); } diff --git a/cmd/web/frontend/src/features/issues/components/reject-action/index.module.css b/cmd/web/frontend/src/features/issues/components/reject-action/index.module.css index 53fee545..b2c73c2f 100644 --- a/cmd/web/frontend/src/features/issues/components/reject-action/index.module.css +++ b/cmd/web/frontend/src/features/issues/components/reject-action/index.module.css @@ -2,13 +2,12 @@ display: inline-flex; } -.primaryButton, .menuButton { align-items: center; block-size: 32px; - background: var(--danger); - border: 1px solid var(--danger); - color: var(--white); + background: transparent; + border: 1px solid var(--border); + color: var(--primary-black); display: inline-flex; font-size: 13px; font-weight: 500; @@ -18,7 +17,6 @@ .primaryButton { border-radius: var(--radius-sm) 0 0 var(--radius-sm); - padding: 0 12px; } .menuButton { @@ -31,12 +29,14 @@ .primaryButton:focus-visible:not(:disabled), .menuButton:hover:not(:disabled), .menuButton:focus-visible:not(:disabled) { - background: var(--danger-text, #9f1d1d); + background: var(--surface-hover); + color: var(--primary-black); + opacity: 1; } .primaryButton:focus-visible, .menuButton:focus-visible { - outline: 2px solid var(--danger); + outline: 2px solid var(--primary-black); outline-offset: 2px; } diff --git a/cmd/web/frontend/src/features/issues/components/reject-action/index.tsx b/cmd/web/frontend/src/features/issues/components/reject-action/index.tsx index fe63072c..e58fe551 100644 --- a/cmd/web/frontend/src/features/issues/components/reject-action/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/reject-action/index.tsx @@ -5,6 +5,7 @@ import { ContextMenuGroupLabel, ContextMenuItem, } from "@/components/ui/context-menu"; +import { Button } from "@/components/ui/button"; import { IconProxy } from "@/components/ui/icon-proxy"; import { builtInChangeRequestShortcuts, @@ -41,14 +42,15 @@ export function RejectAction({ return (
- +