Normalize tooltip and ARIA labeling

This commit is contained in:
murashit 2026-05-26 21:41:30 +09:00
parent f000fa7e75
commit 9f5232ec58
11 changed files with 50 additions and 58 deletions

View file

@ -323,7 +323,6 @@ function renderReferencedThread(parent: HTMLElement, item: Extract<DisplayItem,
label.createSpan({ text: reference.title });
label.createSpan({ cls: "codex-panel__edited-files-separator", text: "·" });
label.createSpan({ text: `${String(reference.includedTurns)}/${String(reference.turnLimit)} turns` });
wrapper.title = reference.threadId;
}
function renderEditedFiles(parent: HTMLElement, item: Extract<DisplayItem, { kind: "message" }>, context: MessageStreamContext): void {

View file

@ -16,7 +16,7 @@ export function renderToolResult(parent: HTMLElement, item: ToolResultDisplayIte
const messageEl = root;
applyExecutionStateClass(messageEl, view.state);
createToolResultHeader(messageEl, view);
const content = messageEl.createDiv({ cls: "codex-panel__tool-summary", attr: { title: view.summary } });
const content = messageEl.createDiv({ cls: "codex-panel__tool-summary" });
context.renderTextWithWikiLinks(content, view.summary);
for (const section of view.details) {

View file

@ -12,7 +12,6 @@ export interface ToolbarChoice {
label: string;
selected?: boolean;
disabled?: boolean;
title?: string;
meta?: string;
onClick: () => void;
}
@ -52,7 +51,6 @@ export interface ToolbarViewModel {
fastActive: boolean;
runtimeSummary: string;
runtimeTitle: string;
runtimeAriaLabel: string;
runtimeEmphasized: boolean;
context: { level: "ok" | "warn" | "danger"; title: string; label: string; percent: number | null } | null;
rateLimit: RateLimitSummary | null;
@ -139,7 +137,7 @@ export function renderToolbar(toolbar: HTMLElement, model: ToolbarViewModel, act
}
function renderHistoryButton(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
const button = createToolbarButton(parent, "history", "Threads");
const button = createToolbarButton(parent, "history", "Toggle thread list");
button.addClass("codex-panel__history-toggle");
if (model.historyOpen) button.addClass("is-active");
button.setAttr("aria-pressed", model.historyOpen ? "true" : "false");
@ -147,7 +145,7 @@ function renderHistoryButton(parent: HTMLElement, model: ToolbarViewModel, actio
}
function renderAutoReviewButton(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
const button = createToolbarButton(parent, "shield", `Auto-review: ${model.autoReviewActive ? "on" : "off"}`);
const button = createToolbarButton(parent, "shield", "Toggle auto-review");
button.addClass("codex-panel__auto-review-toggle");
if (model.autoReviewActive) button.addClass("is-active");
button.setAttr("aria-pressed", model.autoReviewActive ? "true" : "false");
@ -160,7 +158,7 @@ function renderStatusButton(parent: HTMLElement, model: ToolbarViewModel, action
cls: `clickable-icon nav-action-button codex-panel-ui__toolbar-control codex-panel__status-dot codex-panel__status-dot--${model.statusState}${alertClass} ${model.statusPanelOpen ? "is-active" : ""}`,
attr: {
type: "button",
"aria-label": statusButtonLabel(model),
"aria-label": "Toggle connection status",
"aria-expanded": model.statusPanelOpen ? "true" : "false",
},
});
@ -173,20 +171,10 @@ function renderStatusButton(parent: HTMLElement, model: ToolbarViewModel, action
button.onclick = actions.toggleStatusPanel;
}
function statusButtonLabel(model: ToolbarViewModel): string {
const status = model.status.trim() || (model.connected ? "Connected" : "Not connected");
const connection = model.connected ? "connected" : "not connected";
const normalizedStatus = status.toLowerCase().replace(/[.!]+$/u, "");
const parts = [`Status: ${status}`];
if (normalizedStatus !== connection) parts.push(`Connection: ${connection}`);
parts.push(`Diagnostics: ${model.diagnosticAlertLevel}`);
return parts.join("; ");
}
function renderRuntimeStatus(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
const row = parent.createDiv({ cls: "codex-panel__runtime-strip" });
renderRuntimeIcon(row, "list-checks", `Plan mode: ${model.planActive ? "on" : "off"}`, model.planActive, actions.togglePlan);
renderRuntimeIcon(row, "zap", `Fast mode: ${model.fastActive ? "on" : "off"}`, model.fastActive, actions.toggleFast);
renderRuntimeIcon(row, "list-checks", "Toggle plan mode", model.planActive, actions.togglePlan);
renderRuntimeIcon(row, "zap", "Toggle fast mode", model.fastActive, actions.toggleFast);
renderRuntimeModelControl(row, model, actions);
}
@ -217,7 +205,7 @@ function renderRuntimeModelControl(parent: HTMLElement, model: ToolbarViewModel,
cls,
attr: {
type: "button",
"aria-label": model.runtimeAriaLabel,
"aria-label": "Change runtime settings",
"aria-expanded": model.runtimeOpen ? "true" : "false",
},
});
@ -231,7 +219,6 @@ function renderContextMeter(parent: HTMLElement, model: ToolbarViewModel): void
const context = parent.createDiv({
cls: `codex-panel__meter-compact codex-panel__context-compact codex-panel__meter-compact--${model.context.level}`,
});
context.title = model.context.title;
context.createSpan({ cls: "codex-panel__meter-compact-label codex-panel__context-compact-label", text: model.context.label });
const bar = context.createSpan({ cls: "codex-panel__meter-compact-bar codex-panel__context-compact-bar" });
bar.createSpan({
@ -257,14 +244,20 @@ function renderToolbarPanel(toolbar: HTMLElement, model: ToolbarViewModel, actio
function renderStatusPanel(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
const statusItems = parent.createDiv({ cls: "codex-panel__status-panel-items", attr: { role: "menu" } });
createToolbarPanelRow(statusItems, model.connectLabel, { onClick: actions.connect, className: "codex-panel__status-panel-item" });
createToolbarPanelRow(statusItems, model.connectLabel, {
onClick: actions.connect,
className: "codex-panel__status-panel-item",
role: "menuitem",
});
createToolbarPanelRow(statusItems, "Refresh diagnostics", {
onClick: actions.refreshDiagnostics,
className: "codex-panel__status-panel-item",
role: "menuitem",
});
createToolbarPanelRow(statusItems, "Refresh thread list", {
onClick: actions.refreshThreads,
className: "codex-panel__status-panel-item",
role: "menuitem",
});
renderRateLimitPanel(parent, model.rateLimit);
renderConnectionDiagnostics(parent, model.diagnostics);
@ -280,7 +273,6 @@ function renderRateLimitPanel(parent: HTMLElement, rateLimit: RateLimitSummary |
for (const row of rateLimit.rows) {
const item = list.createDiv({
cls: `codex-panel__limit-panel-row codex-panel__limit-panel-row--${row.level}`,
attr: { title: row.title },
});
item.createDiv({ cls: "codex-panel__limit-panel-label", text: row.label });
item.createDiv({ cls: "codex-panel__limit-panel-value", text: row.value });
@ -313,18 +305,22 @@ function renderRuntimePicker(parent: HTMLElement, model: ToolbarViewModel): void
const picker = parent.createDiv({ cls: "codex-panel__runtime-picker", attr: { role: "listbox" } });
picker.createDiv({ cls: "codex-panel__runtime-picker-label", text: "Reasoning effort" });
for (const choice of model.effortChoices) {
createToolbarPanelRow(picker, choice.label, { ...choice, className: "codex-panel__runtime-choice" });
createToolbarPanelRow(picker, choice.label, { ...choice, className: "codex-panel__runtime-choice", role: "option" });
}
picker.createDiv({ cls: "codex-panel__runtime-picker-label", text: "Model" });
for (const choice of model.modelChoices) {
createToolbarPanelRow(picker, choice.label, { ...choice, className: "codex-panel__runtime-choice" });
createToolbarPanelRow(picker, choice.label, { ...choice, className: "codex-panel__runtime-choice", role: "option" });
}
}
function renderThreadList(parent: HTMLElement, threads: ToolbarThreadRow[], actions: ToolbarActions): void {
const threadsEl = parent.createDiv({ cls: "codex-panel__threads" });
if (threads.length === 0) {
createToolbarPanelRow(threadsEl, "No threads", { disabled: true, className: "codex-panel__thread codex-panel__thread--empty" });
createToolbarPanelRow(threadsEl, "No threads", {
disabled: true,
className: "codex-panel__thread codex-panel__thread--empty",
interactive: false,
});
return;
}
@ -340,7 +336,6 @@ function renderThreadList(parent: HTMLElement, threads: ToolbarThreadRow[], acti
createToolbarPanelRow(row, thread.title, {
selected: thread.selected,
disabled: thread.disabled,
title: `${thread.title}\n${thread.threadId}`,
className: "codex-panel__thread",
onClick: () => {
actions.resumeThread(thread.threadId);
@ -372,7 +367,6 @@ function renderThreadRenameRow(parent: HTMLElement, thread: ToolbarThreadRow, ac
createToolbarPanelRow(parent, thread.title, {
className: "codex-panel__thread codex-panel__thread-rename",
interactive: false,
title: `Rename ${thread.title}`,
renderContent: (row) => {
const field = row.createDiv({ cls: "codex-panel__thread-rename-field" });
input = field.createEl("input", {
@ -436,10 +430,10 @@ function createToolbarPanelRow(
options: {
selected?: boolean;
disabled?: boolean;
title?: string;
meta?: string;
className?: string;
interactive?: boolean;
role?: "button" | "menuitem" | "option";
renderContent?: (parent: HTMLElement) => void;
onClick?: () => void;
} = {},
@ -447,12 +441,17 @@ function createToolbarPanelRow(
const selected = Boolean(options.selected);
const disabled = Boolean(options.disabled);
const interactive = options.interactive ?? true;
const attr: Record<string, string> = { title: options.title ?? label };
const role = options.role ?? "button";
const attr: Record<string, string> = {};
if (interactive) {
attr["role"] = "button";
attr["role"] = role;
attr["tabindex"] = disabled ? "-1" : "0";
attr["aria-disabled"] = disabled ? "true" : "false";
attr["aria-selected"] = selected ? "true" : "false";
if (role === "option") {
attr["aria-selected"] = selected ? "true" : "false";
} else if (role === "button" && selected) {
attr["aria-current"] = "true";
}
}
const item = parent.createDiv({
cls: ["codex-panel__toolbar-panel-item", options.className ?? "", selected ? "is-checked" : "", disabled ? "is-disabled" : ""]

View file

@ -82,7 +82,6 @@ function renderTurnDiffHeader(
titleBlock.createDiv({
cls: "codex-panel-chat-turn-diff__meta",
text: `${shortThreadId(state.threadId)} / ${shortThreadId(state.turnId)} · ${fileCountLabel(state.files)}`,
attr: { title: `Thread ${state.threadId}\nTurn ${state.turnId}${state.cwd ? `\n${state.cwd}` : ""}` },
});
if (copyDiff) {
const copyButton = createIconButton(header, "copy", "Copy diff", "codex-panel-chat-turn-diff__copy");

View file

@ -75,7 +75,6 @@ export function renderAgentItem(parent: HTMLElement, item: AgentDisplayItem, con
const list = messageEl.createEl("ul", { cls: "codex-panel__agent-list" });
for (const agent of item.agents) {
const row = list.createEl("li", { cls: "codex-panel__agent-row" });
row.title = agent.threadId;
row.createSpan({ cls: "codex-panel__agent-thread", text: shortThreadId(agent.threadId) });
row.createSpan({ cls: "codex-panel__agent-status", text: agentStatusLabel(agent.status, agent.message) });
}
@ -129,7 +128,6 @@ function renderAgentSummaryRows(parent: HTMLElement, summary: AgentRunSummary):
const list = parent.createEl("ul", { cls: "codex-panel__agent-list codex-panel__agent-list--summary" });
for (const agent of summary.agents) {
const row = list.createEl("li", { cls: "codex-panel__agent-row" });
row.title = agent.threadId;
row.createSpan({ cls: "codex-panel__agent-thread", text: shortThreadId(agent.threadId) });
row.createSpan({ cls: "codex-panel__agent-status", text: agentSummaryStatusLabel(agent) });
}

View file

@ -1277,7 +1277,6 @@ export class CodexChatView extends ItemView {
fastActive: currentServiceTier(snapshot, config) === "fast",
runtimeSummary: runtimeSummaryLabel(model, effort),
runtimeTitle: `Model: ${model ?? "(from default)"}; Effort: ${effort ?? "(from default)"}`,
runtimeAriaLabel: `Runtime: ${model ?? "default"} ${effort ?? "default"}`,
runtimeEmphasized: false,
context: context ? { ...context, label: compactContextLabel(context.percent, context.label) } : null,
rateLimit: limit,

View file

@ -53,7 +53,7 @@ export function renderThreadsView(parent: HTMLElement, model: ThreadsViewModel,
function renderThreadRow(parent: HTMLElement, row: ThreadsRowModel, actions: ThreadsViewActions): void {
const item = parent.createDiv({
cls: "codex-panel-threads__row",
attr: { role: "button", tabindex: "0", "aria-label": `Open thread: ${row.title}` },
attr: { role: "button", tabindex: "0" },
});
if (row.live) item.addClass(`codex-panel-threads__row--${row.live.status}`);
if (row.rename.active) item.addClass("codex-panel-threads__row--renaming");

View file

@ -2,7 +2,7 @@ import { Setting } from "obsidian";
import type { HookMetadata } from "../generated/app-server/v2/HookMetadata";
import type { Thread } from "../generated/app-server/v2/Thread";
import { archivedThreadDisplayTitle, fullThreadTitle } from "../domain/threads/model";
import { archivedThreadDisplayTitle } from "../domain/threads/model";
import { shortThreadId } from "../utils";
export interface ArchivedThreadSectionState {
@ -152,7 +152,6 @@ function renderArchivedThreadList(containerEl: HTMLElement, state: ArchivedThrea
button.extraSettingsEl.setAttr("aria-label", `Restore ${title}`);
});
setting.settingEl.addClass("codex-panel-settings__archived-row");
setting.settingEl.setAttr("title", fullThreadTitle(thread));
}
}
@ -202,11 +201,9 @@ function renderHookRow(list: HTMLElement, hook: HookMetadata, state: HookSection
});
});
setting.settingEl.addClass("codex-panel-settings__hook-row");
setting.settingEl.setAttr("title", hook.command ?? hook.key);
setting.descEl.createDiv({
cls: "codex-panel-settings__hook-hash",
text: hook.currentHash,
attr: { title: hook.key },
});
}

View file

@ -33,7 +33,6 @@ export function renderTextWithWikiLinks(parent: HTMLElement, text: string, openL
text: label,
attr: {
href: target,
title: target,
},
});
link.onclick = (event) => {

View file

@ -752,7 +752,7 @@ describe("message stream block identity and message actions", () => {
const element = block.render();
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("npm run check (exit 1)");
expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBe("npm run check (exit 1)");
expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull();
expect(topLevelDetailsSummaries(element)).toEqual(["command"]);
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["command"]);
expect(element.textContent).not.toContain("Details");
@ -947,7 +947,7 @@ describe("message stream block identity and message actions", () => {
expect(user?.querySelector(".codex-panel__message-content")?.textContent).toBe("この続きです");
expect(user?.querySelector(".codex-panel__referenced-thread")?.textContent).toContain("Referenced 参照元");
expect(user?.querySelector(".codex-panel__referenced-thread")?.textContent).toContain("2/20 turns");
expect(user?.querySelector<HTMLElement>(".codex-panel__referenced-thread")?.title).toBe("thread-reference");
expect(user?.querySelector<HTMLElement>(".codex-panel__referenced-thread")?.getAttribute("title")).toBeNull();
});
it("renders resolved file mentions as a collapsed user message attachment", () => {
@ -1238,7 +1238,7 @@ describe("work log renderer decisions", () => {
const element = block.render();
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png");
expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBe("assets/image.png");
expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull();
});
it("keeps path summary tools absolute outside the workspace root", () => {
@ -1651,20 +1651,18 @@ describe("toolbar renderer decisions", () => {
const statusButton = parent.querySelector(".codex-panel__status-dot");
expect(statusButton?.tagName).toBe("BUTTON");
expect(statusButton?.getAttribute("role")).toBeNull();
expect(statusButton?.getAttribute("aria-label")).toBe("Status: Connected.; Diagnostics: normal");
expect(statusButton?.getAttribute("aria-label")).toBe("Toggle connection status");
parent.querySelector<HTMLButtonElement>(".codex-panel__history-toggle")?.click();
expect(toggleHistory).toHaveBeenCalled();
const autoReviewButton = parent.querySelector<HTMLButtonElement>(".codex-panel__auto-review-toggle");
expect(autoReviewButton?.getAttribute("aria-label")).toBe("Auto-review: off");
expect(autoReviewButton?.getAttribute("aria-label")).toBe("Toggle auto-review");
expect(autoReviewButton?.getAttribute("aria-pressed")).toBe("false");
autoReviewButton?.click();
expect(toggleAutoReview).toHaveBeenCalled();
parent.empty();
renderToolbar(parent, toolbarModel({ status: "Turn running...", statusState: "running", autoReviewActive: true }), toolbarActions());
expect(parent.querySelector(".codex-panel__status-dot")?.getAttribute("aria-label")).toBe(
"Status: Turn running...; Connection: connected; Diagnostics: normal",
);
expect(parent.querySelector(".codex-panel__status-dot")?.getAttribute("aria-label")).toBe("Toggle connection status");
expect(parent.querySelector(".codex-panel__auto-review-toggle")?.getAttribute("aria-pressed")).toBe("true");
expect(toolbarSignature(baseModel)).not.toBe(toolbarSignature({ ...baseModel, status: "Turn running..." }));
@ -1708,6 +1706,8 @@ describe("toolbar renderer decisions", () => {
]);
expect([...parent.querySelectorAll(".codex-panel__runtime-choice")].map((choice) => choice.textContent)).toEqual(["high", "gpt-5.5"]);
for (const choice of parent.querySelectorAll(".codex-panel__runtime-choice")) {
expect(choice.getAttribute("role")).toBe("option");
expect(choice.getAttribute("aria-selected")).toBe("true");
expect(choice.querySelector<HTMLElement>(".codex-panel__toolbar-panel-check")?.dataset["icon"]).toBe("check");
expect(choice.classList.contains("selected")).toBe(false);
expect(choice.classList.contains("is-selected")).toBe(false);
@ -1783,9 +1783,10 @@ describe("toolbar renderer decisions", () => {
expect(parent.textContent).toContain("Refresh diagnostics");
expect(parent.textContent).toContain("codex-cli/1.2.3");
expect(parent.querySelector(".codex-panel__connection-diagnostics-row--error")?.textContent).toContain("model/list failed");
[...parent.querySelectorAll<HTMLElement>(".codex-panel__status-panel-item")]
.find((item) => item.textContent.includes("Refresh diagnostics"))
?.click();
const statusItems = [...parent.querySelectorAll<HTMLElement>(".codex-panel__status-panel-item")];
expect(statusItems.map((item) => item.getAttribute("role"))).toEqual(["menuitem", "menuitem", "menuitem"]);
expect(statusItems.every((item) => item.getAttribute("aria-selected") === null)).toBe(true);
statusItems.find((item) => item.textContent.includes("Refresh diagnostics"))?.click();
expect(refreshDiagnostics).toHaveBeenCalled();
});
@ -1794,21 +1795,21 @@ describe("toolbar renderer decisions", () => {
renderToolbar(normal, toolbarModel({ diagnosticAlertLevel: "normal" }), toolbarActions());
const normalStatus = normal.querySelector(".codex-panel__status-dot");
expect(normalStatus?.querySelector(".codex-panel__status-dot-diagnostic")).toBeNull();
expect(normalStatus?.getAttribute("aria-label")).toContain("Diagnostics: normal");
expect(normalStatus?.getAttribute("aria-label")).toBe("Toggle connection status");
const warning = document.createElement("div");
renderToolbar(warning, toolbarModel({ diagnosticAlertLevel: "warning" }), toolbarActions());
const warningStatus = warning.querySelector(".codex-panel__status-dot");
expect(warningStatus?.classList.contains("codex-panel__status-dot--diagnostic-warning")).toBe(true);
expect(warningStatus?.querySelector(".codex-panel__status-dot-diagnostic--warning")).not.toBeNull();
expect(warningStatus?.getAttribute("aria-label")).toContain("Diagnostics: warning");
expect(warningStatus?.getAttribute("aria-label")).toBe("Toggle connection status");
const error = document.createElement("div");
renderToolbar(error, toolbarModel({ diagnosticAlertLevel: "error" }), toolbarActions());
const errorStatus = error.querySelector(".codex-panel__status-dot");
expect(errorStatus?.classList.contains("codex-panel__status-dot--diagnostic-error")).toBe(true);
expect(errorStatus?.querySelector(".codex-panel__status-dot-diagnostic--error")).not.toBeNull();
expect(errorStatus?.getAttribute("aria-label")).toContain("Diagnostics: error");
expect(errorStatus?.getAttribute("aria-label")).toBe("Toggle connection status");
});
it("renders effective config inside the status menu without a separate toggle", () => {
@ -2426,7 +2427,6 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
fastActive: false,
runtimeSummary: "5.5 high",
runtimeTitle: "Model: gpt-5.5; Effort: high",
runtimeAriaLabel: "Runtime: gpt-5.5 high",
runtimeEmphasized: false,
context: null,
rateLimit: null,

View file

@ -158,7 +158,9 @@ describe("CodexThreadsView", () => {
const view = await threadsView(host);
await view.refresh();
view.containerEl.querySelector<HTMLElement>(".codex-panel-threads__row")?.click();
const row = view.containerEl.querySelector<HTMLElement>(".codex-panel-threads__row");
expect(row?.getAttribute("aria-label")).toBeNull();
row?.click();
await vi.waitFor(() => {
expect(host.openThreadInAvailableView).toHaveBeenCalledWith("thread");