PAM · SUS round 4

This page is out of date. Round 4 is now MR !400, re-recorded on today's main: vita-sus-round4.pages.dev

Round 4: the remaining frontend fixes.

The usability-study gaps left after round 1 that the frontend alone can fix, easiest first, one commit each. Every fix shows the problem, a recording of today's app next to the fixed one, and the code that caused it.

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

Next, easiest first

    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

    • G7Verified on main, no change needed: PNG chosen gives a real PNG (single download, with a size limit, folder and multi-select zips). Only gallery preview thumbnails are WEBP.
    • Round 110 wording and layout fixes, in review (MR !395)
    • Quick winsG17a, G30, G5, G6, G12 on feat/rise-quick-wins

    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/sus-ux-polish
    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 1e0d3ebf3 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 ra để bạn chọn". Folding the step list away was not done: a product contract (CB-SESS-043) requires every step to stay visible. No recording: there is no local chat with this step.

    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 56c9e7ad2 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.

    Before main today
    After feat/sus-ux-polish
    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 e2d9109c3 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/sus-ux-polish
    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 a25f34946 source files1 tests updatedEnglish copy updated too

    Branch feat/sus-ux-polish at a25f3494, on latest main. Not pushed. Updated 23/10/2026 17:55.