fix(webui): preserve range selection and turn timing

This commit is contained in:
Xubin Ren
2026-08-15 15:40:52 +08:00
parent 4de728a555
commit e630e78075
9 changed files with 262 additions and 16 deletions
+59 -11
View File
@@ -298,6 +298,7 @@ export const ChatList = memo(function ChatList({
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
() => new Set(),
);
const deleteSelectionAnchorRef = useRef<string | null>(null);
useEffect(() => {
const clearPaneDropTarget = () => setPaneDropTarget(null);
@@ -421,6 +422,7 @@ export const ChatList = memo(function ChatList({
if (event.key !== "Escape") return;
setDeleteSelectionMode(false);
setSelectedDeleteKeys(new Set());
deleteSelectionAnchorRef.current = null;
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
@@ -530,15 +532,39 @@ export const ChatList = memo(function ChatList({
const updated = new Set(updatedChatIds);
const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
group.sessions.flatMap((session) => {
const paneGroup = paneGroups[session.key];
const isWorkbenchTab = paneGroup?.visible
?? ((paneGroup?.panes.length ?? 0) > 1);
return isWorkbenchTab
? paneGroup?.panes.map((pane) => pane.key) ?? [session.key]
: [session.key];
})
))));
const beginDeleteSelection = (keys: string[]) => {
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
setDeleteSelectionMode(true);
setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key))));
setSelectedDeleteKeys(new Set(validKeys));
deleteSelectionAnchorRef.current = validKeys[0] ?? null;
};
const toggleDeleteSelection = (keys: string[]) => {
const toggleDeleteSelection = (
keys: string[],
shiftKey = false,
targetKey = keys[0],
) => {
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
const anchorKey = deleteSelectionAnchorRef.current;
const range = shiftKey && anchorKey && targetKey
? selectionRange(selectableDeleteKeys, anchorKey, targetKey)
: null;
setSelectedDeleteKeys((current) => {
const next = new Set(current);
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
if (range) {
for (const key of range) next.add(key);
return next;
}
const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key));
for (const key of validKeys) {
if (remove) next.delete(key);
@@ -546,10 +572,12 @@ export const ChatList = memo(function ChatList({
}
return next;
});
if (!range) deleteSelectionAnchorRef.current = targetKey ?? null;
};
const closeDeleteSelection = () => {
setDeleteSelectionMode(false);
setSelectedDeleteKeys(new Set());
deleteSelectionAnchorRef.current = null;
};
const requestDeleteItems = (items: SidebarDeleteItem[]) => {
if (items.length === 0) return;
@@ -801,7 +829,11 @@ export const ChatList = memo(function ChatList({
selected={tabSelected}
partiallySelected={tabPartiallySelected}
onToggle={() => togglePaneGroup(s.key)}
onToggleSelection={() => toggleDeleteSelection(tabDeleteKeys)}
onToggleSelection={(shiftKey) => toggleDeleteSelection(
tabDeleteKeys,
shiftKey,
tabDeleteKeys[0],
)}
onRequestRename={onRequestRenameTab
? () => onRequestRenameTab(s.key, title)
: undefined}
@@ -901,9 +933,9 @@ export const ChatList = memo(function ChatList({
<SidebarItemTooltip label={tooltipTitle}>
<button
type="button"
onClick={() => {
onClick={(event) => {
if (deleteSelectionMode) {
toggleDeleteSelection(tabDeleteKeys);
toggleDeleteSelection(tabDeleteKeys, event.shiftKey, s.key);
return;
}
if (!topicActive) onSelect(s.key);
@@ -1143,7 +1175,7 @@ function WorkbenchTabHeader({
selected: boolean;
partiallySelected: boolean;
onToggle: () => void;
onToggleSelection: () => void;
onToggleSelection: (shiftKey: boolean) => void;
onRequestRename?: () => void;
onDissolve?: () => void;
onRequestDelete: () => void;
@@ -1167,7 +1199,10 @@ function WorkbenchTabHeader({
<SidebarItemTooltip label={title}>
<button
type="button"
onClick={deleteSelectionMode ? onToggleSelection : onToggle}
onClick={(event) => {
if (deleteSelectionMode) onToggleSelection(event.shiftKey);
else onToggle();
}}
draggable={false}
aria-label={t("workbench.tabAria", { title })}
aria-expanded={deleteSelectionMode ? undefined : !collapsed}
@@ -1313,7 +1348,11 @@ function ActivePaneRows({
) => void;
deleteSelectionMode: boolean;
selectedDeleteKeys: ReadonlySet<string>;
onToggleDeleteSelection: (keys: string[]) => void;
onToggleDeleteSelection: (
keys: string[],
shiftKey?: boolean,
targetKey?: string,
) => void;
onBeginDeleteSelection: (keys: string[]) => void;
actionMenuPortalContainer?: HTMLElement | null;
actionMenus: SidebarActionMenuController;
@@ -1368,9 +1407,9 @@ function ActivePaneRows({
<SidebarItemTooltip label={pane.title}>
<button
type="button"
onClick={() => {
onClick={(event) => {
if (deleteSelectionMode) {
onToggleDeleteSelection([pane.key]);
onToggleDeleteSelection([pane.key], event.shiftKey, pane.key);
return;
}
onSelectPane?.(group.tabKey, pane.key);
@@ -1484,6 +1523,15 @@ function ActivePaneRows({
);
}
function selectionRange(order: string[], anchorKey: string, targetKey: string): string[] | null {
const anchorIndex = order.indexOf(anchorKey);
const targetIndex = order.indexOf(targetKey);
if (anchorIndex < 0 || targetIndex < 0) return null;
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
return order.slice(start, end + 1);
}
function SelectionIndicator({
checked,
partial,
+21 -3
View File
@@ -76,8 +76,10 @@ export function ThreadMessages({
);
const forkFlags = useMemo(() => assistantForkFlags(units), [units]);
const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units],
() => isStreaming
? currentActivityClusterIndices(units, activeTurnId)
: new Set<number>(),
[activeTurnId, isStreaming, units],
);
const pendingTurn = useMemo(
() => pendingTurnProjection(messages, activeTurnId),
@@ -362,8 +364,24 @@ function ForkBoundaryDivider({ label }: { label: string }) {
);
}
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
function currentActivityClusterIndices(
units: DisplayUnit[],
activeTurnId: string | null,
): Set<number> {
const indices = new Set<number>();
if (activeTurnId) {
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (
unit.type === "activity"
&& unit.messages.some((message) => message.turnId === activeTurnId)
) {
indices.add(i);
return indices;
}
}
}
let markedCurrentActivity = false;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
+10 -2
View File
@@ -814,8 +814,11 @@ export function ThreadShell({
const rememberedViewportTurnId = chatId
? activeViewportTurnByChatIdRef.current.get(chatId) ?? null
: null;
const canonicalRunTurnId = chatId && messagesReady && turnActive
? client.getRunTurnId(chatId)
: null;
const viewportTurnId = messagesReady && turnActive
? rememberedViewportTurnId ?? restoredViewportTurnId
? canonicalRunTurnId ?? rememberedViewportTurnId ?? restoredViewportTurnId
: null;
const activeTurnStartedHere =
viewportTurnId !== null && viewportTurnId === submittedViewportTurnId;
@@ -1313,7 +1316,12 @@ export function ThreadShell({
(content: string, images?: SendAttachment[], options?: SendOptions) => {
setFallbackModelName(null);
const submitted = send(content, images, withWorkspaceScope(options));
if (chatId && submitted && !submitted.sideChannel) {
if (
chatId
&& submitted
&& !submitted.sideChannel
&& options?.continueActiveTurn !== true
) {
activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
setSubmittedViewportTurnId(submitted.turnId);
}