From ce4c6a99e9d77f7998c54b1b14294cf79b8dedd4 Mon Sep 17 00:00:00 2001 From: Jiro Date: Sat, 15 Aug 2026 11:49:20 +0900 Subject: [PATCH 1/2] feat(web): refine issue quick actions Unify issue-card and detail actions on shared button variants, add blocked issue resolve actions and mock data, and refine artifact and sidebar presentation. Add component stories and regression coverage for the updated interactions.\n\nVerified with npm test -- --run, npm run typecheck, npm run build, and git diff --check. --- cmd/web/frontend/docs/ui-design-system.ja.md | 31 ++++---- cmd/web/frontend/docs/ui-design-system.md | 41 +++++----- .../layout/sidebar/index.module.css | 2 +- .../src/components/ui/button/index.module.css | 74 +++++++++++++++---- .../components/ui/button/index.stories.tsx | 52 +++++++++++++ .../src/components/ui/button/index.tsx | 27 +++++-- .../issues/components/card/index.module.css | 34 --------- .../issues/components/card/index.test.tsx | 6 ++ .../features/issues/components/card/index.tsx | 22 +++--- .../artifacts-section/index.module.css | 49 ++++++++++-- .../artifacts-section/index.test.tsx | 4 + .../artifacts-section/index.tsx | 20 ++++- .../basic-info-panel/index.module.css | 25 +------ .../basic-info-panel/index.tsx | 44 +++++++---- .../issue-detail-page/index.test.tsx | 23 +++++- .../components/issue-detail-page/index.tsx | 23 ++++++ .../components/reject-action/index.module.css | 14 ++-- .../issues/components/reject-action/index.tsx | 8 +- .../frontend/src/mocks/fixtures/comments.ts | 8 ++ cmd/web/frontend/src/mocks/fixtures/issues.ts | 26 +++++++ 20 files changed, 382 insertions(+), 151 deletions(-) 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..c5be4ae8 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,26 @@ 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" }); + + renderIssueDetail(); + + const resolve = await screen.findByRole("button", { name: "Resolve" }); + expect(resolve).toHaveClass(buttonStyles.secondary, buttonStyles.compact); + + await user.click(resolve); + + expect(await screen.findByRole("dialog", { name: "Continue with comment #42" })).toBeInTheDocument(); }); 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..4227275b 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,23 @@ export function IssueDetailPage() { variant={changeRequestDialog.variant} /> ) : null} + {issueState.kind === "ready" && isResolveDialogOpen ? ( + { + setChangeRequestError(""); + setIsResolveDialogOpen(false); + }} + onMoveIssueReady={() => handleMoveIssueReady("continue")} + onSuccess={() => { + setChangeRequestError(""); + setIsResolveDialogOpen(false); + }} + /> + ) : 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 (
- + Date: Sat, 15 Aug 2026 11:51:43 +0900 Subject: [PATCH 2/2] fix(web): refresh requests after resolving issue Reload change requests when the detail-page Resolve flow succeeds so an already-loaded Comments tab does not remain stale. Extend the blocked quick-action test through submission and refresh.\n\nVerified with npm test -- --run, npm run typecheck, npm run build, and git diff --check. --- .../issue-detail-page/index.test.tsx | 24 ++++++++++++++++++- .../components/issue-detail-page/index.tsx | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) 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 c5be4ae8..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 @@ -159,6 +159,18 @@ describe("IssueDetailPage", () => { 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(); @@ -167,7 +179,17 @@ describe("IssueDetailPage", () => { await user.click(resolve); - expect(await screen.findByRole("dialog", { name: "Continue with comment #42" })).toBeInTheDocument(); + 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 4227275b..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 @@ -578,6 +578,7 @@ export function IssueDetailPage() { onSuccess={() => { setChangeRequestError(""); setIsResolveDialogOpen(false); + void loadChangeRequests(); }} /> ) : null}