fix(webui): preserve range selection and turn timing
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user