PAM · SUS round 4

Round 4: the remaining frontend fixes.

Four frontend-only fixes on main after MR !395 merged, kept separate from the round 3 MR. One commit each, re-tested. Every fix shows the problem, a recording of main next to the fixed build, and the code that caused it.

4fixed on this branch
0frontend fixes queued next
4earlier rounds
16need backend or model work, not touched

Next, easiest first

  • Nothing queued: every frontend-only gap is done, parked for a decision, or needs the backend.

Parked: needs a product decision

  • G16Folding the chat's tool steps: CB-SESS-043 requires every step to stay visible. Product decision.
  • G14bThe BETA badge on Workflow stays: a test pins it deliberately. Only the description changed.
  • G16One name for Keep / Like ("Giữ" vs "Thích"). Product naming decision.
  • G1bRenaming "Mở trong trình chỉnh sửa" to name area editing: CB-CANVAS-005 pins the label.

Verified and earlier rounds

  • MR !400These 4 fixes, opened 23/10 for review
  • Round 110 fixes, merged into main through MR !395
  • Round 37 quick wins, in review as MR !399 (separate branch)
  • G7Verified on main, no change needed: choosing PNG gives a real PNG on every download path.

Backend / model, out of scope

G3G4G9G13G26G32G33G7G1bG25G27G28G29G31G24M1–M11
G14a
Production · severity 6/12

The model picker says what it is

01
Problem
Không biết PAM có chọn model; người dùng sang Google Flow

The composer chip showed two icons and "Tự động · Tự động". Nothing on it said it chooses the model, so testers went to other tools to pick one.

Fix

The chip starts with "Mô hình", and its tooltip says what Tự động means: the app picks a model for each request.

