Merge current master for required base freshness

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* origin/master:
  fix(tasks): hide empty message history disclosure (#17751)
This commit is contained in:
DottaandPaperclip committed 2026-10-26 12:36:38 -05:00
commit 6bc3859883
7 files changed
+216 -16

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect.
### `task-detail`
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **More messages** loads earlier messages and run activity; incoming messages remain visible while the task stays open.
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **More messages** appears only when earlier comments or runs are omitted and loads that history. Fresh tasks with no omitted history have no disclosure. A single legacy run with a truncated log offers **More run output** instead. **More task actions → Show task activity** loads full activity even for a single native run; incoming messages remain visible while the task stays open.
### `task-sharing`
+28 -1
View File
@@ -26,6 +26,7 @@ const transcriptState = vi.hoisted(() => ({
transcriptByRun: new Map(),
isInitialHydrating: false,
hydratedRunIds: undefined as Set<string> | undefined,
truncatedRunIds: new Set<string>(),
}));
const nativeTranscriptState = vi.hoisted(() => ({
transcriptByRun: new Map(),
@@ -35,6 +36,7 @@ const nativeTranscriptState = vi.hoisted(() => ({
}));
const transcriptHookRuns = vi.hoisted(() => ({
legacy: [] as unknown[][],
legacyReadLimits: [] as (number | undefined)[],
native: [] as unknown[][],
}));
const sidebarState = vi.hoisted(() => ({ isMobile: false }));
@@ -51,12 +53,14 @@ const editorCallbacks = vi.hoisted(() => ({ change: (_value: string) => {} }));
const streamlinedState = vi.hoisted(() => ({ enabled: true }));
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: ({ runs }: { runs: unknown[] }) => {
useLiveRunTranscripts: ({ runs, logReadLimitBytes }: { runs: unknown[]; logReadLimitBytes?: number }) => {
transcriptHookRuns.legacy.push(runs);
transcriptHookRuns.legacyReadLimits.push(logReadLimitBytes);
return {
transcriptByRun: new Map(transcriptState.transcriptByRun),
isInitialHydrating: transcriptState.isInitialHydrating,
hydratedRunIds: transcriptState.hydratedRunIds,
truncatedRunIds: transcriptState.truncatedRunIds,
};
},
}));
@@ -122,11 +126,13 @@ beforeEach(() => {
transcriptState.transcriptByRun.clear();
transcriptState.isInitialHydrating = false;
transcriptState.hydratedRunIds = undefined;
transcriptState.truncatedRunIds.clear();
nativeTranscriptState.transcriptByRun.clear();
nativeTranscriptState.errorsByRun.clear();
nativeTranscriptState.hydratedRunIds = undefined;
nativeTranscriptState.terminalHydratedRunIds = undefined;
transcriptHookRuns.legacy.length = 0;
transcriptHookRuns.legacyReadLimits.length = 0;
transcriptHookRuns.native.length = 0;
sidebarState.isMobile = false;
planState.data = null;
@@ -371,6 +377,27 @@ it.each([true, false])("keeps cold-open messages readable while history is pendi
expect(container.querySelector('[data-testid="mock-editor"]')).not.toBeNull();
});
it.each([0, 32_000, 100_000])("offers earlier output only when a single legacy run exceeds the compact window (%s bytes)", (logBytes) => {
const onExpandHistory = vi.fn();
if (logBytes > 32_000) transcriptState.truncatedRunIds.add("legacy-run");
const props = {
issueId: "single-legacy-run", comments: [], onAdd: async () => {}, onExpandHistory,
linkedRuns: [{ runId: "legacy-run", agentId: "agent", status: "succeeded", adapterType: "claude_local", runtimeMode: "legacy" as const, createdAt: "2025-01-01T10:00:00Z", startedAt: "2025-01-01T10:00:00Z", logBytes }],
};
render(<TaskChatThread {...props} compactHistory />);
expect(container.textContent).not.toContain("More messages");
const outputControl = [...container.querySelectorAll("button")].find((button) => button.textContent === "More run output");
expect(Boolean(outputControl)).toBe(logBytes > 32_000);
expect(transcriptHookRuns.legacyReadLimits.at(-1)).toBe(32_000);
if (outputControl) {
act(() => outputControl.click());
expect(onExpandHistory).toHaveBeenCalledOnce();
render(<TaskChatThread {...props} compactHistory={false} />);
expect(container.textContent).not.toContain("More run output");
expect(transcriptHookRuns.legacyReadLimits.at(-1)).toBeUndefined();
}
});
it("defers initial history for a saved conversation with its own draft key", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueId="saved-chat"
conversationMode draftKey="agent-chat:company:saved-agent" initialHistoryPending />);
+13 -4
View File
@@ -138,6 +138,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { TaskChatPresentationProvider } from "@/components/task-chat/presentation-mode";
const EMPTY_BROWSERS: TaskBrowser[] = [];
const COMPACT_RUN_LOG_READ_LIMIT_BYTES = 32_000;
function toMs(value: Date | string | null | undefined): number {
if (!value) return 0;
@@ -424,6 +425,7 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread> & {
creationActivity?: ActivityEvent[];
compactHistory?: boolean;
historyControl?: ReactNode;
onExpandHistory?: () => void;
initialHistoryPending?: boolean;
initialHistoryError?: boolean;
onRetryInitialHistory?: () => void;
@@ -966,9 +968,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
transcriptByRun: logTranscriptByRun,
isInitialHydrating: logsAreInitiallyHydrating,
hydratedRunIds: hydratedLogRunIds,
truncatedRunIds,
errorsByRun: logErrorsByRun,
retry: retryLogs,
} = useLiveRunTranscripts({ runs: logRuns, companyId, ...(props.compactHistory ? { logReadLimitBytes: 32_000 } : {}) });
} = useLiveRunTranscripts({ runs: logRuns, companyId, ...(props.compactHistory ? { logReadLimitBytes: COMPACT_RUN_LOG_READ_LIMIT_BYTES } : {}) });
const fallbackByRunRef = useRef(
new Map<string, NonNullable<ReturnType<typeof logTranscriptByRun.get>>>(),
);
@@ -2824,9 +2827,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
],
);
const historyControl = props.historyControl ?? (
props.compactHistory && props.onExpandHistory
&& logRuns.some((run) => truncatedRunIds?.has(run.id))
? <Button type="button" variant="ghost" size="sm" onClick={props.onExpandHistory}>More run output</Button>
: null
);
const renderBrief = useCallback(
() => issueBrief ? <><TaskChatDescriptionBubble brief={issueBrief} />{props.historyControl}</> : null,
[issueBrief, props.historyControl],
() => issueBrief ? <><TaskChatDescriptionBubble brief={issueBrief} />{historyControl}</> : null,
[issueBrief, historyControl],
);
const assignedAgentForNotice = useMemo(() => {
@@ -3127,7 +3136,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<TaskChatThreadView
items={items}
attachments={attachments}
header={<>{threadHeaderWithBlockers}{!issueBrief ? props.historyControl : null}</>}
header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}</>}
renderInteraction={renderInteraction}
renderBrief={renderBrief}
renderMessageActions={renderMessageActions}
@@ -131,17 +131,18 @@ describe("useLiveRunTranscripts", () => {
});
it("expands a compact log tail without hiding or duplicating the latest output", async () => {
let truncatedRunIds: ReadonlySet<string> = new Set();
const row = (seq: number, chunk: string) => JSON.stringify({ seq, chunk, ts: `2026-10-24T00:00:0${seq}Z`, stream: "stdout" });
const result = (content: string) => ({ runId: "run-1", store: "memory", logRef: "log-1", content, nextOffset: 100_000 });
let resolveExpanded!: (value: ReturnType<typeof result>) => void;
logMock.mockResolvedValueOnce(result(`${row(2, "Latest answer")}\n`));
logMock.mockImplementationOnce(() => new Promise(resolve => { resolveExpanded = resolve; }));
function Harness({ limit, bytes = 100_000 }: { limit: number; bytes?: number }) {
useLiveRunTranscripts({
({ truncatedRunIds } = useLiveRunTranscripts({
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", logBytes: bytes }],
enableRealtimeUpdates: false,
logReadLimitBytes: limit,
});
}));
return null;
}
const root = createRoot(document.createElement("div"));
@@ -149,11 +150,13 @@ describe("useLiveRunTranscripts", () => {
try {
await act(async () => root.render(<Harness limit={32_000} />));
expect(logMock).toHaveBeenLastCalledWith("run-1", 68_000, 32_000, expect.objectContaining({ alignStartToRecord: true }));
expect(truncatedRunIds.has("run-1")).toBe(true);
expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]);
await act(async () => root.render(<Harness limit={256_000} />));
expect(logMock).toHaveBeenLastCalledWith("run-1", 0, 256_000, expect.anything());
expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]);
await act(async () => resolveExpanded(result(`${row(1, "Earlier activity")}\n${row(2, "Latest answer")}\n`)));
expect(truncatedRunIds.has("run-1")).toBe(false);
expect(chunks()).toEqual([
expect.objectContaining({ chunk: "Earlier activity" }),
expect.objectContaining({ chunk: "Latest answer" }),
@@ -165,6 +168,30 @@ describe("useLiveRunTranscripts", () => {
}
});
it("does not report omitted output when a run grows beyond the window after reading from the start", async () => {
let truncatedRunIds: ReadonlySet<string> = new Set();
const row = (seq: number) => `${JSON.stringify({ seq, chunk: `Output ${seq}`, stream: "stdout", ts: "2026-10-24T00:00:00Z" })}\n`;
const response = { runId: "run-1", store: "memory", logRef: "log-1" };
logMock.mockResolvedValueOnce({ ...response, content: row(1), nextOffset: 100 });
logMock.mockResolvedValueOnce({ ...response, content: row(2), nextOffset: 100_000 });
function Harness({ bytes }: { bytes: number }) {
({ truncatedRunIds } = useLiveRunTranscripts({
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", logBytes: bytes }],
enableRealtimeUpdates: false, logReadLimitBytes: 32_000,
}));
return null;
}
const root = createRoot(document.createElement("div"));
try {
await act(async () => root.render(<Harness bytes={100} />));
expect(logMock).toHaveBeenLastCalledWith("run-1", 0, 32_000, expect.anything());
expect(truncatedRunIds.has("run-1")).toBe(false);
await act(async () => root.render(<Harness bytes={100_000} />));
expect(logMock).toHaveBeenLastCalledWith("run-1", 100, 32_000, expect.anything());
expect(truncatedRunIds.has("run-1")).toBe(false);
} finally { await act(async () => root.unmount()); }
});
it("keeps record alignment pending when an expanded log read needs retry", async () => {
const row = (seq: number, chunk: string) => JSON.stringify({ seq, chunk, ts: `2026-10-24T00:00:0${seq}Z`, stream: "stdout" });
const result = (content: string) => ({ runId: "run-1", store: "memory", logRef: "log-1", content, nextOffset: 500_000 });
@@ -144,6 +144,7 @@ export function useLiveRunTranscripts({
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
const logReadLimitByRunRef = useRef(new Map<string, number>());
const [truncatedRunIds, setTruncatedRunIds] = useState<ReadonlySet<string>>(new Set());
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
// PAP-462 B3: buffered runs that dropped out of the `runs` list, mapped to the
// wall-clock deadline (ms) after which their buffer may be pruned. A run still
@@ -260,6 +261,10 @@ export function useLiveRunTranscripts({
const next = new Map([...previous].filter(([id]) => retainedRunIds.has(id)));
return next.size === previous.size ? previous : next;
});
setTruncatedRunIds((previous) => {
const next = new Set([...previous].filter((id) => retainedRunIds.has(id)));
return next.size === previous.size ? previous : next;
});
for (const key of pendingLogRowsByRunRef.current.keys()) {
const runId = key.replace(/:records$/, "");
@@ -340,6 +345,15 @@ export function useLiveRunTranscripts({
// Commit the expanded window only after success so retries still align
// its first record and failed reads leave the previous parser intact.
if (previousReadLimit === undefined || rewinding) {
setTruncatedRunIds((previous) => {
if (previous.has(run.id) === (offset > 0)) return previous;
const next = new Set(previous);
if (offset > 0) next.add(run.id);
else next.delete(run.id);
return next;
});
}
logReadLimitByRunRef.current.set(run.id, logReadLimitBytes);
if (rewinding) {
pendingLogRowsByRunRef.current.delete(run.id);
@@ -596,7 +610,7 @@ export function useLiveRunTranscripts({
return {
transcriptByRun,
hydratedRunIds, errorsByRun, retry,
hydratedRunIds, truncatedRunIds, errorsByRun, retry,
isInitialHydrating: normalizedRuns.some((run) => canReadPersistedLog(run) && !hydratedRunIds.has(run.id)),
hasOutputForRun(runId: string) {
return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true;
+94
View File
@@ -1684,6 +1684,100 @@ describe("IssueDetail", () => {
expect(windowOpen).not.toHaveBeenCalled();
});
it.each([null, "queued", "running", "succeeded", "failed"])("hides the history disclosure on a fresh task with first run %s", async (status) => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockIssuesApi.get.mockResolvedValue(createIssue());
const runs = status ? [{ runId: "first-run", status, agentId: "agent-1", createdAt: new Date().toISOString() }] : [];
mockActivityApi.recentRunsForIssue.mockResolvedValue(runs);
await act(async () => renderIssueView());
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
expect(mockActivityApi.recentRunsForIssue).toHaveBeenCalled();
expect(props?.linkedRuns).toMatchObject(runs);
expect(props?.compactHistory).toBe(true);
expect(props?.historyControl).toBeNull();
});
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
});
it("loads full first-run activity from the task menu without an older-message disclosure", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockIssuesApi.get.mockResolvedValue(createIssue());
const run = { runId: "first-run", runtimeMode: "native", status: "succeeded", agentId: "agent-1", createdAt: new Date().toISOString() };
mockActivityApi.recentRunsForIssue.mockResolvedValue([run]);
mockActivityApi.runsForIssue.mockResolvedValue([run]);
await act(async () => renderIssueView());
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
expect(props?.linkedRuns).toMatchObject([run]);
expect(props?.compactHistory).toBe(true);
expect(props?.historyControl).toBeNull();
});
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
await act(async () => {
container.querySelector<HTMLButtonElement>('button[aria-label="More task actions"]')!.click();
});
const activityButton = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.trim() === "Show task activity");
expect(activityButton).toBeTruthy();
await act(async () => activityButton!.click());
await waitForAssertion(() => {
expect(mockActivityApi.runsForIssue).toHaveBeenCalled();
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].compactHistory).toBe(false);
});
await act(async () => {
container.querySelector<HTMLButtonElement>('button[aria-label="More task actions"]')!.click();
});
expect(Array.from(document.body.querySelectorAll("button"))
.some((button) => button.textContent?.trim() === "Show task activity")).toBe(false);
});
it("does not add a history disclosure when a second run is observed while the page stays open", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockIssuesApi.get.mockResolvedValue(createIssue());
const run = (runId: string) => ({ runId, status: "succeeded", agentId: "agent-1", createdAt: new Date().toISOString() });
const first = run("first"), second = run("second");
mockActivityApi.recentRunsForIssue.mockResolvedValue([first]);
await act(async () => renderIssueView());
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns).toMatchObject([first]));
mockActivityApi.recentRunsForIssue.mockResolvedValue([second, first]);
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs("PAP-1") }); });
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
expect(props?.linkedRuns).toMatchObject([first, second]);
expect(props?.historyControl).toBeNull();
});
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
});
it("reveals omitted runs through the history disclosure when there are no older comments", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockIssuesApi.get.mockResolvedValue(createIssue());
const run = (runId: string) => ({ runId, status: "succeeded", agentId: "agent-1", createdAt: new Date().toISOString() });
const latest = run("latest"), older = run("older"), next = run("next");
mockActivityApi.recentRunsForIssue.mockResolvedValue([latest, older]);
mockActivityApi.runsForIssue.mockResolvedValue([next, latest, older]);
await act(async () => renderIssueView());
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
expect(props?.linkedRuns).toMatchObject([latest]);
expect(props?.historyControl).toBeTruthy();
});
mockActivityApi.recentRunsForIssue.mockResolvedValue([next, latest]);
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs("PAP-1") }); });
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns).toMatchObject([latest, next]));
const disclosure = mockIssueChatThreadRender.mock.calls.at(-1)?.[0].historyControl;
expect(disclosure).toBeTruthy();
await act(async () => disclosure.props.onClick());
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
expect(props?.linkedRuns).toMatchObject([next, latest, older]);
expect(props?.historyControl).toBeNull();
expect(props?.compactHistory).toBe(false);
});
});
it("reveals a cold task and recent exchange while full detail and older history are stalled", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
const stalled = createDeferred<Issue>();
+36 -7
View File
@@ -1203,6 +1203,11 @@ function InboxMobileToolbar({
);
}
interface RecentIssueRunWindow {
runs: RunForIssue[];
hasEarlierRuns: boolean;
}
type IssueDetailChatTabProps = {
executionBlocked?: boolean;
interruption?: Issue["interruption"];
@@ -1620,37 +1625,48 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun;
const linkedRunsKey = historyExpanded
? queryKeys.issues.runs(issueQueryRef)
: [...queryKeys.issues.runs(issueQueryRef), "recent"];
: [...queryKeys.issues.runs(issueQueryRef), "recent-window"];
const {
data: linkedRuns,
data: linkedRunHistory,
isPending: linkedRunsPending,
isError: linkedRunsError,
refetch: refetchLinkedRuns,
} = useQuery({
} = useQuery<RunForIssue[] | RecentIssueRunWindow>({
queryKey: linkedRunsKey,
queryFn: async () => {
if (historyExpanded) return activityApi.runsForIssue(issueQueryRef);
let recent = await activityApi.recentRunsForIssue(issueQueryRef);
let previous = queryClient.getQueryData<RunForIssue[]>(linkedRunsKey) ?? [];
const previousWindow = queryClient.getQueryData<RecentIssueRunWindow>(linkedRunsKey);
let previous = previousWindow?.runs ?? [];
let reconciledFullHistory = false;
if (previous.some((run) =>
(run.status === "running" || run.status === "queued") && !recent.some((next) => next.runId === run.runId),
)) {
// After a reconnect, several turns may have completed beyond the small
// window. Reconcile watched runs once against authoritative summaries.
recent = await activityApi.runsForIssue(issueQueryRef);
reconciledFullHistory = true;
const existingRunIds = new Set(recent.map((run) => run.runId));
previous = previous.filter((run) => existingRunIds.has(run.runId));
}
// The second row refreshes a just-finished predecessor. Start with only
// the latest run, then keep every run observed while this task is open.
const visible = new Set([...previous.map((run) => run.runId), ...recent.slice(0, 1).map((run) => run.runId)]);
return [...new Map([...previous, ...recent].filter((run) => visible.has(run.runId)).map((run) => [run.runId, run])).values()];
return {
runs: [...new Map([...previous, ...recent].filter((run) => visible.has(run.runId)).map((run) => [run.runId, run])).values()],
// A first run is already visible. The disclosure needs evidence of an
// omitted run, and must retain that evidence as later turns arrive.
hasEarlierRuns: recent.some((run) => !visible.has(run.runId))
|| (!reconciledFullHistory && recent.length >= 2 && previousWindow?.hasEarlierRuns === true),
};
},
enabled: !!issueId,
refetchInterval:
hasLiveRuns || issueStatus === "in_progress" ? 1000 : false,
placeholderData: historyExpanded ? queryClient.getQueryData<RunForIssue[]>([...queryKeys.issues.runs(issueQueryRef), "recent"]) : undefined,
placeholderData: historyExpanded ? queryClient.getQueryData<RecentIssueRunWindow>([...queryKeys.issues.runs(issueQueryRef), "recent-window"])?.runs : undefined,
});
const linkedRuns = Array.isArray(linkedRunHistory) ? linkedRunHistory : linkedRunHistory?.runs;
const hasEarlierRuns = !Array.isArray(linkedRunHistory) && linkedRunHistory?.hasEarlierRuns === true;
const resolvedActivity = useMemo(
() => [...new Map([...(activity ?? []), ...(inputFacts.data ?? [])].map((event) => [event.id, event])).values()],
[activity, inputFacts.data],
@@ -2560,7 +2576,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
<ThreadComponent
{...(!classicTaskInterfaceEnabled ? {
compactHistory: !historyExpanded,
historyControl: !historyExpanded && (hasEarlierMessages || (linkedRuns?.length ?? 0) > 0) ? (
onExpandHistory,
historyControl: !historyExpanded && (hasEarlierMessages || hasEarlierRuns) ? (
<Button type="button" variant="ghost" size="sm" onClick={onExpandHistory}>
More messages
</Button>
@@ -7661,6 +7678,18 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="end">
{taskChatShellEnabled && !historyExpanded ? (
<button
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50"
onClick={() => {
expandHistory();
setMoreOpen(false);
}}
>
<ActivityIcon className="h-3 w-3" />
Show task activity
</button>
) : null}
{streamlinedTaskDetailEnabled ? (
<>
<button