Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/web/frontend/docs/ui-design-system.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,16 @@ gap: 14px;
リンクは等幅 ID と ledger-muted タイムスタンプを持つ 3 カラム内 grid です。
- `comment-list` はカード形状を再利用し、各コメントの前に
`border-top` separator を入れます。
- Change request 操作はラベル付きメニューグループとして表示します。
`コメントを入力…` はエディターを開き、設定済みショートカットは指示本文を
即時送信します。将来、短い表示名に長い agent 指示を割り当てられるよう、
ショートカットの表示名と本文は別々の値として扱います。
- 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 を付けます。
Expand Down
10 changes: 10 additions & 0 deletions cmd/web/frontend/docs/ui-design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,16 @@ 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.
- 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.
Expand Down
3 changes: 2 additions & 1 deletion cmd/web/frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ 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 (
<IssuesView
showFilterSortActions={false}
summary={summary}
onAddIssue={onAddIssue}
onRejectIssue={onRejectIssue}
onRejectShortcut={onRejectShortcut}
onStatusChange={onStatusChange}
/>
);
Expand Down
3 changes: 2 additions & 1 deletion cmd/web/frontend/src/app/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -13,6 +13,7 @@ export default function IssuesPage() {
summary={summary}
onAddIssue={onAddIssue}
onRejectIssue={onRejectIssue}
onRejectShortcut={onRejectShortcut}
onStatusChange={onStatusChange}
/>
);
Expand Down
48 changes: 45 additions & 3 deletions cmd/web/frontend/src/components/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { PanelMessage } from "@/components/ui/pannel-message";
import { ToastStack } from "@/components/ui/toast";
import {
createIssue,
createChangeRequest,
deleteProject,
fetchProjects,
fetchSummary,
Expand All @@ -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";
Expand All @@ -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<void>;
onStatusChange: (id: number, status: IssueStatus) => Promise<void>;
};

Expand All @@ -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;
Expand Down Expand Up @@ -123,6 +127,10 @@ function LayoutContent({ children }: { children: ReactNode }) {
const [isDeletingProject, setIsDeletingProject] = useState(false);
const [rejectIssueID, setRejectIssueID] = useState<number | null>(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<string | null>(null);
const [refreshIntervalMs, setRefreshIntervalMs] = useState(
Expand Down Expand Up @@ -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 });
toast.success({ message: t("toast.success.issueRejected") });
void load({ silent: true });
}

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);
Expand Down Expand Up @@ -336,6 +374,7 @@ function LayoutContent({ children }: { children: ReactNode }) {
onSelectIssue: setSelectedIssueID,
onAddIssue: handleAddIssue,
onRejectIssue: handleRejectIssue,
onRejectShortcut: handleRejectShortcut,
onStatusChange: handleStatusChange,
}
: null;
Expand All @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions cmd/web/frontend/src/components/ui/markdown-editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function MarkdownEditor({
initialTab = "raw",
isSaving = false,
labels,
readOnly = false,
rows = 12,
showActions = true,
stablePanelRows,
Expand All @@ -37,6 +38,7 @@ export function MarkdownEditor({
initialTab?: MarkdownEditorTab;
isSaving?: boolean;
labels: MarkdownEditorLabels;
readOnly?: boolean;
rows?: number;
showActions?: boolean;
stablePanelRows?: number;
Expand Down Expand Up @@ -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)}
/>
Expand Down
21 changes: 21 additions & 0 deletions cmd/web/frontend/src/features/issues/change-request-shortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type ChangeRequestVariant = "continue" | "reject";

export type ChangeRequestShortcut = {
id: string;
label: string;
body: string;
};

export const builtInChangeRequestShortcuts = {
continue: [
{ id: "ok", label: "Ok", body: "Ok" },
{ id: "retry", label: "Retry", body: "Retry" },
],
reject: [
{
id: "fix-ci-conflict",
label: "Fix CI & Conflict",
body: "Fix CI & Conflict",
},
],
} satisfies Record<ChangeRequestVariant, readonly ChangeRequestShortcut[]>;
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
type RejectIssueHandler = (id: number) => void;
type RejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise<void>;

const boardActions = [
{ icon: "filter", titleKey: "issues.board.filter" },
Expand All @@ -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();
Expand Down Expand Up @@ -80,6 +84,7 @@ export function IssueBoard({
key={issue.id}
issue={issue}
onRejectIssue={onRejectIssue}
onRejectShortcut={onRejectShortcut}
onStatusChange={onStatusChange}
/>
))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -153,36 +154,14 @@
gap: 6px;
justify-content: center;
line-height: 1;
min-height: 30px;
padding: 8px 12px;
padding: 0 12px;
}

.quickActionButton:hover,
.quickActionButton:focus-visible {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,22 @@ function issueWithCommentCount(commentCount: number): IssueSummary {

function renderCard(props: Partial<Parameters<typeof IssueCard>[0]> = {}) {
const onRejectIssue = vi.fn();
const onRejectShortcut = vi.fn(async () => undefined);
const onStatusChange = vi.fn(async () => undefined);
const rendered = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<IssueCard
issue={issue}
onRejectIssue={onRejectIssue}
onRejectShortcut={onRejectShortcut}
onStatusChange={onStatusChange}
{...props}
/>
</MemoryRouter>
</QueryClientProvider>,
);
return { onRejectIssue, onStatusChange, unmount: rendered.unmount };
return { onRejectIssue, onRejectShortcut, onStatusChange, unmount: rendered.unmount };
}

describe("IssueCard", () => {
Expand Down
18 changes: 10 additions & 8 deletions cmd/web/frontend/src/features/issues/components/card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ 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";
import styles from "./index.module.css";

type IssueStatusChangeHandler = (id: number, status: IssueStatus) => Promise<void>;
type IssueRejectHandler = (id: number) => void;
type IssueRejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise<void>;

type IssueMetric = {
icon: IconProxyName;
Expand All @@ -45,20 +48,22 @@ export function IssueCard({
issue,
onStatusChange,
onRejectIssue,
onRejectShortcut,
readonly = false,
runCount,
}: {
issue: IssueSummary;
onStatusChange: IssueStatusChangeHandler;
onRejectIssue?: IssueRejectHandler;
onRejectShortcut?: IssueRejectShortcutHandler;
readonly?: boolean;
runCount?: number;
}) {
const { t } = useTranslation();
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<HTMLElement>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { isThreadIDLoading, threadID } = useIssueThreadID(issue.id, isMenuOpen);
Expand Down Expand Up @@ -219,13 +224,10 @@ export function IssueCard({
{quickStatusTarget || canReject ? (
<div className={styles.actionGroup}>
{canReject ? (
<button
className={styles.rejectActionButton}
type="button"
onClick={() => onRejectIssue(issue.id)}
>
{t("issues.reject.action")}
</button>
<RejectAction
onOpenDialog={() => onRejectIssue(issue.id)}
onSelectShortcut={(shortcut) => onRejectShortcut(issue.id, shortcut)}
/>
) : null}
{quickStatusTarget ? (
<button
Expand Down
Loading
Loading