From 46e1b137f529616fe4c642862ddbb587e76ea374 Mon Sep 17 00:00:00 2001 From: Jiro Date: Fri, 14 Aug 2026 11:06:25 +0900 Subject: [PATCH 1/3] feat(web): add change request shortcuts Add configurable built-in shortcuts for continue and reject actions. Use a split reject action, preserve free-text dialogs, and recover status-update failures without creating duplicate requests. Cover the new interactions with tests and document the UI pattern. --- cmd/web/frontend/docs/ui-design-system.ja.md | 7 ++ cmd/web/frontend/docs/ui-design-system.md | 7 ++ cmd/web/frontend/src/app/dashboard/page.tsx | 3 +- cmd/web/frontend/src/app/issues/page.tsx | 3 +- .../frontend/src/components/layout/index.tsx | 48 +++++++++- .../issues/change-request-shortcuts.ts | 18 ++++ .../issues/components/board/index.tsx | 5 + .../issues/components/card/index.module.css | 21 ----- .../issues/components/card/index.test.tsx | 4 +- .../features/issues/components/card/index.tsx | 18 ++-- .../change-request-dialog/index.tsx | 14 ++- .../basic-info-panel/index.module.css | 20 ---- .../basic-info-panel/index.tsx | 17 ++-- .../comment-card/index.test.tsx | 14 ++- .../issue-detail-page/comment-card/index.tsx | 21 ++++- .../issue-detail-page/comment-list/index.tsx | 3 +- .../issue-detail-page/index.test.tsx | 31 ++++++- .../components/issue-detail-page/index.tsx | 83 ++++++++++++++--- .../issues/components/issues-view/index.tsx | 5 +- .../issues/components/issues-view/types.ts | 2 + .../components/reject-action/index.module.css | 45 +++++++++ .../reject-action/index.stories.tsx | 16 ++++ .../components/reject-action/index.test.tsx | 39 ++++++++ .../issues/components/reject-action/index.tsx | 91 +++++++++++++++++++ cmd/web/frontend/src/lib/i18n.ts | 8 ++ cmd/web/frontend/src/stories/fixtures.ts | 2 + 26 files changed, 455 insertions(+), 90 deletions(-) create mode 100644 cmd/web/frontend/src/features/issues/change-request-shortcuts.ts create mode 100644 cmd/web/frontend/src/features/issues/components/reject-action/index.module.css create mode 100644 cmd/web/frontend/src/features/issues/components/reject-action/index.stories.tsx create mode 100644 cmd/web/frontend/src/features/issues/components/reject-action/index.test.tsx create mode 100644 cmd/web/frontend/src/features/issues/components/reject-action/index.tsx diff --git a/cmd/web/frontend/docs/ui-design-system.ja.md b/cmd/web/frontend/docs/ui-design-system.ja.md index 4db4a26f..37b40b88 100644 --- a/cmd/web/frontend/docs/ui-design-system.ja.md +++ b/cmd/web/frontend/docs/ui-design-system.ja.md @@ -633,6 +633,13 @@ gap: 14px; リンクは等幅 ID と ledger-muted タイムスタンプを持つ 3 カラム内 grid です。 - `comment-list` はカード形状を再利用し、各コメントの前に `border-top` separator を入れます。 +- Change request 操作はラベル付きメニューグループとして表示します。 + `コメントを入力…` はエディターを開き、設定済みショートカットは指示本文を + 即時送信します。将来、短い表示名に長い agent 指示を割り当てられるよう、 + ショートカットの表示名と本文は別々の値として扱います。 +- Reject 操作は split button を使用します。主ボタンはエディターを開き、 + chevron 側はショートカットメニューを開きます。両方で disabled と送信中の + 状態を共有します。 - `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 6a86243f..73f00c02 100644 --- a/cmd/web/frontend/docs/ui-design-system.md +++ b/cmd/web/frontend/docs/ui-design-system.md @@ -634,6 +634,13 @@ palette: link is a 3-column inner grid with monospace IDs and ledger-muted timestamps. - `comment-list` reuses the card shape and prefixes each comment with a `border-top` separator. +- Change-request actions expose a labelled menu group. `Write a comment…` + 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. - `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/app/dashboard/page.tsx b/cmd/web/frontend/src/app/dashboard/page.tsx index 5b6505df..7200775e 100644 --- a/cmd/web/frontend/src/app/dashboard/page.tsx +++ b/cmd/web/frontend/src/app/dashboard/page.tsx @@ -4,7 +4,7 @@ import { useLayoutData } from "@/components/layout"; import { IssuesView } from "@/features/issues/components/issues-view"; export default function DashboardPage() { - const { summary, onAddIssue, onRejectIssue, onStatusChange } = useLayoutData(); + const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onStatusChange } = useLayoutData(); return ( ); diff --git a/cmd/web/frontend/src/app/issues/page.tsx b/cmd/web/frontend/src/app/issues/page.tsx index aba5d7cb..382d7c31 100644 --- a/cmd/web/frontend/src/app/issues/page.tsx +++ b/cmd/web/frontend/src/app/issues/page.tsx @@ -4,7 +4,7 @@ import { useLayoutData, useLayoutShellData } from "@/components/layout"; import { IssuesView } from "@/features/issues/components/issues-view"; export default function IssuesPage() { - const { summary, onAddIssue, onRejectIssue, onStatusChange } = useLayoutData(); + const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onStatusChange } = useLayoutData(); const { isProjectIssueScope } = useLayoutShellData(); return ( @@ -13,6 +13,7 @@ export default function IssuesPage() { summary={summary} onAddIssue={onAddIssue} onRejectIssue={onRejectIssue} + onRejectShortcut={onRejectShortcut} onStatusChange={onStatusChange} /> ); diff --git a/cmd/web/frontend/src/components/layout/index.tsx b/cmd/web/frontend/src/components/layout/index.tsx index 786f2382..4d88e063 100644 --- a/cmd/web/frontend/src/components/layout/index.tsx +++ b/cmd/web/frontend/src/components/layout/index.tsx @@ -15,6 +15,7 @@ import { PanelMessage } from "@/components/ui/pannel-message"; import { ToastStack } from "@/components/ui/toast"; import { createIssue, + createChangeRequest, deleteProject, fetchProjects, fetchSummary, @@ -35,6 +36,7 @@ import { AddIssueDialog } from "@/components/dialog/add-issue"; import { AddProjectDialog } from "@/components/dialog/add-project"; import { DeleteProjectDialog } from "@/components/dialog/delete-project"; import { ChangeRequestDialog } from "@/features/issues/components/change-request-dialog"; +import type { ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; import { Header } from "./header"; import { Sidebar } from "./sidebar"; import { useTheme } from "./use-theme"; @@ -59,6 +61,7 @@ export type LayoutData = { onSelectIssue: (issueID: number) => void; onAddIssue: (status?: IssueStatus) => void; onRejectIssue: (issueID: number) => void; + onRejectShortcut: (issueID: number, shortcut: ChangeRequestShortcut) => Promise; onStatusChange: (id: number, status: IssueStatus) => Promise; }; @@ -78,6 +81,7 @@ export type LayoutShellData = { projects: Project[]; rejectIssue: IssueSummary | null; rejectIssueError: string; + rejectRequestRecovery: { body: string; requestCreated: boolean }; summary: Summary | null; title: string | null; onIssueDetailTitleChange: (title: string | null) => void; @@ -123,6 +127,10 @@ function LayoutContent({ children }: { children: ReactNode }) { const [isDeletingProject, setIsDeletingProject] = useState(false); const [rejectIssueID, setRejectIssueID] = useState(null); const [rejectIssueError, setRejectIssueError] = useState(""); + const [rejectRequestRecovery, setRejectRequestRecovery] = useState<{ + body: string; + requestCreated: boolean; + }>({ body: "", requestCreated: false }); const [isMovingRejectedIssue, setIsMovingRejectedIssue] = useState(false); const [issueDetailTitleOverride, setIssueDetailTitleOverride] = useState(null); const [refreshIntervalMs, setRefreshIntervalMs] = useState( @@ -257,17 +265,47 @@ function LayoutContent({ children }: { children: ReactNode }) { function handleRejectIssue(issueID: number) { setRejectIssueID(issueID); setRejectIssueError(""); + setRejectRequestRecovery({ body: "", requestCreated: false }); modal.openModal(modalIDs.rejectIssue); } + async function handleRejectShortcut(issueID: number, shortcut: ChangeRequestShortcut) { + try { + await createChangeRequest(issueID, { + author: "reviewer", + body: shortcut.body, + }, { silent: true }); + } catch (error) { + toast.error({ + message: error instanceof Error ? error.message : t("issues.reject.errors.submitFailed"), + }); + return; + } + + try { + await moveRejectedIssueReady(issueID); + } catch (error) { + setRejectIssueID(issueID); + setRejectRequestRecovery({ body: shortcut.body, requestCreated: true }); + setRejectIssueError( + error instanceof Error ? error.message : t("layout.failedToRejectIssue"), + ); + modal.openModal(modalIDs.rejectIssue); + } + } + + async function moveRejectedIssueReady(issueID: number) { + await updateIssueStatus(issueID, "ready", { silent: true }); + await load({ silent: true }); + toast.success({ message: t("toast.success.issueRejected") }); + } + async function handleMoveRejectedIssueReady() { if (rejectIssueID === null) return; setIsMovingRejectedIssue(true); setRejectIssueError(""); try { - await updateIssueStatus(rejectIssueID, "ready", { silent: true }); - await load({ silent: true }); - toast.success({ message: t("toast.success.issueRejected") }); + await moveRejectedIssueReady(rejectIssueID); } catch (error) { const message = error instanceof Error ? error.message : t("layout.failedToRejectIssue"); setRejectIssueError(message); @@ -336,6 +374,7 @@ function LayoutContent({ children }: { children: ReactNode }) { onSelectIssue: setSelectedIssueID, onAddIssue: handleAddIssue, onRejectIssue: handleRejectIssue, + onRejectShortcut: handleRejectShortcut, onStatusChange: handleStatusChange, } : null; @@ -356,6 +395,7 @@ function LayoutContent({ children }: { children: ReactNode }) { projects, rejectIssue: issues.find((issue) => issue.id === rejectIssueID) ?? null, rejectIssueError, + rejectRequestRecovery, summary, title: issueDetailTitleOverride ?? issueDetailTitle ?? issueScopeTitle( issueScope, @@ -508,6 +548,8 @@ function LayoutModalContent({ shellData }: { shellData: LayoutShellData }) { isMovingIssue={shellData.isMovingRejectedIssue} issueID={shellData.rejectIssue.id} issueTitle={shellData.rejectIssue.title} + initialBody={shellData.rejectRequestRecovery.body} + initialRequestCreated={shellData.rejectRequestRecovery.requestCreated} onCancel={shellData.onCloseModal} onMoveIssueReady={shellData.onMoveRejectedIssueReady} onSuccess={shellData.onCloseModal} diff --git a/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts b/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts new file mode 100644 index 00000000..bcc417a4 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts @@ -0,0 +1,18 @@ +export type ChangeRequestVariant = "continue" | "reject"; + +export type ChangeRequestShortcut = { + id: string; + label: string; + body: string; +}; + +export const builtInChangeRequestShortcuts = { + continue: [{ id: "ok", label: "Ok", body: "Ok" }], + reject: [ + { + id: "fix-ci-conflict", + label: "Fix CI & Conflict", + body: "Fix CI & Conflict", + }, + ], +} satisfies Record; diff --git a/cmd/web/frontend/src/features/issues/components/board/index.tsx b/cmd/web/frontend/src/features/issues/components/board/index.tsx index 04a849df..c29e14b7 100644 --- a/cmd/web/frontend/src/features/issues/components/board/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/board/index.tsx @@ -2,11 +2,13 @@ import { useTranslation } from "react-i18next"; import { IssueCard } from "@/features/issues/components/card"; import { IconProxy, type IconProxyName } from "@/components/ui/icon-proxy"; import type { IssueStatus, Summary } from "@/lib/types"; +import type { ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; import { boardColumns } from "./board-columns"; import styles from "./index.module.css"; type StatusChangeHandler = (id: number, status: IssueStatus) => Promise; type RejectIssueHandler = (id: number) => void; +type RejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise; const boardActions = [ { icon: "filter", titleKey: "issues.board.filter" }, @@ -19,12 +21,14 @@ export function IssueBoard({ summary, onAddIssue, onRejectIssue, + onRejectShortcut, onStatusChange, }: { showFilterSortActions?: boolean; summary: Summary; onAddIssue: (status?: IssueStatus) => void; onRejectIssue?: RejectIssueHandler; + onRejectShortcut?: RejectShortcutHandler; onStatusChange: StatusChangeHandler; }) { const { t } = useTranslation(); @@ -80,6 +84,7 @@ export function IssueBoard({ key={issue.id} issue={issue} onRejectIssue={onRejectIssue} + onRejectShortcut={onRejectShortcut} onStatusChange={onStatusChange} /> )) 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 a602c981..d175489a 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 @@ -162,27 +162,6 @@ opacity: 0.9; } -.rejectActionButton { - align-items: center; - background: var(--surface); - border: 1px solid var(--danger-border, #d4a1a1); - border-radius: var(--radius-sm); - color: var(--danger-text, #9f1d1d); - display: inline-flex; - flex-shrink: 0; - font-size: 13px; - font-weight: 500; - justify-content: center; - line-height: 1; - min-height: 30px; - padding: 8px 12px; -} - -.rejectActionButton:hover, -.rejectActionButton:focus-visible { - background: var(--danger-surface, #fff3f3); -} - .quickAction-ready { --quick-action-bg: var(--status-ready-text); --quick-action-border: transparent; 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 7ba1ef0c..15cb732f 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 @@ -47,6 +47,7 @@ function issueWithCommentCount(commentCount: number): IssueSummary { function renderCard(props: Partial[0]> = {}) { const onRejectIssue = vi.fn(); + const onRejectShortcut = vi.fn(async () => undefined); const onStatusChange = vi.fn(async () => undefined); const rendered = render( @@ -54,13 +55,14 @@ function renderCard(props: Partial[0]> = {}) { , ); - return { onRejectIssue, onStatusChange, unmount: rendered.unmount }; + return { onRejectIssue, onRejectShortcut, onStatusChange, unmount: rendered.unmount }; } describe("IssueCard", () => { 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 d0d965dc..8ebf535b 100644 --- a/cmd/web/frontend/src/features/issues/components/card/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/card/index.tsx @@ -14,6 +14,8 @@ import { toast } from "@/lib/toast"; import { PriorityBadge } from "@/features/issues/components/priority-badge"; import { ProjectBadge } from "@/features/issues/components/project-badge"; import { StatusBadge } from "@/features/issues/components/status-badge"; +import { RejectAction } from "@/features/issues/components/reject-action"; +import type { ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; import { useIssueThreadID } from "@/features/issues/hooks/use-issue-thread-id"; import { pullRequestArtifact } from "@/features/issues/artifacts"; import { PendingBadge } from "./pending-badge"; @@ -21,6 +23,7 @@ import styles from "./index.module.css"; type IssueStatusChangeHandler = (id: number, status: IssueStatus) => Promise; type IssueRejectHandler = (id: number) => void; +type IssueRejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise; type IssueMetric = { icon: IconProxyName; @@ -45,12 +48,14 @@ export function IssueCard({ issue, onStatusChange, onRejectIssue, + onRejectShortcut, readonly = false, runCount, }: { issue: IssueSummary; onStatusChange: IssueStatusChangeHandler; onRejectIssue?: IssueRejectHandler; + onRejectShortcut?: IssueRejectShortcutHandler; readonly?: boolean; runCount?: number; }) { @@ -58,7 +63,7 @@ export function IssueCard({ const statusOptions = statusOptionsFor(issue.status); const canChangeStatus = !readonly && statusOptions.length > 1; const quickStatusTarget = readonly ? undefined : quickStatusTargets[issue.status]; - const canReject = !readonly && issue.status === "review" && onRejectIssue !== undefined; + const canReject = !readonly && issue.status === "review" && onRejectIssue !== undefined && onRejectShortcut !== undefined; const cardRef = useRef(null); const [isMenuOpen, setIsMenuOpen] = useState(false); const { isThreadIDLoading, threadID } = useIssueThreadID(issue.id, isMenuOpen); @@ -219,13 +224,10 @@ export function IssueCard({ {quickStatusTarget || canReject ? (
{canReject ? ( - + onRejectIssue(issue.id)} + onSelectShortcut={(shortcut) => onRejectShortcut(issue.id, shortcut)} + /> ) : null} {quickStatusTarget ? ( + onOpenDialog={onRejectIssue} + onSelectShortcut={onRejectShortcut} + />
) : null} diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx index ff95538f..bc3c7947 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx @@ -25,11 +25,23 @@ describe("CommentCard", () => { await user.click(screen.getByRole("button", { name: "Comment actions for codex" })); const menu = screen.getByRole("menu", { name: "Comment actions for codex" }); - const action = within(menu).getByRole("menuitem", { name: "Continue with comment" }); + const action = within(menu).getByRole("menuitem", { name: "Write a comment…" }); expect(action.querySelector(".lucide-arrow-right")).toBeInTheDocument(); await user.click(action); expect(onContinueWithComment).toHaveBeenCalledOnce(); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); + + it("selects the Ok shortcut with its separate label and body", async () => { + const user = userEvent.setup(); + const onContinueWithComment = vi.fn(); + + render(); + + await user.click(screen.getByRole("button", { name: "Comment actions for codex" })); + await user.click(screen.getByRole("menuitem", { name: "Ok" })); + + expect(onContinueWithComment).toHaveBeenCalledWith({ id: "ok", label: "Ok", body: "Ok" }); + }); }); diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx index 33f5f65d..e4544a4d 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx @@ -1,9 +1,10 @@ import { useId, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuGroupLabel, ContextMenuItem } from "@/components/ui/context-menu"; import { IconProxy } from "@/components/ui/icon-proxy"; import { Markdown } from "@/components/ui/markdown"; import { CommentTypeBadge } from "@/features/issues/components/comment-type-badge"; +import { builtInChangeRequestShortcuts, type ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; import type { Comment } from "@/lib/types"; import { formatDateTime } from "../format"; import styles from "./index.module.css"; @@ -11,9 +12,11 @@ import styles from "./index.module.css"; export function CommentCard({ comment, onContinueWithComment, + shortcuts = builtInChangeRequestShortcuts.continue, }: { comment: Comment; - onContinueWithComment?: () => void; + onContinueWithComment?: (shortcut?: ChangeRequestShortcut) => void; + shortcuts?: readonly ChangeRequestShortcut[]; }) { const { t } = useTranslation(); const menuID = useId(); @@ -47,6 +50,7 @@ export function CommentCard({ )} > + {t("issues.continueWithComment.action")} } onSelect={() => { @@ -54,8 +58,19 @@ export function CommentCard({ onContinueWithComment(); }} > - {t("issues.continueWithComment.action")} + {t("issues.changeRequest.writeComment")} + {shortcuts.map((shortcut) => ( + { + setIsMenuOpen(false); + onContinueWithComment(shortcut); + }} + > + {shortcut.label} + + ))} ) : ( + ( + + )} + > + {t("issues.reject.action")} + { + setIsMenuOpen(false); + onOpenDialog(); + }} + > + {t("issues.changeRequest.writeComment")} + + {shortcuts.map((shortcut) => ( + void handleShortcut(shortcut)} + > + {shortcut.label} + + ))} + + + ); +} diff --git a/cmd/web/frontend/src/lib/i18n.ts b/cmd/web/frontend/src/lib/i18n.ts index b93dc9b5..116520ec 100644 --- a/cmd/web/frontend/src/lib/i18n.ts +++ b/cmd/web/frontend/src/lib/i18n.ts @@ -178,6 +178,7 @@ const resources = { }, reject: { action: "Reject", + shortcutMenu: "Reject の定型文", cancel: "キャンセル", close: "閉じる", emptyRequest: "リクエスト本文なし", @@ -213,6 +214,9 @@ const resources = { submit: "コメントをつけて継続", title: "コメントをつけて継続 #{{id}}", }, + changeRequest: { + writeComment: "コメントを入力…", + }, detail: { issueStatus: "Issue ステータス", openDetail: "詳細ページを開く", @@ -774,6 +778,7 @@ const resources = { }, reject: { action: "Reject", + shortcutMenu: "Reject shortcuts", cancel: "Cancel", close: "Close", emptyRequest: "No request body", @@ -809,6 +814,9 @@ const resources = { submit: "Continue with comment", title: "Continue with comment #{{id}}", }, + changeRequest: { + writeComment: "Write a comment…", + }, detail: { issueStatus: "Issue Status", openDetail: "Open detail page", diff --git a/cmd/web/frontend/src/stories/fixtures.ts b/cmd/web/frontend/src/stories/fixtures.ts index 0d54f1cf..56a645e8 100644 --- a/cmd/web/frontend/src/stories/fixtures.ts +++ b/cmd/web/frontend/src/stories/fixtures.ts @@ -108,6 +108,7 @@ export const storyShellData: LayoutShellData = { onSelectIssue: noop, onAddIssue: noop, onRejectIssue: noop, + onRejectShortcut: asyncNoop, onStatusChange: asyncNoop, }, loadState: { @@ -118,6 +119,7 @@ export const storyShellData: LayoutShellData = { projects: projectFixtures, rejectIssue: null, rejectIssueError: "", + rejectRequestRecovery: { body: "", requestCreated: false }, summary: storySummary, title: "Tasq", onIssueDetailTitleChange: noop, From aa09e82bbfac7b6e571a562649930c3a835ef467 Mon Sep 17 00:00:00 2001 From: Jiro Date: Fri, 14 Aug 2026 11:14:45 +0900 Subject: [PATCH 2/3] feat(web): expand issue quick actions Add Retry and status-specific sidebar quick actions, align Reject styling with card actions, and prevent duplicate continue submissions. Separate refresh failures from status recovery and keep submitted request bodies read-only during retry. --- cmd/web/frontend/docs/ui-design-system.ja.md | 3 ++ cmd/web/frontend/docs/ui-design-system.md | 3 ++ .../frontend/src/components/layout/index.tsx | 2 +- .../components/ui/markdown-editor/index.tsx | 3 ++ .../issues/change-request-shortcuts.ts | 5 ++- .../change-request-dialog/index.tsx | 1 + .../basic-info-panel/index.module.css | 40 +++++++++++++++++- .../basic-info-panel/index.tsx | 41 +++++++++++++++---- .../comment-card/index.stories.tsx | 2 +- .../comment-card/index.test.tsx | 22 +++++++++- .../issue-detail-page/comment-card/index.tsx | 23 ++++++++--- .../issue-detail-page/comment-list/index.tsx | 2 +- .../issue-detail-page/index.test.tsx | 31 ++++++++++++++ .../components/issue-detail-page/index.tsx | 11 ++--- .../components/reject-action/index.module.css | 17 ++++---- cmd/web/frontend/src/lib/i18n.ts | 2 + 16 files changed, 175 insertions(+), 33 deletions(-) diff --git a/cmd/web/frontend/docs/ui-design-system.ja.md b/cmd/web/frontend/docs/ui-design-system.ja.md index 37b40b88..f14cf6a7 100644 --- a/cmd/web/frontend/docs/ui-design-system.ja.md +++ b/cmd/web/frontend/docs/ui-design-system.ja.md @@ -640,6 +640,9 @@ gap: 14px; - Reject 操作は split button を使用します。主ボタンはエディターを開き、 chevron 側はショートカットメニューを開きます。両方で disabled と送信中の 状態を共有します。 +- Issue 詳細 sidebar の status shortcut には `Quick Action` の見出しを付けます。 + backlog では `Ready`、ready では `Draft`、review では `Done` と赤色の Reject + split button を表示します。 - `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 73f00c02..71170430 100644 --- a/cmd/web/frontend/docs/ui-design-system.md +++ b/cmd/web/frontend/docs/ui-design-system.md @@ -641,6 +641,9 @@ palette: - 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. +- 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. - `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/index.tsx b/cmd/web/frontend/src/components/layout/index.tsx index 4d88e063..c96b7585 100644 --- a/cmd/web/frontend/src/components/layout/index.tsx +++ b/cmd/web/frontend/src/components/layout/index.tsx @@ -296,8 +296,8 @@ function LayoutContent({ children }: { children: ReactNode }) { async function moveRejectedIssueReady(issueID: number) { await updateIssueStatus(issueID, "ready", { silent: true }); - await load({ silent: true }); toast.success({ message: t("toast.success.issueRejected") }); + void load({ silent: true }); } async function handleMoveRejectedIssueReady() { diff --git a/cmd/web/frontend/src/components/ui/markdown-editor/index.tsx b/cmd/web/frontend/src/components/ui/markdown-editor/index.tsx index 5eddb5fd..9e96f7fc 100644 --- a/cmd/web/frontend/src/components/ui/markdown-editor/index.tsx +++ b/cmd/web/frontend/src/components/ui/markdown-editor/index.tsx @@ -23,6 +23,7 @@ export function MarkdownEditor({ initialTab = "raw", isSaving = false, labels, + readOnly = false, rows = 12, showActions = true, stablePanelRows, @@ -37,6 +38,7 @@ export function MarkdownEditor({ initialTab?: MarkdownEditorTab; isSaving?: boolean; labels: MarkdownEditorLabels; + readOnly?: boolean; rows?: number; showActions?: boolean; stablePanelRows?: number; @@ -176,6 +178,7 @@ export function MarkdownEditor({ aria-label={labels.textarea} className={styles.textarea} rows={rows} + readOnly={readOnly} value={draft} onChange={(event) => handleDraftChange(event.target.value)} /> diff --git a/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts b/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts index bcc417a4..b4d92b78 100644 --- a/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts +++ b/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts @@ -7,7 +7,10 @@ export type ChangeRequestShortcut = { }; export const builtInChangeRequestShortcuts = { - continue: [{ id: "ok", label: "Ok", body: "Ok" }], + continue: [ + { id: "ok", label: "Ok", body: "Ok" }, + { id: "retry", label: "Retry", body: "Retry" }, + ], reject: [ { id: "fix-ci-conflict", diff --git a/cmd/web/frontend/src/features/issues/components/change-request-dialog/index.tsx b/cmd/web/frontend/src/features/issues/components/change-request-dialog/index.tsx index 548aeea7..daaf290d 100644 --- a/cmd/web/frontend/src/features/issues/components/change-request-dialog/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/change-request-dialog/index.tsx @@ -119,6 +119,7 @@ export function ChangeRequestDialog({ saving: t("markdownEditor.saving"), textarea: t(`${translationKey}.fields.body`), }} + readOnly={hasCreatedRequest} showActions={false} stablePanelRows={12} value={body} 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 6065d1a6..14c3b372 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 @@ -8,11 +8,47 @@ padding: 18px; } -.panelActions { +.quickActions { border-top: 1px solid var(--border); + display: grid; + gap: var(--space-3); + padding-top: var(--space-4); +} + +.quickActions h3 { + color: var(--muted); + font-size: 12px; + font-weight: 700; + margin: 0; + text-transform: uppercase; +} + +.panelActions { display: flex; + flex-wrap: wrap; + gap: var(--space-2); justify-content: flex-end; - padding-top: var(--space-4); +} + +.quickActionButton { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--primary-black); + font-size: 13px; + font-weight: 500; + min-block-size: 30px; + padding: 8px 12px; +} + +.quickActionButton:hover:not(:disabled), +.quickActionButton:focus-visible:not(:disabled) { + background: var(--surface-hover); +} + +.quickActionButton:disabled { + cursor: not-allowed; + opacity: 0.6; } .metaGrid { 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 305afc36..78b541ee 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 @@ -30,6 +30,8 @@ export function BasicInfoPanel({ }) { const { t } = useTranslation(); const dependencyIssues = dependencyIssueLinks(issue.dependency_ids, issueOptions); + const quickStatusAction = quickStatusActionFor(issue.status); + const canReject = issue.status === "review" && onRejectIssue && onRejectShortcut; return (
@@ -67,19 +69,44 @@ export function BasicInfoPanel({ - {issue.status === "review" && onRejectIssue && onRejectShortcut ? ( -
- + {quickStatusAction || canReject ? ( +
+

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

+
+ {quickStatusAction ? ( + + ) : null} + {canReject ? ( + + ) : null} +
) : null}
); } +function quickStatusActionFor(status: IssueStatus): { + status: IssueStatus; + labelKey: "statuses.ready" | "issues.board.draft" | "statuses.done"; +} | 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" }; + return null; +} + function dependencyIssueLinks( dependencyIDs: number[], issueOptions: IssueSummary[], diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.stories.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.stories.tsx index 75d478e0..c57d2485 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.stories.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.stories.tsx @@ -22,6 +22,6 @@ export const WithContinueAction: Story = { ...storyComments[0], type: "blocker", }, - onContinueWithComment: () => undefined, + onContinueWithComment: async () => undefined, }, }; diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx index bc3c7947..47d1437a 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import "@/lib/i18n"; @@ -40,8 +40,28 @@ describe("CommentCard", () => { render(); await user.click(screen.getByRole("button", { name: "Comment actions for codex" })); + expect(screen.getByRole("menuitem", { name: "Retry" })).toBeVisible(); await user.click(screen.getByRole("menuitem", { name: "Ok" })); expect(onContinueWithComment).toHaveBeenCalledWith({ id: "ok", label: "Ok", body: "Ok" }); }); + + it("prevents another shortcut selection while submitting", async () => { + const user = userEvent.setup(); + let resolveSubmission: () => void = () => undefined; + const onContinueWithComment = vi.fn( + () => new Promise((resolve) => { resolveSubmission = resolve; }), + ); + + render(); + + const trigger = screen.getByRole("button", { name: "Comment actions for codex" }); + await user.click(trigger); + await user.click(screen.getByRole("menuitem", { name: "Ok" })); + + expect(trigger).toBeDisabled(); + expect(onContinueWithComment).toHaveBeenCalledTimes(1); + resolveSubmission(); + await waitFor(() => expect(trigger).toBeEnabled()); + }); }); diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx index e4544a4d..7d094983 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx @@ -15,14 +15,25 @@ export function CommentCard({ shortcuts = builtInChangeRequestShortcuts.continue, }: { comment: Comment; - onContinueWithComment?: (shortcut?: ChangeRequestShortcut) => void; + onContinueWithComment?: (shortcut?: ChangeRequestShortcut) => Promise; shortcuts?: readonly ChangeRequestShortcut[]; }) { const { t } = useTranslation(); const menuID = useId(); const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const menuLabel = t("issues.detailPage.commentActions", { author: comment.author }); + async function handleShortcut(shortcut: ChangeRequestShortcut) { + setIsMenuOpen(false); + setIsSubmitting(true); + try { + await onContinueWithComment?.(shortcut); + } finally { + setIsSubmitting(false); + } + } + return (
@@ -43,6 +54,7 @@ export function CommentCard({ {...triggerProps} aria-label={menuLabel} className={styles.menuButton} + disabled={isSubmitting} title={menuLabel} type="button" > @@ -52,10 +64,11 @@ export function CommentCard({ > {t("issues.continueWithComment.action")} } onSelect={() => { setIsMenuOpen(false); - onContinueWithComment(); + void onContinueWithComment(); }} > {t("issues.changeRequest.writeComment")} @@ -63,10 +76,8 @@ export function CommentCard({ {shortcuts.map((shortcut) => ( { - setIsMenuOpen(false); - onContinueWithComment(shortcut); - }} + disabled={isSubmitting} + onSelect={() => void handleShortcut(shortcut)} > {shortcut.label} diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-list/index.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-list/index.tsx index 56d9ac81..1db234ff 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-list/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-list/index.tsx @@ -19,7 +19,7 @@ export function CommentList({ isLoading: boolean; latestActionableBlockerCommentID?: number; onLoadMore: () => void; - onContinueWithComment?: (shortcut?: ChangeRequestShortcut) => void; + onContinueWithComment?: (shortcut?: ChangeRequestShortcut) => Promise; }) { const { t } = useTranslation(); 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 923de181..7679ccdf 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 @@ -120,6 +120,37 @@ describe("IssueDetailPage", () => { expect(screen.getByRole("button", { name: "Change status" })).toHaveTextContent("in_progress"); }); + it("shows status-specific quick actions in the sidebar", async () => { + const user = userEvent.setup(); + api.updateIssueStatus.mockResolvedValueOnce({ ...issue, status: "backlog" }); + + renderIssueDetail(); + + expect(await screen.findByRole("heading", { name: "Quick Action" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Draft" })); + expect(api.updateIssueStatus).toHaveBeenCalledWith(42, "backlog"); + }); + + it("shows Ready as the backlog quick action", async () => { + const user = userEvent.setup(); + api.fetchIssue.mockResolvedValueOnce({ ...issue, status: "backlog" }); + api.updateIssueStatus.mockResolvedValueOnce({ ...issue, status: "ready" }); + + renderIssueDetail(); + + await user.click(await screen.findByRole("button", { name: "Ready" })); + expect(api.updateIssueStatus).toHaveBeenCalledWith(42, "ready"); + }); + + it("shows Done and Reject quick actions for review issues", async () => { + api.fetchIssue.mockResolvedValueOnce({ ...issue, status: "review" }); + + renderIssueDetail(); + + expect(await screen.findByRole("button", { name: "Done" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument(); + }); + it("rejects a review issue with a change request", async () => { const user = userEvent.setup(); api.fetchIssue.mockResolvedValueOnce({ ...issue, status: "review" }); 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 97b37adf..37c10675 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 @@ -391,15 +391,16 @@ export function IssueDetailPage() { try { await handleMoveIssueReady(variant); - if (variant === "continue") { - await loadChangeRequests(); - } } catch { setChangeRequestDialog({ variant, initialBody: shortcut.body, initialRequestCreated: true, }); + return; + } + if (variant === "continue") { + await loadChangeRequests(); } } @@ -507,10 +508,10 @@ export function IssueDetailPage() { : undefined } onLoadMore={() => void loadComments(nextCursor ?? undefined)} - onContinueWithComment={(shortcut) => { + onContinueWithComment={async (shortcut) => { setChangeRequestError(""); if (shortcut) { - void handleChangeRequestShortcut("continue", shortcut); + await handleChangeRequestShortcut("continue", shortcut); return; } setChangeRequestDialog({ variant: "continue" }); 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 bbe0c164..f404ba5b 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 @@ -4,16 +4,17 @@ .primaryButton, .menuButton { - background: var(--surface); - border: 1px solid var(--danger-border, #d4a1a1); - color: var(--danger-text, #9f1d1d); - font-weight: 600; - min-block-size: 38px; + background: var(--danger); + border: 1px solid var(--danger); + color: var(--white); + font-size: 13px; + font-weight: 500; + min-block-size: 30px; } .primaryButton { border-radius: var(--radius-sm) 0 0 var(--radius-sm); - padding: var(--space-0) var(--space-3-5); + padding: 8px 12px; } .menuButton { @@ -22,14 +23,14 @@ border-radius: 0 var(--radius-sm) var(--radius-sm) 0; display: inline-flex; justify-content: center; - padding: var(--space-0) var(--space-2-5); + padding: 8px var(--space-2-5); } .primaryButton:hover:not(:disabled), .primaryButton:focus-visible:not(:disabled), .menuButton:hover:not(:disabled), .menuButton:focus-visible:not(:disabled) { - background: var(--danger-surface, #fff3f3); + background: var(--danger-text, #9f1d1d); } .primaryButton:focus-visible, diff --git a/cmd/web/frontend/src/lib/i18n.ts b/cmd/web/frontend/src/lib/i18n.ts index 116520ec..82e6d4bc 100644 --- a/cmd/web/frontend/src/lib/i18n.ts +++ b/cmd/web/frontend/src/lib/i18n.ts @@ -226,6 +226,7 @@ const resources = { workspace: "Workspace", }, detailPage: { + quickActions: "Quick Action", backToIssue: "Issue に戻る", backToList: "一覧に戻る", approvalRequest: "承認リクエスト", @@ -826,6 +827,7 @@ const resources = { workspace: "Workspace", }, detailPage: { + quickActions: "Quick Action", backToIssue: "Back to issue", backToList: "Back to list", approvalRequest: "Approval request", From 433f4c496d7670d98bdf558cd38f4170ad07adf1 Mon Sep 17 00:00:00 2001 From: Jiro Date: Fri, 14 Aug 2026 11:39:55 +0900 Subject: [PATCH 3/3] fix(web): align quick action heights Give card, sidebar, and Reject split-button actions the same fixed control height and horizontal-only padding so text and icon segments align consistently. --- .../issues/components/card/index.module.css | 4 ++-- .../basic-info-panel/index.module.css | 5 +++-- .../components/reject-action/index.module.css | 13 +++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) 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 d175489a..38a451a9 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 @@ -142,6 +142,7 @@ .quickActionButton { align-items: center; + block-size: 32px; background: var(--quick-action-bg); border: 1px solid var(--quick-action-border); border-radius: var(--radius-sm); @@ -153,8 +154,7 @@ gap: 6px; justify-content: center; line-height: 1; - min-height: 30px; - padding: 8px 12px; + padding: 0 12px; } .quickActionButton:hover, 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 14c3b372..51b7344c 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 @@ -31,14 +31,15 @@ } .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; - min-block-size: 30px; - padding: 8px 12px; + line-height: 1; + padding: 0 12px; } .quickActionButton:hover:not(:disabled), 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 f404ba5b..53fee545 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 @@ -4,26 +4,27 @@ .primaryButton, .menuButton { + align-items: center; + block-size: 32px; background: var(--danger); border: 1px solid var(--danger); color: var(--white); + display: inline-flex; font-size: 13px; font-weight: 500; - min-block-size: 30px; + justify-content: center; + line-height: 1; } .primaryButton { border-radius: var(--radius-sm) 0 0 var(--radius-sm); - padding: 8px 12px; + padding: 0 12px; } .menuButton { - align-items: center; border-left: 0; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; - display: inline-flex; - justify-content: center; - padding: 8px var(--space-2-5); + padding: 0 var(--space-2-5); } .primaryButton:hover:not(:disabled),