feat(FN-2901): merge fusion/fn-2901
- fix(FN-2901): reset source issue collapse state on task change - fix(FN-2901): use issue number in collapsed source issue summary - feat(FN-2901): complete Step 5 — finalize source issue toggle UX - test(FN-2901): complete Step 4 — verify collapsible source issue behavior - feat(FN-2901): complete Step 3 — style collapsible source issue header - feat(FN-2901): complete Step 2 — add collapsible source issue section - feat(FN-2901): complete Step 1 — update lucide imports - feat(FN-2729): merge fusion/fn-2729 - fix(engine): prevent auto-merge cooldown loop on unresolvable conflicts - feat(FN-2911): improve chat attachment compose and preview UX Fusion-Task-Id: FN-2901
This commit is contained in:
@@ -438,7 +438,7 @@ describe("Settings view", () => {
|
|||||||
await waitForFrameContains(lastFrame, "Remote");
|
await waitForFrameContains(lastFrame, "Remote");
|
||||||
expect(lastFrame() ?? "").toContain("cloudflare");
|
expect(lastFrame() ?? "").toContain("cloudflare");
|
||||||
|
|
||||||
stdin.write("\t");
|
stdin.write("\u001B[C");
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
stdin.write("C");
|
stdin.write("C");
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
@@ -470,9 +470,9 @@ describe("Settings view", () => {
|
|||||||
|
|
||||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||||
await waitForFrameContains(lastFrame, "──── Remote ────");
|
await waitForFrameContains(lastFrame, "──── Remote ────");
|
||||||
stdin.write("\t");
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
|
|
||||||
|
stdin.write("\u001B[C");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
stdin.write("L");
|
stdin.write("L");
|
||||||
await waitForFrameContains(lastFrame, "TTL ms:");
|
await waitForFrameContains(lastFrame, "TTL ms:");
|
||||||
stdin.write("\r");
|
stdin.write("\r");
|
||||||
@@ -508,9 +508,8 @@ describe("Settings view", () => {
|
|||||||
controller.setInteractiveView("settings");
|
controller.setInteractiveView("settings");
|
||||||
|
|
||||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||||
stdin.write("\t");
|
stdin.write("\u001B[C");
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
stdin.write("P");
|
stdin.write("P");
|
||||||
await waitForFrameContains(lastFrame, "Persistent token: tok_****");
|
await waitForFrameContains(lastFrame, "Persistent token: tok_****");
|
||||||
expect(regeneratePersistentToken).toHaveBeenCalledTimes(1);
|
expect(regeneratePersistentToken).toHaveBeenCalledTimes(1);
|
||||||
@@ -531,9 +530,9 @@ describe("Settings view", () => {
|
|||||||
|
|
||||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||||
await waitForFrameContains(lastFrame, "──── Remote ────");
|
await waitForFrameContains(lastFrame, "──── Remote ────");
|
||||||
stdin.write("\t");
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
|
|
||||||
|
stdin.write("\u001B[C");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
stdin.write("L");
|
stdin.write("L");
|
||||||
await waitForFrameContains(lastFrame, "TTL ms:");
|
await waitForFrameContains(lastFrame, "TTL ms:");
|
||||||
stdin.write("a");
|
stdin.write("a");
|
||||||
@@ -559,7 +558,7 @@ describe("Settings view", () => {
|
|||||||
controller.setInteractiveView("settings");
|
controller.setInteractiveView("settings");
|
||||||
|
|
||||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||||
stdin.write("\t");
|
stdin.write("\u001B[C");
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
stdin.write("K");
|
stdin.write("K");
|
||||||
await waitForFrameContains(lastFrame, "QR SVG returned by server.");
|
await waitForFrameContains(lastFrame, "QR SVG returned by server.");
|
||||||
|
|||||||
@@ -251,6 +251,64 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-source-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source-number {
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source-toggle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source-chevron--expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source-section .detail-source-grid {
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
padding-top: var(--space-md);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* Agent Log tab: stretch to fill the remaining modal body height
|
/* Agent Log tab: stretch to fill the remaining modal body height
|
||||||
so the log viewer uses all available space above the action bar. */
|
so the log viewer uses all available space above the action bar. */
|
||||||
.detail-section--agent-log {
|
.detail-section--agent-log {
|
||||||
@@ -1544,6 +1602,14 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-source-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source-summary {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-body {
|
.detail-body {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "./TaskDetailModal.css";
|
import "./TaskDetailModal.css";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Bot, X, ChevronDown } from "lucide-react";
|
import { Pencil, Bot, X, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
@@ -359,6 +359,7 @@ export function TaskDetailModal({
|
|||||||
// Split-menu dropdown state for footer actions
|
// Split-menu dropdown state for footer actions
|
||||||
const [showMoveMenu, setShowMoveMenu] = useState(false);
|
const [showMoveMenu, setShowMoveMenu] = useState(false);
|
||||||
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
||||||
|
const [sourceIssueExpanded, setSourceIssueExpanded] = useState(false);
|
||||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const moveButtonRef = useRef<HTMLButtonElement>(null);
|
const moveButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const actionsMenuRef = useRef<HTMLDivElement>(null);
|
const actionsMenuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -395,6 +396,7 @@ export function TaskDetailModal({
|
|||||||
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
|
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
|
||||||
setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
|
setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
|
||||||
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
||||||
|
setSourceIssueExpanded(false);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}, [task.id, task.title, task.description, task.sourceIssue, task.executionMode]);
|
}, [task.id, task.title, task.description, task.sourceIssue, task.executionMode]);
|
||||||
|
|
||||||
@@ -1695,38 +1697,68 @@ export function TaskDetailModal({
|
|||||||
<MergeDetails task={task} />
|
<MergeDetails task={task} />
|
||||||
{task.sourceIssue && (
|
{task.sourceIssue && (
|
||||||
<div className="detail-section detail-source-section">
|
<div className="detail-section detail-source-section">
|
||||||
<h4>Source Issue</h4>
|
<div className="detail-source-header">
|
||||||
<dl className="detail-source-grid">
|
<div className="detail-source-summary">
|
||||||
<div>
|
<span className="detail-source-label">Source issue</span>
|
||||||
<dt>Provider</dt>
|
{task.sourceIssue.url ? (
|
||||||
<dd>{task.sourceIssue.provider}</dd>
|
<a
|
||||||
|
className="detail-source-link detail-source-number"
|
||||||
|
href={task.sourceIssue.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{`(#${task.sourceIssue.issueNumber})`}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="detail-source-number">{`(#${task.sourceIssue.issueNumber})`}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<button
|
||||||
<dt>Repository</dt>
|
type="button"
|
||||||
<dd>{task.sourceIssue.repository}</dd>
|
className="detail-source-toggle"
|
||||||
</div>
|
aria-expanded={sourceIssueExpanded}
|
||||||
<div>
|
aria-label={sourceIssueExpanded ? "Collapse source issue details" : "Expand source issue details"}
|
||||||
<dt>Issue Identifier</dt>
|
onClick={() => setSourceIssueExpanded((expanded) => !expanded)}
|
||||||
<dd>{task.sourceIssue.externalIssueId}</dd>
|
>
|
||||||
</div>
|
<ChevronRight
|
||||||
<div>
|
size={16}
|
||||||
<dt>URL</dt>
|
className={sourceIssueExpanded ? "detail-source-chevron--expanded" : undefined}
|
||||||
<dd>
|
/>
|
||||||
{task.sourceIssue.url ? (
|
</button>
|
||||||
<a
|
</div>
|
||||||
className="detail-source-link"
|
{sourceIssueExpanded && (
|
||||||
href={task.sourceIssue.url}
|
<dl className="detail-source-grid">
|
||||||
target="_blank"
|
<div>
|
||||||
rel="noopener noreferrer"
|
<dt>Provider</dt>
|
||||||
>
|
<dd>{task.sourceIssue.provider}</dd>
|
||||||
{task.sourceIssue.url}
|
</div>
|
||||||
</a>
|
<div>
|
||||||
) : (
|
<dt>Repository</dt>
|
||||||
<span className="detail-source-empty">(none)</span>
|
<dd>{task.sourceIssue.repository}</dd>
|
||||||
)}
|
</div>
|
||||||
</dd>
|
<div>
|
||||||
</div>
|
<dt>Issue Identifier</dt>
|
||||||
</dl>
|
<dd>{task.sourceIssue.externalIssueId}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>URL</dt>
|
||||||
|
<dd>
|
||||||
|
{task.sourceIssue.url ? (
|
||||||
|
<a
|
||||||
|
className="detail-source-link"
|
||||||
|
href={task.sourceIssue.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{task.sourceIssue.url}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="detail-source-empty">(none)</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="detail-section detail-agent-section">
|
<div className="detail-section detail-agent-section">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
MessageSquare: () => null,
|
MessageSquare: () => null,
|
||||||
ChevronUp: () => null,
|
ChevronUp: () => null,
|
||||||
ChevronDown: () => null,
|
ChevronDown: () => null,
|
||||||
ChevronRight: () => null,
|
ChevronRight: (props: any) => <svg data-testid="chevron-right-icon" {...props} />,
|
||||||
X: () => null,
|
X: () => null,
|
||||||
Maximize2: () => null,
|
Maximize2: () => null,
|
||||||
Minimize2: () => null,
|
Minimize2: () => null,
|
||||||
@@ -4193,7 +4193,8 @@ describe("TaskDetailModal", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders source issue fields and link in read mode", () => {
|
it("renders source issue collapsed by default and expands details on toggle", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
task={makeTask({
|
task={makeTask({
|
||||||
@@ -4214,13 +4215,28 @@ describe("TaskDetailModal", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Source Issue")).toBeTruthy();
|
expect(screen.getByText("Source issue")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("link", { name: "(#2473)" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://github.com/runfusion/fusion/issues/2473",
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Provider")).toBeNull();
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("button", { name: "Expand source issue details" });
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
const chevron = screen.getByTestId("chevron-right-icon");
|
||||||
|
expect(chevron.classList.contains("detail-source-chevron--expanded")).toBe(false);
|
||||||
|
|
||||||
|
await user.click(toggle);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Collapse source issue details" })).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(screen.getByText("Provider")).toBeTruthy();
|
||||||
expect(screen.getByText("github")).toBeTruthy();
|
expect(screen.getByText("github")).toBeTruthy();
|
||||||
expect(screen.getByText("runfusion/fusion")).toBeTruthy();
|
expect(screen.getByText("runfusion/fusion")).toBeTruthy();
|
||||||
expect(screen.getByText("I_kgDOExample")).toBeTruthy();
|
|
||||||
const sourceLink = screen.getByRole("link", { name: "https://github.com/runfusion/fusion/issues/2473" });
|
const sourceLink = screen.getByRole("link", { name: "https://github.com/runfusion/fusion/issues/2473" });
|
||||||
expect(sourceLink).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/2473");
|
expect(sourceLink).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/2473");
|
||||||
expect(sourceLink).toHaveAttribute("target", "_blank");
|
expect(sourceLink).toHaveAttribute("target", "_blank");
|
||||||
|
expect(screen.getByTestId("chevron-right-icon").classList.contains("detail-source-chevron--expanded")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides source issue read section when sourceIssue metadata is missing", () => {
|
it("hides source issue read section when sourceIssue metadata is missing", () => {
|
||||||
@@ -4236,7 +4252,7 @@ describe("TaskDetailModal", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByText("Source Issue")).toBeNull();
|
expect(screen.queryByText("Source issue")).toBeNull();
|
||||||
expect(screen.queryByText("No source issue metadata recorded.")).toBeNull();
|
expect(screen.queryByText("No source issue metadata recorded.")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user