Skip to content
Draft
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
144 changes: 62 additions & 82 deletions site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { type RefObject, useEffect, useMemo, useRef, useState } from "react";

type TabLike = {
value: string;
Expand Down Expand Up @@ -38,10 +30,12 @@ export const useTabOverflowKebabMenu = <TTab extends TabLike>({
}: UseTabOverflowKebabMenuOptions<TTab>): UseTabOverflowKebabMenuResult<TTab> => {
const containerRef = useRef<HTMLDivElement>(null);
const tabWidthByValueRef = useRef<Record<string, number>>({});
const tabsRef = useRef(tabs);
const [overflowTabValues, setOverflowTabValues] = useState<string[]>([]);
tabsRef.current = tabs;

const recalculateOverflow = useCallback(() => {
if (!enabled) {
useEffect(() => {
if (!enabled || !isActive) {
setOverflowTabValues([]);
return;
}
Expand All @@ -51,84 +45,70 @@ export const useTabOverflowKebabMenu = <TTab extends TabLike>({
return;
}

for (const tab of tabs) {
const tabElement = container.querySelector<HTMLElement>(
`[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`,
);
if (tabElement) {
tabWidthByValueRef.current[tab.value] = tabElement.offsetWidth;
const recalculateOverflow = () => {
const currentTabs = tabsRef.current;
for (const tab of currentTabs) {
const tabElement = container.querySelector<HTMLElement>(
`[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`,
);
if (tabElement) {
tabWidthByValueRef.current[tab.value] = tabElement.offsetWidth;
}
}
}

const alwaysVisibleTabs = tabs.slice(0, alwaysVisibleTabsCount);
const optionalTabs = tabs.slice(alwaysVisibleTabsCount);
if (optionalTabs.length === 0) {
setOverflowTabValues([]);
return;
}

const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => {
return total + (tabWidthByValueRef.current[tab.value] ?? 0);
}, 0);

const availableWidth = container.clientWidth;
let usedWidth = alwaysVisibleWidth;
const nextOverflowValues: string[] = [];

for (let i = 0; i < optionalTabs.length; i++) {
const tab = optionalTabs[i];
const tabWidth = tabWidthByValueRef.current[tab.value] ?? 0;
const hasMoreTabsAfterCurrent = i < optionalTabs.length - 1;
const widthNeeded =
usedWidth +
tabWidth +
(hasMoreTabsAfterCurrent ? overflowTriggerWidthPx : 0);

if (widthNeeded <= availableWidth) {
usedWidth += tabWidth;
continue;
const alwaysVisibleTabs = currentTabs.slice(0, alwaysVisibleTabsCount);
const optionalTabs = currentTabs.slice(alwaysVisibleTabsCount);
if (optionalTabs.length === 0) {
setOverflowTabValues([]);
return;
}

nextOverflowValues.push(
...optionalTabs.slice(i).map((overflowTab) => overflowTab.value),
);
break;
}

setOverflowTabValues((currentValues) => {
if (
currentValues.length === nextOverflowValues.length &&
currentValues.every(
(value, index) => value === nextOverflowValues[index],
)
) {
return currentValues;
const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => {
return total + (tabWidthByValueRef.current[tab.value] ?? 0);
}, 0);

const availableWidth = container.clientWidth;
let usedWidth = alwaysVisibleWidth;
const nextOverflowValues: string[] = [];

for (let i = 0; i < optionalTabs.length; i++) {
const tab = optionalTabs[i];
const tabWidth = tabWidthByValueRef.current[tab.value] ?? 0;
const hasMoreTabsAfterCurrent = i < optionalTabs.length - 1;
const widthNeeded =
usedWidth +
tabWidth +
(hasMoreTabsAfterCurrent ? overflowTriggerWidthPx : 0);

if (widthNeeded <= availableWidth) {
usedWidth += tabWidth;
continue;
}

nextOverflowValues.push(
...optionalTabs.slice(i).map((overflowTab) => overflowTab.value),
);
break;
}
return nextOverflowValues;
});
}, [alwaysVisibleTabsCount, enabled, overflowTriggerWidthPx, tabs]);

useLayoutEffect(() => {
if (!isActive) {
return;
}
recalculateOverflow();
}, [isActive, recalculateOverflow]);
setOverflowTabValues((currentValues) => {
if (
currentValues.length === nextOverflowValues.length &&
currentValues.every(
(value, index) => value === nextOverflowValues[index],
)
) {
return currentValues;
}
return nextOverflowValues;
});
};

useEffect(() => {
if (!isActive) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const observer = new ResizeObserver(() => {
recalculateOverflow();
});
recalculateOverflow();
const observer = new ResizeObserver(recalculateOverflow);
observer.observe(container);
return () => observer.disconnect();
}, [isActive, recalculateOverflow]);
}, [alwaysVisibleTabsCount, enabled, isActive, overflowTriggerWidthPx]);

const overflowTabValuesSet = useMemo(
() => new Set(overflowTabValues),
Expand All @@ -144,9 +124,9 @@ export const useTabOverflowKebabMenu = <TTab extends TabLike>({
[tabs, overflowTabValuesSet],
);

const getTabMeasureProps = useCallback((tabValue: string) => {
const getTabMeasureProps = (tabValue: string) => {
return { [DATA_ATTR_TAB_VALUE]: tabValue };
}, []);
};

return {
containerRef,
Expand Down
125 changes: 63 additions & 62 deletions site/src/modules/resources/AgentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,7 @@ import {
PlayIcon,
SquareCheckBigIcon,
} from "lucide-react";
import {
type FC,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { type FC, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link as RouterLink } from "react-router";
import AutoSizer from "react-virtualized-auto-sizer";
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
Expand Down Expand Up @@ -162,7 +154,7 @@ export const AgentRow: FC<AgentRowProps> = ({
// This is a bit of a hack on the react-window API to get the scroll position.
// If we're scrolled to the bottom, we want to keep the list scrolled to the bottom.
// This makes it feel similar to a terminal that auto-scrolls downwards!
const handleLogScroll = useCallback((props: ListOnScrollProps) => {
const handleLogScroll = (props: ListOnScrollProps) => {
if (
props.scrollOffset === 0 ||
props.scrollUpdateWasRequested ||
Expand All @@ -179,7 +171,7 @@ export const AgentRow: FC<AgentRowProps> = ({
logListDivRef.current.scrollHeight -
(props.scrollOffset + parent.clientHeight);
setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT);
}, []);
};

const devcontainers = useAgentContainers(agent);

Expand Down Expand Up @@ -211,47 +203,43 @@ export const AgentRow: FC<AgentRowProps> = ({
);

const [selectedLogTab, setSelectedLogTab] = useState("all");
const logTabs = useMemo(
() =>
[
{
title: "All Logs",
value: "all",
},
...agent.log_sources
.filter((logSource) => {
return agentLogs.some(
(log) =>
log.source_id === logSource.id && (log.output?.length ?? 0) > 0,
);
})
.map((logSource) => ({
startIcon: logSource.icon ? (
<ExternalImage
src={logSource.icon}
alt=""
className="size-icon-xs shrink-0"
/>
) : logSource.display_name === STARTUP_SCRIPT_DISPLAY_NAME ? (
<PlayIcon className="size-icon-xs shrink-0" />
) : null,
title: logSource.display_name,
value: logSource.id,
}))
.sort((a, b) => {
// Ensure that "Startup Script" is always the first tab.
const startupPriorityDiff =
Number(a.title !== STARTUP_SCRIPT_DISPLAY_NAME) -
Number(b.title !== STARTUP_SCRIPT_DISPLAY_NAME);
return startupPriorityDiff || a.title.localeCompare(b.title);
}),
] as {
startIcon?: React.ReactNode;
title: string;
value: string;
}[],
[agent.log_sources, agentLogs],
);
const logTabs: {
startIcon?: React.ReactNode;
title: string;
value: string;
}[] = [
{
title: "All Logs",
value: "all",
},
...agent.log_sources
.filter((logSource) => {
return agentLogs.some(
(log) =>
log.source_id === logSource.id && (log.output?.length ?? 0) > 0,
);
})
.map((logSource) => ({
startIcon: logSource.icon ? (
<ExternalImage
src={logSource.icon}
alt=""
className="size-icon-xs shrink-0"
/>
) : logSource.display_name === STARTUP_SCRIPT_DISPLAY_NAME ? (
<PlayIcon className="size-icon-xs shrink-0" />
) : null,
title: logSource.display_name,
value: logSource.id,
}))
.sort((a, b) => {
// Ensure that "Startup Script" is always the first tab.
const startupPriorityDiff =
Number(a.title !== STARTUP_SCRIPT_DISPLAY_NAME) -
Number(b.title !== STARTUP_SCRIPT_DISPLAY_NAME);
return startupPriorityDiff || a.title.localeCompare(b.title);
}),
];
const {
containerRef: logTabsListContainerRef,
visibleTabs: visibleLogTabs,
Expand All @@ -277,16 +265,29 @@ export const AgentRow: FC<AgentRowProps> = ({
level: log.level,
sourceId: log.source_id,
}));
const allLogsText = agentLogs.map((log) => log.output).join("\n");
const selectedLogsText = selectedLogs.map((log) => log.output).join("\n");
const hasSelectedLogs = selectedLogs.length > 0;
const hasAnyLogs = agentLogs.length > 0;
const { showCopiedSuccess, copyToClipboard } = useClipboard();
const selectedLogTabTitle =
logTabs.find((tab) => tab.value === selectedLogTab)?.title ?? "Logs";
const sanitizedTabTitle = selectedLogTabTitle
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const logFilenameSuffix = sanitizedTabTitle || "logs";
const downloadableLogSets = logTabs
.filter((tab) => tab.value !== "all")
.map((tab) => {
const logsText = agentLogs
.filter((log) => log.source_id === tab.value)
.map((log) => log.output)
.join("\n");
const filenameSuffix = tab.title
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
return {
label: tab.title,
filenameSuffix: filenameSuffix || tab.value,
logsText,
startIcon: tab.startIcon,
};
});

return (
<div
Expand Down Expand Up @@ -544,9 +545,9 @@ export const AgentRow: FC<AgentRowProps> = ({
</Button>
<DownloadSelectedAgentLogsButton
agentName={agent.name}
filenameSuffix={logFilenameSuffix}
logsText={selectedLogsText}
disabled={!hasSelectedLogs}
logSets={downloadableLogSets}
allLogsText={allLogsText}
disabled={!hasAnyLogs}
/>
</div>
</div>
Expand Down
Loading
Loading