feat(FN-3198): improve ListView split-pane UX and add agent runs tab
This merge lands six commits that overhaul ListView split-pane UX across the dashboard and add an agent runs tab to both the ListView and AgentDetailView, with auto-expand behavior when a run is selected. It also fixes overflow issues in the workspace selector (desktop) and clamps long labels in the Fusion-Task-Id: FN-3198
This commit is contained in:
@@ -34,6 +34,24 @@ const MEMORY_LAYER_DESCRIPTIONS: Record<MemoryFileInfo["layer"], string> = {
|
||||
dreams: "Synthesized patterns and open loops promoted from daily memory.",
|
||||
};
|
||||
|
||||
const MEMORY_FILE_OPTION_LABEL_MAX_CHARS = 72;
|
||||
|
||||
function truncateMiddle(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const visibleChars = Math.max(1, maxChars - 1);
|
||||
const startChars = Math.ceil(visibleChars / 2);
|
||||
const endChars = Math.floor(visibleChars / 2);
|
||||
return `${value.slice(0, startChars)}…${value.slice(value.length - endChars)}`;
|
||||
}
|
||||
|
||||
function formatMemoryFileOptionLabel(file: MemoryFileInfo): string {
|
||||
const fullLabel = `${file.label} — ${file.path}`;
|
||||
return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS);
|
||||
}
|
||||
|
||||
interface ParsedInsightCategory {
|
||||
name: string;
|
||||
key: string;
|
||||
@@ -437,8 +455,8 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
disabled={selectedFileDirty}
|
||||
>
|
||||
{memoryFiles.map((file) => (
|
||||
<option key={file.path} value={file.path}>
|
||||
{file.label} - {file.path}
|
||||
<option key={file.path} value={file.path} title={`${file.label} — ${file.path}`}>
|
||||
{formatMemoryFileOptionLabel(file)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -172,6 +172,23 @@ type SettingsSection = {
|
||||
|
||||
const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)";
|
||||
const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md";
|
||||
const MEMORY_FILE_OPTION_LABEL_MAX_CHARS = 72;
|
||||
|
||||
function truncateMiddle(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const visibleChars = Math.max(1, maxChars - 1);
|
||||
const startChars = Math.ceil(visibleChars / 2);
|
||||
const endChars = Math.floor(visibleChars / 2);
|
||||
return `${value.slice(0, startChars)}…${value.slice(value.length - endChars)}`;
|
||||
}
|
||||
|
||||
function formatMemoryFileOptionLabel(file: MemoryFileInfo): string {
|
||||
const fullLabel = `${file.label} — ${file.path}`;
|
||||
return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS);
|
||||
}
|
||||
|
||||
const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
// Account group (scope-less items — independent of settings storage)
|
||||
@@ -3672,8 +3689,8 @@ export function SettingsModal({
|
||||
disabled={memoryDirty}
|
||||
>
|
||||
{memoryFiles.map((file) => (
|
||||
<option key={file.path} value={file.path}>
|
||||
{file.label} - {file.path}
|
||||
<option key={file.path} value={file.path} title={`${file.label} — ${file.path}`}>
|
||||
{formatMemoryFileOptionLabel(file)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -41,11 +41,12 @@
|
||||
|
||||
.workspace-selector-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
top: calc(100% + var(--space-sm));
|
||||
left: auto;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
min-width: 300px;
|
||||
max-width: 360px;
|
||||
max-width: min(360px, calc(100vw - var(--space-xl)));
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -117,10 +118,8 @@
|
||||
}
|
||||
|
||||
.workspace-selector-menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
min-width: min(300px, calc(100vw - 24px));
|
||||
max-width: calc(100vw - 24px);
|
||||
min-width: min(300px, calc(100vw - var(--space-xl)));
|
||||
max-width: calc(100vw - var(--space-xl));
|
||||
}
|
||||
|
||||
.workspace-selector-option-meta {
|
||||
|
||||
@@ -278,6 +278,30 @@ describe("MemoryView", () => {
|
||||
const button = screen.getByRole("button", { name: /Dreaming…/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("truncates long memory file option labels to avoid wide native dropdown expansion", () => {
|
||||
mockUseMemoryData.mockReturnValue(
|
||||
createMemoryData({
|
||||
memoryFiles: [
|
||||
{
|
||||
path: ".fusion/memory/very/deep/path/that/keeps/growing/until/the/browser/native/select/dropdown/can-overflow-on-the-right-edge.md",
|
||||
label: "Long-term memory",
|
||||
layer: "long-term",
|
||||
size: 12,
|
||||
updatedAt: "2026-04-17T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
selectedFilePath: ".fusion/memory/very/deep/path/that/keeps/growing/until/the/browser/native/select/dropdown/can-overflow-on-the-right-edge.md",
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoryView addToast={vi.fn()} />);
|
||||
|
||||
const option = screen.getByRole("option", { name: /Long-term memory/ });
|
||||
expect(option.textContent).toContain("…");
|
||||
expect(option.textContent).not.toContain("dropdown/can-overflow-on-the-right-edge.md");
|
||||
});
|
||||
|
||||
it("hides Dream Now button when dreams are disabled", () => {
|
||||
render(<MemoryView addToast={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -1209,6 +1209,31 @@ describe("SettingsModal", () => {
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("truncates long memory file option labels to keep native dropdown width bounded", async () => {
|
||||
mockFetchMemoryFiles.mockResolvedValue({
|
||||
files: [
|
||||
{
|
||||
path: ".fusion/memory/very/deep/path/that/keeps/growing/until/the/browser/native/select/dropdown/can-overflow-on-the-right-edge.md",
|
||||
label: "Long-term memory",
|
||||
layer: "long-term",
|
||||
size: 42,
|
||||
updatedAt: "2026-04-17T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
await userEvent.click(await screen.findByText("Memory"));
|
||||
|
||||
const option = await screen.findByRole("option", { name: /Long-term memory/ });
|
||||
expect(option.textContent).toContain("…");
|
||||
expect(option.textContent).not.toContain("dropdown/can-overflow-on-the-right-edge.md");
|
||||
});
|
||||
|
||||
it("shows only loading copy while backend status is unresolved", async () => {
|
||||
mockUseMemoryBackendStatus.mockReturnValue({
|
||||
// Simulate stale negative payload while a refresh is still in-flight.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { WorkspaceSelector } from "../WorkspaceSelector";
|
||||
import type { WorkspaceInfo } from "../../hooks/useWorkspaces";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
const workspaces: WorkspaceInfo[] = [
|
||||
{
|
||||
@@ -89,4 +90,10 @@ describe("WorkspaceSelector", () => {
|
||||
expect(screen.getByText(/Implement a very long task title that shoul/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/…$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("anchors the menu to the trigger's right edge on desktop to avoid right overflow", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
expect(css).toMatch(/\.workspace-selector-menu\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user