Improve review comment UI
This commit is contained in:
@@ -23,7 +23,7 @@ export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; gro
|
||||
|
||||
export interface WorkspaceLayout {
|
||||
root: SplitNode;
|
||||
focusedPaneId: string;
|
||||
focusedPaneId: string | null;
|
||||
}
|
||||
|
||||
interface SplitPaneInternal extends SplitPane {
|
||||
@@ -398,9 +398,9 @@ function asInternalNode(node: SplitNode): SplitNodeInternal {
|
||||
|
||||
function asInternalLayout(layout: WorkspaceLayout): {
|
||||
root: SplitNodeInternal;
|
||||
focusedPaneId: string;
|
||||
focusedPaneId: string | null;
|
||||
} {
|
||||
return layout as { root: SplitNodeInternal; focusedPaneId: string };
|
||||
return layout as { root: SplitNodeInternal; focusedPaneId: string | null };
|
||||
}
|
||||
|
||||
function findPanePathById(
|
||||
@@ -899,11 +899,14 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout {
|
||||
|
||||
const rawLayout = layout as WorkspaceLayout;
|
||||
const root = normalizeNode(rawLayout.root) ?? asInternalNode(createDefaultLayout().root);
|
||||
const focusedPaneId = trimNonEmpty(rawLayout.focusedPaneId);
|
||||
const focusedPaneId =
|
||||
rawLayout.focusedPaneId === null ? null : trimNonEmpty(rawLayout.focusedPaneId);
|
||||
const resolvedFocusedPaneId =
|
||||
(focusedPaneId && findPaneById(root, focusedPaneId)?.id) ??
|
||||
collectAllPanes(root)[0]?.id ??
|
||||
DEFAULT_PANE_ID;
|
||||
focusedPaneId === null
|
||||
? null
|
||||
: ((focusedPaneId && findPaneById(root, focusedPaneId)?.id) ??
|
||||
collectAllPanes(root)[0]?.id ??
|
||||
DEFAULT_PANE_ID);
|
||||
|
||||
return {
|
||||
root,
|
||||
@@ -911,7 +914,10 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout {
|
||||
};
|
||||
}
|
||||
|
||||
export function findPaneById(root: SplitNode, paneId: string): SplitPane | null {
|
||||
export function findPaneById(root: SplitNode, paneId: string | null | undefined): SplitPane | null {
|
||||
if (!paneId) {
|
||||
return null;
|
||||
}
|
||||
const internalRoot = asInternalNode(root);
|
||||
if (internalRoot.kind === "pane") {
|
||||
return internalRoot.pane.id === paneId ? internalRoot.pane : null;
|
||||
@@ -1074,10 +1080,12 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
|
||||
const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id);
|
||||
const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal;
|
||||
const nextFocusedPaneId =
|
||||
findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ??
|
||||
(fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ??
|
||||
collectAllPanes(nextRoot)[0]?.id ??
|
||||
DEFAULT_PANE_ID;
|
||||
internalLayout.focusedPaneId === null
|
||||
? null
|
||||
: (findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ??
|
||||
(fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ??
|
||||
collectAllPanes(nextRoot)[0]?.id ??
|
||||
DEFAULT_PANE_ID);
|
||||
|
||||
return {
|
||||
root: nextRoot,
|
||||
@@ -1204,7 +1212,7 @@ export function reorderFocusedPaneTabsInLayout(
|
||||
input: ReorderFocusedPaneTabsInLayoutInput,
|
||||
): WorkspaceLayout | null {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
if (!findPaneById(layout.root, layout.focusedPaneId)) {
|
||||
if (!layout.focusedPaneId || !findPaneById(layout.root, layout.focusedPaneId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@ describe("workspace-layout-store actions", () => {
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -313,6 +314,66 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(pane.focusedTabId).toBe(secondTabId);
|
||||
});
|
||||
|
||||
it("unfocuses and restores the previous focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const token = store.unfocusPane(workspaceKey);
|
||||
expect(token).toBeTruthy();
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore stale focus after another pane is focused", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
store.splitPane(workspaceKey, {
|
||||
tabId: firstTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
store.focusPane(workspaceKey, "main");
|
||||
|
||||
const token = store.unfocusPane(workspaceKey);
|
||||
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for nested focus restorations before restoring", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const outerToken = store.unfocusPane(workspaceKey);
|
||||
const innerToken = store.unfocusPane(workspaceKey);
|
||||
|
||||
store.restorePaneFocus(workspaceKey, outerToken!);
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, innerToken!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
@@ -68,6 +68,7 @@ interface WorkspaceLayoutStore {
|
||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
@@ -94,6 +95,8 @@ interface WorkspaceLayoutStore {
|
||||
) => string | null;
|
||||
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
||||
focusPane: (workspaceKey: string, paneId: string) => void;
|
||||
unfocusPane: (workspaceKey: string) => string | null;
|
||||
restorePaneFocus: (workspaceKey: string, token: string) => void;
|
||||
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
||||
reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void;
|
||||
pinAgent: (workspaceKey: string, agentId: string) => void;
|
||||
@@ -103,6 +106,11 @@ interface WorkspaceLayoutStore {
|
||||
purgeWorkspace: (workspaceKey: string) => void;
|
||||
}
|
||||
|
||||
interface WorkspaceFocusRestorationState {
|
||||
restorePaneId: string | null;
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
const MAX_TREE_DEPTH = 4;
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -162,6 +170,26 @@ function getWorkspaceLayout(
|
||||
return normalizeLayout(state[workspaceKey] ?? createDefaultLayout());
|
||||
}
|
||||
|
||||
function createFocusRestorationToken(): string {
|
||||
const randomValue =
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `workspace-focus-${randomValue}`;
|
||||
}
|
||||
|
||||
function withoutFocusRestoration(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
): Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace"> | null {
|
||||
if (!(workspaceKey in state.focusRestorationByWorkspace)) {
|
||||
return null;
|
||||
}
|
||||
const { [workspaceKey]: _removed, ...focusRestorationByWorkspace } =
|
||||
state.focusRestorationByWorkspace;
|
||||
return { focusRestorationByWorkspace };
|
||||
}
|
||||
|
||||
export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -169,6 +197,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
openTabFocused: (workspaceKey, target) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
@@ -183,6 +212,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
normalizedTarget.kind !== "agent"
|
||||
? state.hiddenAgentIdsByWorkspace
|
||||
@@ -246,6 +276,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -270,6 +301,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -295,6 +327,9 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
...(result.layout.focusedPaneId !== null
|
||||
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
||||
: {}),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
normalizedTarget.kind !== "agent"
|
||||
? state.hiddenAgentIdsByWorkspace
|
||||
@@ -329,6 +364,9 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
...(result.layout.focusedPaneId !== null
|
||||
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
||||
: {}),
|
||||
hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
|
||||
state.hiddenAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
@@ -429,6 +467,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
@@ -462,6 +501,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
@@ -489,6 +529,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -513,6 +554,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -520,6 +562,90 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
unfocusPane: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = createFocusRestorationToken();
|
||||
set((state) => {
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const currentRestoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey];
|
||||
const restorePaneId = currentRestoration?.restorePaneId ?? layout.focusedPaneId;
|
||||
|
||||
return {
|
||||
focusRestorationByWorkspace: {
|
||||
...state.focusRestorationByWorkspace,
|
||||
[normalizedWorkspaceKey]: {
|
||||
restorePaneId,
|
||||
tokens: [...(currentRestoration?.tokens ?? []), token],
|
||||
},
|
||||
},
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]:
|
||||
layout.focusedPaneId === null ? layout : { ...layout, focusedPaneId: null },
|
||||
},
|
||||
};
|
||||
});
|
||||
return token;
|
||||
},
|
||||
restorePaneFocus: (workspaceKey, token) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedToken = trimNonEmpty(token);
|
||||
if (!normalizedWorkspaceKey || !normalizedToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const restoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey];
|
||||
if (!restoration?.tokens.includes(normalizedToken)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTokens = restoration.tokens.filter((entry) => entry !== normalizedToken);
|
||||
const { [normalizedWorkspaceKey]: _removed, ...remainingRestorations } =
|
||||
state.focusRestorationByWorkspace;
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
|
||||
if (layout.focusedPaneId !== null) {
|
||||
return {
|
||||
focusRestorationByWorkspace: remainingRestorations,
|
||||
};
|
||||
}
|
||||
|
||||
if (nextTokens.length > 0) {
|
||||
return {
|
||||
focusRestorationByWorkspace: {
|
||||
...remainingRestorations,
|
||||
[normalizedWorkspaceKey]: {
|
||||
restorePaneId: restoration.restorePaneId,
|
||||
tokens: nextTokens,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const restorePaneId = findPaneById(layout.root, restoration.restorePaneId)?.id ?? null;
|
||||
if (!restorePaneId) {
|
||||
return {
|
||||
focusRestorationByWorkspace: remainingRestorations,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
focusRestorationByWorkspace: remainingRestorations,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: {
|
||||
...layout,
|
||||
focusedPaneId: restorePaneId,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
resizeSplit: (workspaceKey, groupId, sizes) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedGroupId = trimNonEmpty(groupId);
|
||||
@@ -555,6 +681,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -682,7 +809,8 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
||||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
||||
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace;
|
||||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace;
|
||||
if (!hasAny) {
|
||||
return state;
|
||||
}
|
||||
@@ -694,11 +822,14 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
state.pinnedAgentIdsByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
|
||||
state.hiddenAgentIdsByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
||||
state.focusRestorationByWorkspace;
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
pinnedAgentIdsByWorkspace,
|
||||
hiddenAgentIdsByWorkspace,
|
||||
focusRestorationByWorkspace,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user