Before main today
After feat/rise-fe-round4
Codeproblem fix
src/features/artifact/components/studio/ComposerModelSelect.tsx
line 102
}
>
{/* Names the control: two icons and "Auto" alone never said a model can be chosen,
so testers went to other tools for that (RISE G14a). */}
<span aria-hidden className="text-ink/55 @max-[520px]:hidden">
{t("chipLabel")}
</span>
{MEDIA.map(({ medium, Icon }, index) => (
<span key={medium} className="flex items-center gap-2">
src/i18n/locales/vi.json
line 1098
"modelSelect": {
"auto": "Tự động",
"hint": "Các mô hình mà cuộc trò chuyện này dùng để tạo",
"hint": "Chọn mô hình tạo ảnh và video. Tự động: ứng dụng tự chọn cho từng yêu cầu.",
"media": {
"image": {
line 1131
"grok-imagine-image-2_0": "Dải kích thước rộng nhất · tối đa 3 ảnh tham chiếu",
"gemini-nano-banana-2_1-4k": "Ảnh 4K · tạo từ văn bản & sửa theo ảnh tham chiếu"
}
},
"chipLabel": "Mô hình"
},
"plan": {
commit a0c02ded3 source files1 tests updatedEnglish copy updated too
G16
Production · severity 5/12

Plain words for the "candidate" step

02
Problem
Nhiều lớp hiển thị trung gian gây rối

The chat's step list says "Đã đăng ký làm ứng viên" (registered as a candidate), internal vocabulary for results offered to pick from.

Fix

It reads "Đã đưa 2 kết quả ra để bạn chọn". Folding the step list away was not done: a product contract (CB-SESS-043) keeps every step visible. Recorded on a stored demo chat whose agent offered 2 results.

Before main today
After feat/rise-fe-round4
Codeproblem fix
src/i18n/locales/vi.json
line 494
},
"finalize": {
"candidatesDone": "{count, plural, =1 {Đã đăng ký làm ứng viên} other {Đã đăng ký # mục làm ứng viên}}",
"candidatesRunning": "{count, plural, =1 {Đang đăng ký làm ứng viên} other {Đang đăng ký # mục làm ứng viên}}",
"candidatesDone": "{count, plural, =1 {Đã đưa ra để bạn chọn} other {Đã đưa # kết quả ra để bạn chọn}}",
"candidatesRunning": "{count, plural, =1 {Đang đưa ra để bạn chọn} other {Đang đưa # kết quả ra để bạn chọn}}",
"libraryDone": "{count, plural, =1 {Đã thêm vào thư viện} other {Đã thêm # mục vào thư viện}}",
"libraryRunning": "{count, plural, =1 {Đang thêm vào thư viện} other {Đang thêm # mục vào thư viện}}"
commit 1a9636fa2 source files1 tests updatedEnglish copy updated too
G33
Approval · severity 5/12

Approve several requests at once

03
Problem
Chưa duyệt được hàng loạt

Reviewers had to open and approve each creative one by one, even when several were waiting.

Fix

"Đang chờ tôi" gets a checkbox per request and "Chọn tất cả". After a confirm that asks them to have looked at each one, the chosen requests are approved one by one through the existing endpoint, and any the server refuses are reported. The recording stops at the confirm, so local data is unchanged.

Before main today
After feat/rise-fe-round4
Codeproblem fix
src/features/artifact/review/components/ApprovalClient.tsx
line 1
"use client";
// access-gate-exempt: bulk approve acts only on "Awaiting me", which the server fills with
// the requests whose decision is the viewer's (the same right ReviewPanel reads as
// `canDecide`, not a brand role); the server re-checks every decision and a refusal is
// reported, never counted.
import { useNow } from "@/shared/hooks/useNow";
import { relativeLong } from "@/utils/timeAgo";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useSearchParams } from "next/navigation";
import { useState } from "react";
line 10
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { toast } from "@/utils/toast";
import { LazyVideoStill } from "@/components/common/lazy-video-still";
import { SegmentedControl } from "@/components/ui/segmented-control";
line 24
} from "@/utils/objectProxy";
import { listApprovals } from "../services/approval.service";
import { decideApproval, listApprovals } from "../services/approval.service";
import { ApprovalStatusBadge } from "./ApprovalStatusBadge";
import { ApproverTicks } from "./ApproverTicks";
line 67
const approvals = data?.approvals ?? [];
// Bulk approve (RISE G33): "Awaiting me" only, and each request is still decided on its
// own, so one the server refuses is reported and the rest go through.
const queryClient = useQueryClient();
const bulk = tab === "awaiting_me" && approvals.length > 0;
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [approving, setApproving] = useState(false);
const chosen = approvals.filter((approval) => selected.has(approval.id));
const allChosen = bulk && chosen.length === approvals.length;
const toggle = (id: string, on: boolean) =>
setSelected((current) => {
const next = new Set(current);
if (on) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
const changeTab = (next: ApprovalTab) => {
setSelected(new Set());
setTab(next);
};
const approveChosen = async () => {
setApproving(true);
let failed = 0;
for (const approval of chosen) {
try {
await decideApproval(brandId, approval.id, { status: "approved" });
} catch {
failed += 1;
}
}
const approved = chosen.length - failed;
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["submissions", brandId] }),
queryClient.invalidateQueries({ queryKey: ["artifacts", brandId] }),
]);
setApproving(false);
setConfirmOpen(false);
setSelected(new Set());
if (failed === 0) {
toast.success(t("bulk.success", { count: approved }));
} else {
toast.error(t("bulk.partial", { approved, failed }));
}
};
return (
<div className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-5 px-8 py-10">
line 84
}))}
value={tab}
onChange={setTab}
onChange={changeTab}
/>
</header>
{bulk && !isPending && (
<div className="flex min-h-9 items-center gap-3 px-1">
<Label
size="sm"
tone="muted"
weight="normal"
className="flex items-center gap-2.5"
>
<Checkbox
checked={allChosen}
indeterminate={chosen.length > 0 && !allChosen}
onCheckedChange={(checked) =>
setSelected(
checked
? new Set(approvals.map((approval) => approval.id))
: new Set()
)
}
/>
{chosen.length > 0
? t("bulk.selected", { count: chosen.length })
: t("bulk.selectAll")}
</Label>
{chosen.length > 0 && (
<Button
type="button"
size="sm"
shape="pill"
className="ml-auto"
onClick={() => setConfirmOpen(true)}
>
{t("bulk.approve", { count: chosen.length })}
</Button>
)}
</div>
)}
{isPending ? (
<div className="flex flex-col gap-2">
line 101
<ul className="flex flex-col gap-2">
{approvals.map((approval) => (
<li key={approval.id}>
<li key={approval.id} className="flex items-center gap-3">
{bulk && (
<Checkbox
aria-label={t("bulk.selectOne", {
name: approval.artifact.name ?? t("untitled"),
})}
checked={selected.has(approval.id)}
onCheckedChange={(checked) => toggle(approval.id, checked)}
className="ml-1 shrink-0"
/>
)}
<InboxRow
approval={approval}
line 113
</ul>
)}
<ConfirmDialog
open={confirmOpen}
title={t("bulk.confirmTitle", { count: chosen.length })}
message={t("bulk.confirmMessage")}
confirmLabel={t("bulk.confirm")}
confirmLoading={approving}
onConfirm={() => void approveChosen()}
onCancel={() => setConfirmOpen(false)}
/>
{openArtifactId && (
<InboxLightbox
line 156
wrap="normal"
onClick={onOpen}
className="flex w-full"
className="flex w-full min-w-0 flex-1"
>
{isAudioMime(approval.artifact.mimeType) ? (
src/i18n/locales/vi.json
line 86
"rowMeta": "{who} · đã yêu cầu {when} · cần {count} lượt duyệt",
"rowMetaStage": "{who} · đã yêu cầu {when} · cần {count} lượt duyệt · Vòng {index}/{total}",
"withdrawn": "Đã hủy yêu cầu"
"withdrawn": "Đã hủy yêu cầu",
"bulk": {
"selectAll": "Chọn tất cả",
"selected": "Đã chọn {count}",
"selectOne": "Chọn {name}",
"approve": "Duyệt {count} mục",
"confirmTitle": "Duyệt {count} ấn phẩm?",
"confirmMessage": "Mỗi yêu cầu sẽ được ghi là bạn đã duyệt. Hãy chắc chắn bạn đã xem từng ấn phẩm.",
"confirm": "Duyệt",
"success": "Đã duyệt {count} yêu cầu.",
"partial": "Đã duyệt {approved}. {failed} yêu cầu không duyệt được; mở từng mục để xem lý do."
}
},
"thread": {
commit 65e26ca23 source files1 tests updatedEnglish copy updated too
G1b
Production · severity 8/12

Point people to area editing

04
Problem
Không thấy công cụ khoanh vùng; sửa bằng lệnh càng sửa càng sai

Area editing exists: opening the editor lands straight in area marking, and edits stay inside the painted area. But it sits behind "Mở trong trình chỉnh sửa", which says nothing about it, so testers kept re-prompting the whole image.

Fix

Hovering the button now says: "Tô lên phần cần sửa rồi mô tả thay đổi; phần còn lại của ảnh giữ nguyên." The label itself stays: a product contract (CB-CANVAS-005) pins it.

Before main today
After feat/rise-fe-round4
Codeproblem fix
src/components/common/lightbox/lightbox-media-action-bar.tsx
line 38
onEdit,
editLabel,
editHint,
recrop,
onRecrop,
line 67
/** The Edit button's label; the caller names what its Edit opens. */
editLabel?: string;
/** Hover sentence on the edit action. */
editHint?: string;
recrop?: LightboxRecrop | null;
onRecrop?: (
line 101
icon={<Pencil className="size-3.5" />}
label={editText}
hint={editHint}
onClick={onEdit}
/>
line 175
icon={<Pencil className="size-[15px]" />}
label={editText}
hint={editHint}
iconOnly={narrow}
onClick={onEdit}
src/components/common/lightbox/lightbox.tsx
line 179
/** Label for the Edit action; defaults to "Edit". */
editLabel?: string;
/** Hover sentence on the edit action, saying what the editor is for. */
editHint?: string;
versions?: {
key: string;
line 319
onEdit,
editLabel,
editHint,
versions,
onRestoreVersion,
line 1143
onEdit={onEdit}
editLabel={editLabel}
editHint={editHint}
recrop={recrop}
onRecrop={onRecrop}
line 1178
onEdit={onEdit}
editLabel={editLabel}
editHint={editHint}
recrop={recrop}
onRecrop={onRecrop}
src/components/common/lightbox/media-action-button.tsx
line 22
href,
downloadName,
hint,
}: {
icon: ReactNode;
label: string;
/** A sentence shown on hover that says what the action is for, beyond its label. */
hint?: string;
iconOnly?: boolean;
look?: "pill" | "toolbar";
line 78
);
if (iconOnly) {
return <TooltipHint label={label}>{control}</TooltipHint>;
if (hint || iconOnly) {
return (
<TooltipHint
label={iconOnly && hint ? `${label}: ${hint}` : (hint ?? label)}
>
{control}
</TooltipHint>
);
}
src/features/artifact/components/lightbox/ArtifactLightbox.tsx
line 526
onEdit={capabilities.canvasEditor ? openInEditor : undefined}
editLabel={t("lightbox.openInEditor")}
// RISE G1b: area editing lives in the editor, and testers never found it.
editHint={t("lightbox.openInEditorHint")}
versions={
capabilities.versions
src/i18n/locales/vi.json
line 876
"htmlPreview": "Xem trước HTML",
"labelHtml": "HTML",
"labelImage": "Hình ảnh"
"labelImage": "Hình ảnh",
"openInEditorHint": "Tô lên phần cần sửa rồi mô tả thay đổi; phần còn lại của ảnh giữ nguyên."
}
},
commit 837d74ef6 source files1 tests updatedEnglish copy updated too

Branch feat/rise-fe-round4 at 837d74ef, on main after MR !395, separate from MR !399. In review as MR !400. Updated 23/10/2026 17:53.