mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
* feat(app): open files and subagents in a closable sheet on compact On phones, opening a file, a provider subagent, or a Paseo subagent now shows it in a bottom sheet over the current tab instead of adding a tab, so the tab strip stays for terminals and root agents. Paseo subagents get "Open as tab" to move the same tab (same tabId and state) into a full tab with a composer; agents in a sheet hide the composer. The decision lives in the workspace layout store's openTab, driven by a per-kind `compactSheet` rule in the panel manifest, so every open path (chat links, Explorer, subagents track, notifications, plugins) follows it without call-site branching, and a new kind opts in with one manifest entry. The app shell publishes the layout mode (compact / single-pane / split-panes) once, so callers stop passing isCompact for placement. A sheet holding unsaved edits is never implicitly displaced; later opens go to tabs underneath, and closing it goes through the existing discard guard. The editor host is position: sticky because CodeMirror's scrollIntoView otherwise scrolls the bottom sheet's container and drags the sheet to full height. * test(app): fail the compact sheet snap check when the sheet is oversized --------- Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
|
import { usePaneContext } from "@/panels/pane-context";
|
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
|
|
|
export interface PanelInstanceIdentity {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
tabId: string;
|
|
}
|
|
|
|
export interface PanelInstanceAttributes {
|
|
modified: boolean;
|
|
suspendPendingSave?: () => () => void;
|
|
}
|
|
|
|
const DEFAULT_ATTRIBUTES: PanelInstanceAttributes = { modified: false };
|
|
const attributesByPanel = new Map<string, PanelInstanceAttributes>();
|
|
const listenersByPanel = new Map<string, Set<() => void>>();
|
|
const allListeners = new Set<() => void>();
|
|
let attributesRevision = 0;
|
|
|
|
function buildWorkspacePanelKey(workspaceKey: string, tabId: string): string {
|
|
return `${workspaceKey}:${tabId}`;
|
|
}
|
|
|
|
export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string {
|
|
return buildWorkspacePanelKey(buildWorkspaceTabPersistenceKey(identity) ?? "", identity.tabId);
|
|
}
|
|
|
|
/** Looks a panel up by the workspace key the layout store uses. */
|
|
export function getWorkspacePanelAttributes(
|
|
workspaceKey: string,
|
|
tabId: string,
|
|
): PanelInstanceAttributes {
|
|
return attributesByPanel.get(buildWorkspacePanelKey(workspaceKey, tabId)) ?? DEFAULT_ATTRIBUTES;
|
|
}
|
|
|
|
export function getPanelInstanceAttributes(
|
|
identity: PanelInstanceIdentity,
|
|
): PanelInstanceAttributes {
|
|
return attributesByPanel.get(buildPanelInstanceKey(identity)) ?? DEFAULT_ATTRIBUTES;
|
|
}
|
|
|
|
export function setPanelInstanceAttributes(
|
|
identity: PanelInstanceIdentity,
|
|
attributes: PanelInstanceAttributes,
|
|
): void {
|
|
const key = buildPanelInstanceKey(identity);
|
|
const previous = attributesByPanel.get(key) ?? DEFAULT_ATTRIBUTES;
|
|
if (
|
|
previous.modified === attributes.modified &&
|
|
previous.suspendPendingSave === attributes.suspendPendingSave
|
|
) {
|
|
return;
|
|
}
|
|
if (attributes.modified) attributesByPanel.set(key, attributes);
|
|
else attributesByPanel.delete(key);
|
|
attributesRevision += 1;
|
|
for (const listener of listenersByPanel.get(key) ?? []) listener();
|
|
for (const listener of allListeners) listener();
|
|
}
|
|
|
|
export function useModifiedPanelTabIds(input: {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
tabIds: string[];
|
|
}): Set<string> {
|
|
const revision = useSyncExternalStore(
|
|
useCallback((listener: () => void) => {
|
|
allListeners.add(listener);
|
|
return () => allListeners.delete(listener);
|
|
}, []),
|
|
() => attributesRevision,
|
|
() => attributesRevision,
|
|
);
|
|
return useMemo(() => {
|
|
void revision;
|
|
return new Set(
|
|
input.tabIds.filter(
|
|
(tabId) =>
|
|
getPanelInstanceAttributes({
|
|
serverId: input.serverId,
|
|
workspaceId: input.workspaceId,
|
|
tabId,
|
|
}).modified,
|
|
),
|
|
);
|
|
}, [input.serverId, input.tabIds, input.workspaceId, revision]);
|
|
}
|
|
|
|
export function subscribePanelInstanceAttributes(
|
|
identity: PanelInstanceIdentity,
|
|
listener: () => void,
|
|
): () => void {
|
|
const key = buildPanelInstanceKey(identity);
|
|
const listeners = listenersByPanel.get(key) ?? new Set<() => void>();
|
|
listeners.add(listener);
|
|
listenersByPanel.set(key, listeners);
|
|
return () => {
|
|
listeners.delete(listener);
|
|
if (listeners.size === 0) listenersByPanel.delete(key);
|
|
};
|
|
}
|
|
|
|
export function usePanelInstanceAttributes({
|
|
serverId,
|
|
workspaceId,
|
|
tabId,
|
|
}: PanelInstanceIdentity): PanelInstanceAttributes {
|
|
const subscribe = useCallback(
|
|
(listener: () => void) =>
|
|
subscribePanelInstanceAttributes({ serverId, workspaceId, tabId }, listener),
|
|
[serverId, tabId, workspaceId],
|
|
);
|
|
const getSnapshot = useCallback(
|
|
() => getPanelInstanceAttributes({ serverId, workspaceId, tabId }),
|
|
[serverId, tabId, workspaceId],
|
|
);
|
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
}
|
|
|
|
export function usePublishPanelInstanceAttributes(attributes: PanelInstanceAttributes): void {
|
|
const { serverId, workspaceId, tabId } = usePaneContext();
|
|
const modified = attributes.modified;
|
|
const suspendPendingSave = attributes.suspendPendingSave;
|
|
useEffect(() => {
|
|
const identity = { serverId, workspaceId, tabId };
|
|
setPanelInstanceAttributes(identity, { modified, suspendPendingSave });
|
|
return () => setPanelInstanceAttributes(identity, DEFAULT_ATTRIBUTES);
|
|
}, [modified, serverId, suspendPendingSave, tabId, workspaceId]);
|
|
}
|