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.",
|
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 {
|
interface ParsedInsightCategory {
|
||||||
name: string;
|
name: string;
|
||||||
key: string;
|
key: string;
|
||||||
@@ -437,8 +455,8 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
|||||||
disabled={selectedFileDirty}
|
disabled={selectedFileDirty}
|
||||||
>
|
>
|
||||||
{memoryFiles.map((file) => (
|
{memoryFiles.map((file) => (
|
||||||
<option key={file.path} value={file.path}>
|
<option key={file.path} value={file.path} title={`${file.label} — ${file.path}`}>
|
||||||
{file.label} - {file.path}
|
{formatMemoryFileOptionLabel(file)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -172,6 +172,23 @@ type SettingsSection = {
|
|||||||
|
|
||||||
const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)";
|
const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)";
|
||||||
const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md";
|
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[] = [
|
const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||||
// Account group (scope-less items — independent of settings storage)
|
// Account group (scope-less items — independent of settings storage)
|
||||||
@@ -3672,8 +3689,8 @@ export function SettingsModal({
|
|||||||
disabled={memoryDirty}
|
disabled={memoryDirty}
|
||||||
>
|
>
|
||||||
{memoryFiles.map((file) => (
|
{memoryFiles.map((file) => (
|
||||||
<option key={file.path} value={file.path}>
|
<option key={file.path} value={file.path} title={`${file.label} — ${file.path}`}>
|
||||||
{file.label} - {file.path}
|
{formatMemoryFileOptionLabel(file)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -41,11 +41,12 @@
|
|||||||
|
|
||||||
.workspace-selector-menu {
|
.workspace-selector-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + var(--space-sm));
|
||||||
left: 0;
|
left: auto;
|
||||||
|
right: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
max-width: 360px;
|
max-width: min(360px, calc(100vw - var(--space-xl)));
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -117,10 +118,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspace-selector-menu {
|
.workspace-selector-menu {
|
||||||
left: auto;
|
min-width: min(300px, calc(100vw - var(--space-xl)));
|
||||||
right: 0;
|
max-width: calc(100vw - var(--space-xl));
|
||||||
min-width: min(300px, calc(100vw - 24px));
|
|
||||||
max-width: calc(100vw - 24px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-selector-option-meta {
|
.workspace-selector-option-meta {
|
||||||
|
|||||||
@@ -278,6 +278,30 @@ describe("MemoryView", () => {
|
|||||||
const button = screen.getByRole("button", { name: /Dreaming…/i });
|
const button = screen.getByRole("button", { name: /Dreaming…/i });
|
||||||
expect(button).toBeDisabled();
|
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", () => {
|
it("hides Dream Now button when dreams are disabled", () => {
|
||||||
render(<MemoryView addToast={vi.fn()} />);
|
render(<MemoryView addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
|||||||
@@ -1209,6 +1209,31 @@ describe("SettingsModal", () => {
|
|||||||
expect(checkbox).toBeChecked();
|
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 () => {
|
it("shows only loading copy while backend status is unresolved", async () => {
|
||||||
mockUseMemoryBackendStatus.mockReturnValue({
|
mockUseMemoryBackendStatus.mockReturnValue({
|
||||||
// Simulate stale negative payload while a refresh is still in-flight.
|
// 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 userEvent from "@testing-library/user-event";
|
||||||
import { WorkspaceSelector } from "../WorkspaceSelector";
|
import { WorkspaceSelector } from "../WorkspaceSelector";
|
||||||
import type { WorkspaceInfo } from "../../hooks/useWorkspaces";
|
import type { WorkspaceInfo } from "../../hooks/useWorkspaces";
|
||||||
|
import { loadAllAppCss } from "../../test/cssFixture";
|
||||||
|
|
||||||
const workspaces: WorkspaceInfo[] = [
|
const workspaces: WorkspaceInfo[] = [
|
||||||
{
|
{
|
||||||
@@ -89,4 +90,10 @@ describe("WorkspaceSelector", () => {
|
|||||||
expect(screen.getByText(/Implement a very long task title that shoul/)).toBeInTheDocument();
|
expect(screen.getByText(/Implement a very long task title that shoul/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/…$/)).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