mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(desktop): the transcript folds what an earlier compaction replaced, as Chat-Compaction draws it
After the second compaction the content before the second-latest compaction
folds behind one divider line at the top of the conversation ("N earlier
messages folded into a digest · show the folded content"); the stretch the
latest compaction closed stays on screen, so a fresh compaction never leaves the
page empty, and each further compaction folds one more stretch into the same
line. The fold is display only, always starts folded, and opens in place.
This commit is contained in:
1 parent
58c0b7b4a8
commit
7b43b14559
8 files changed
+147
-1
No files matched your search
@@ -159,6 +159,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "composer.send-shortcut", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "composer.running-message-intent", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.unfold-compacted", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.thinking-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -151,3 +151,10 @@
|
||||
}
|
||||
.step-err code { display: block; overflow-wrap: anywhere; white-space: pre-wrap; font: inherit; }
|
||||
.step-err .step-stop { font-family: var(--ui); }
|
||||
.flow .fold-earlier { display: flex; align-items: center; gap: 8px; width: 100%; max-width: var(--column); margin: 10px auto 6px; padding: 0; border: 0; background: none; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); cursor: pointer; }
|
||||
.flow .fold-earlier > i { flex: 1; height: 1px; background: var(--hair); }
|
||||
.flow .fold-earlier .studio-icon { width: 13px; height: 13px; flex: none; }
|
||||
.flow .fold-earlier b { color: var(--text); font-weight: var(--w-med); }
|
||||
.flow .fold-earlier b::before { content: "· "; }
|
||||
.flow .fold-earlier:hover span { color: var(--muted); }
|
||||
.flow .fold-earlier[aria-expanded="true"] .studio-icon { transform: rotate(180deg); }
|
||||
@@ -537,4 +537,7 @@ export const CHAT: Messages = {
|
||||
"chat.page_number": { zh: "页码", en: "Page number" },
|
||||
"chat.zoom_in": { zh: "放大", en: "Zoom in" },
|
||||
"chat.zoom_out": { zh: "缩小", en: "Zoom out" },
|
||||
"chat.n_earlier_messages_folded": { zh: "较早的 {n} 条消息已折叠为简报", en: "{n} earlier messages folded into a digest" },
|
||||
"chat.show_the_folded_content": { zh: "查看被折叠的内容", en: "Show the folded content" },
|
||||
"chat.hide_the_folded_content": { zh: "收起较早的内容", en: "Fold the earlier content again" },
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { Item } from "../state/session";
|
||||
import { compactionFold } from "./compactionfold";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
/** What the transcript shows once earlier compactions fold what they replaced,
|
||||
* and the divider line that opens and closes that fold. */
|
||||
export function useEarlierFold(every: Item[]): { items: Item[]; bar: ReactNode } {
|
||||
const [unfolded, setUnfolded] = useState(false);
|
||||
const earlier = useMemo(() => compactionFold(every), [every]);
|
||||
const items = useMemo(() => (unfolded ? every : every.slice(earlier.cut)), [every, earlier.cut, unfolded]);
|
||||
const bar = earlier.cut > 0 && (
|
||||
<button type="button" className="fold-earlier" data-action="transcript.unfold-compacted" aria-expanded={unfolded} onClick={() => setUnfolded((on) => !on)}>
|
||||
<i aria-hidden="true" />
|
||||
<StudioIcon name="down" />
|
||||
<span>{t("chat.n_earlier_messages_folded", { n: earlier.folded })}</span>
|
||||
<b>{t(unfolded ? "chat.hide_the_folded_content" : "chat.show_the_folded_content")}</b>
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
return { items, bar };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, memo, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type ReactNode, type RefObject, type SetStateAction } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useEarlierFold } from "./EarlierFold";
|
||||
import type { Item, Waiting } from "../state/session";
|
||||
import type { ExtensionSurface } from "../port/wire";
|
||||
import type { AgentPort, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope } from "../port/port";
|
||||
@@ -103,7 +104,7 @@ interface Props {
|
||||
brought?: { ids: string[]; bring: (id: string) => void; dismiss: (id: string) => void };
|
||||
}
|
||||
|
||||
export function Transcript({ items, port, historyPaging, entering, onEntered, brought, revision, waiting, scroll, hidden, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onSettings, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere, foldWork = false, turnRunning = false, scope = "" }: Props) {
|
||||
export function Transcript({ items: every, port, historyPaging, entering, onEntered, brought, revision, waiting, scroll, hidden, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onSettings, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere, foldWork = false, turnRunning = false, scope = "" }: Props) {
|
||||
// A block the selection touches must not leave the DOM. Unmounting the node a
|
||||
// selection is anchored to makes the browser remap that selection onto
|
||||
// whatever is still mounted — which reads as "I selected up there and the
|
||||
@@ -111,6 +112,7 @@ export function Transcript({ items, port, historyPaging, entering, onEntered, br
|
||||
// neither end inside it, is held as plain text from the data: mounting its
|
||||
// cards in one synchronous render is what made a long drag freeze.
|
||||
const [held, setHeld] = useState<Map<number, Held>>(new Map());
|
||||
const { items, bar: earlierBar } = useEarlierFold(every);
|
||||
|
||||
// Stick to the bottom only while the reader is already there; scrolling up
|
||||
// must not be yanked back by incoming frames.
|
||||
@@ -487,6 +489,7 @@ export function Transcript({ items, port, historyPaging, entering, onEntered, br
|
||||
{/* 空转录是「窗口的空白」,壁纸该在那儿;一有内容它就是内容了。 */}
|
||||
<div className="flow" ref={flow} data-empty={items.length === 0 && !historyPaging?.hasMore && !historyPaging?.busy && !historyPaging?.failed ? "" : undefined}>
|
||||
<HistoryPageControl paging={historyPaging} load={loadOlder} />
|
||||
{earlierBar}
|
||||
{items.length === 0 && !historyPaging?.hasMore && !historyPaging?.busy && !historyPaging?.failed && (
|
||||
<Hero needsProject={needsProject} onOpen={onOpenProject} onKeep={onKeepHere} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Item } from "../state/session";
|
||||
import { compactionFold } from "./compactionfold";
|
||||
|
||||
const user = (id: string): Item => ({ t: "user", id, text: id }) as Item;
|
||||
const say = (id: string): Item => ({ t: "say", id, text: id, done: true }) as Item;
|
||||
const fold = (id: string, over: { messages?: number; done?: boolean } = {}): Item =>
|
||||
({ t: "compaction", id, c: { messages: over.messages ?? 12 }, done: over.done ?? true }) as Item;
|
||||
|
||||
describe("what an earlier compaction folds away", () => {
|
||||
it("folds nothing before the second compaction: the stretch the first one closed stays", () => {
|
||||
expect(compactionFold([user("a"), say("b"), fold("c1"), user("d"), say("e")])).toEqual({ cut: 0, folded: 0 });
|
||||
});
|
||||
|
||||
it("folds only what lies before the second-latest compaction, and counts its messages", () => {
|
||||
const items = [user("a"), say("b"), user("c"), say("d"), fold("c1"), user("e"), say("f"), fold("c2"), user("g")];
|
||||
expect(compactionFold(items)).toEqual({ cut: 4, folded: 4 });
|
||||
});
|
||||
|
||||
it("moves one compaction forward each time, never folding the previous stretch", () => {
|
||||
const items = [user("a"), fold("c1"), user("b"), fold("c2"), user("c"), fold("c3"), user("d")];
|
||||
expect(compactionFold(items).cut).toBe(3);
|
||||
expect(compactionFold(items.slice(0, 5)).cut).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores a compaction that is still running or folded nothing", () => {
|
||||
const running = [user("a"), fold("c1"), user("b"), fold("c2", { done: false })];
|
||||
expect(compactionFold(running).cut).toBe(0);
|
||||
const empty = [user("a"), fold("c1"), user("b"), fold("c2", { messages: 0 }), user("c")];
|
||||
expect(compactionFold(empty).cut).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Item } from "../state/session";
|
||||
|
||||
/** Where the visible transcript starts once earlier compactions fold what they
|
||||
* replaced. One fold only: the content up to the second-latest compaction goes
|
||||
* behind it, so the stretch the latest compaction itself closed stays on screen
|
||||
* and a fresh compaction never leaves the page empty. A compaction that folded
|
||||
* nothing, or has not finished, is not a boundary. */
|
||||
export function compactionFold(items: readonly Item[]): { cut: number; folded: number } {
|
||||
const marks: number[] = [];
|
||||
items.forEach((item, i) => {
|
||||
if (item.t === "compaction" && item.done && (item.c.messages ?? 0) > 0) marks.push(i);
|
||||
});
|
||||
if (marks.length < 2) return { cut: 0, folded: 0 };
|
||||
const cut = marks[marks.length - 2];
|
||||
const folded = items.slice(0, cut).filter((item) => item.t === "user" || item.t === "say").length;
|
||||
return { cut, folded };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { Transcript } from "./Transcript";
|
||||
import type { Item } from "../state/session";
|
||||
|
||||
const renders = vi.hoisted(() => new Map<string, number>());
|
||||
vi.mock("./cards/SayCard", () => ({
|
||||
SayCard: ({ item }: { item: { id: string; text: string } }) => {
|
||||
renders.set(item.id, (renders.get(item.id) ?? 0) + 1);
|
||||
return <p data-say={item.id}>{item.text}</p>;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
renders.clear();
|
||||
});
|
||||
|
||||
const noop = async () => undefined as never;
|
||||
const same = { entering: [] as string[], onEntered: () => {}, waiting: {}, scroll: { current: null }, onExtInvoke: () => {}, checkpoints: new Map(), onOpenProject: () => {}, onKeepHere: () => {}, takeovers: {} };
|
||||
const view = (items: Item[]) => (
|
||||
<Transcript items={items} {...same} revision={1} hidden={false} focus={null} onApprove={noop} onFullAccess={noop} onPlan={noop} onAnswer={noop} onForget={noop}
|
||||
onExtSubmit={noop} onPrepareRewind={noop} onCommitRewind={noop} onPrepareFileRevert={noop} onCommitFileRevert={noop} needsProject={false} />
|
||||
);
|
||||
|
||||
|
||||
const turns = (n: number): Item[] => Array.from({ length: n }, (_, i): Item[] => [
|
||||
{ t: "user", id: `u${i}`, text: `q${i}`, authoredTurn: i, msgIndex: i * 2 },
|
||||
{ t: "say", id: `s${i}`, text: `a${i}`, done: true },
|
||||
]).flat();
|
||||
const squash = (id: string, messages = 8): Item => ({ t: "compaction", id, c: { messages }, done: true });
|
||||
const saying = () => [...document.querySelectorAll("[data-say]")].map((e) => e.getAttribute("data-say"));
|
||||
|
||||
describe("earlier compactions in a transcript", () => {
|
||||
it("keeps everything on screen after the first compaction", () => {
|
||||
render(view([...turns(2), squash("c1"), ...turns(1)].map((it, i) => (it.t === "say" ? { ...it, id: `s${i}` } : it))));
|
||||
expect(screen.queryByRole("button", { name: /查看被折叠的内容/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("folds only the stretch before the second-latest compaction, behind one bar that opens and closes it", () => {
|
||||
const items: Item[] = [
|
||||
{ t: "user", id: "u0", text: "q0", authoredTurn: 0, msgIndex: 0 }, { t: "say", id: "old", text: "oldest", done: true },
|
||||
squash("c1"),
|
||||
{ t: "user", id: "u1", text: "q1", authoredTurn: 1, msgIndex: 2 }, { t: "say", id: "mid", text: "kept", done: true },
|
||||
squash("c2"),
|
||||
{ t: "user", id: "u2", text: "q2", authoredTurn: 2, msgIndex: 4 }, { t: "say", id: "new", text: "latest", done: true },
|
||||
];
|
||||
render(view(items));
|
||||
expect(saying()).toEqual(["mid", "new"]);
|
||||
const bar = screen.getByRole("button", { name: /较早的 2 条消息已折叠为简报/ });
|
||||
expect(bar.getAttribute("aria-expanded")).toBe("false");
|
||||
fireEvent.click(bar);
|
||||
expect(saying()).toEqual(["old", "mid", "new"]);
|
||||
expect(screen.getByRole("button", { name: /收起较早的内容/ }).getAttribute("aria-expanded")).toBe("true");
|
||||
fireEvent.click(screen.getByRole("button", { name: /收起较早的内容/ }));
|
||||
expect(saying()).toEqual(["mid", "new"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user