feat: add sqlite storage and terminal ui
This commit is contained in:
@@ -2,6 +2,7 @@ import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
createLauncherId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
@@ -110,6 +111,11 @@ interface OpenTabInLayoutResult {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface OpenLauncherTabInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
now: number;
|
||||
}
|
||||
|
||||
interface RetargetTabInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
@@ -121,6 +127,17 @@ interface RetargetTabInLayoutResult {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface ConvertDraftToAgentInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface ConvertDraftToAgentInLayoutResult {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface ReorderFocusedPaneTabsInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabIds: string[];
|
||||
@@ -181,6 +198,20 @@ interface ReorderPaneTabsInLayoutInput {
|
||||
tabIds: string[];
|
||||
}
|
||||
|
||||
export interface WorkspaceTabReconcileState {
|
||||
layout: WorkspaceLayout;
|
||||
pinnedAgentIds?: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabSnapshot {
|
||||
agentsHydrated: boolean;
|
||||
terminalsHydrated: boolean;
|
||||
activeAgentIds: Iterable<string>;
|
||||
knownAgentIds: Iterable<string>;
|
||||
standaloneTerminalIds: Iterable<string>;
|
||||
hasActivePendingDraftCreate?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PANE_ID = "main";
|
||||
const MIN_SPLIT_SIZE = 0.1;
|
||||
|
||||
@@ -750,6 +781,37 @@ function updateTabInTree(root: SplitNodeInternal, input: UpdateTabInTreeInput):
|
||||
});
|
||||
}
|
||||
|
||||
function replaceTabInTree(
|
||||
root: SplitNodeInternal,
|
||||
input: {
|
||||
tabId: string;
|
||||
nextTabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
},
|
||||
): SplitNodeInternal {
|
||||
const panePath = findPanePathContainingTab(root, input.tabId);
|
||||
invariant(panePath, `Tab not found: ${input.tabId}`);
|
||||
return replaceNodeAtPath(root, panePath, (node) => {
|
||||
invariant(node.kind === "pane", "Expected pane while replacing tab");
|
||||
return {
|
||||
kind: "pane",
|
||||
pane: normalizePaneAfterTabChange({
|
||||
...node.pane,
|
||||
tabs: node.pane.tabs.map((tab) =>
|
||||
tab.tabId === input.tabId
|
||||
? {
|
||||
...tab,
|
||||
tabId: input.nextTabId,
|
||||
target: input.target,
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
focusedTabId: node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateGroupSizesInTree(
|
||||
root: SplitNodeInternal,
|
||||
input: UpdateGroupSizesInTreeInput,
|
||||
@@ -957,22 +1019,12 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
|
||||
return detachTabFromTree(asInternalNode(root), { tabId }).root;
|
||||
}
|
||||
|
||||
export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||
function insertNewTabIntoFocusedPane(input: {
|
||||
layout: WorkspaceLayout;
|
||||
target: WorkspaceTabTarget;
|
||||
now: number;
|
||||
}): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
if (existingTab) {
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout,
|
||||
tabId: existingTab.tabId,
|
||||
}) ?? input.layout,
|
||||
};
|
||||
}
|
||||
|
||||
const focusedPane =
|
||||
findPaneById(layout.root, layout.focusedPaneId) ??
|
||||
collectAllPanes(layout.root)[0] ??
|
||||
@@ -999,6 +1051,38 @@ export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutRes
|
||||
};
|
||||
}
|
||||
|
||||
export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
if (existingTab) {
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout,
|
||||
tabId: existingTab.tabId,
|
||||
}) ?? input.layout,
|
||||
};
|
||||
}
|
||||
|
||||
return insertNewTabIntoFocusedPane(input);
|
||||
}
|
||||
|
||||
export function openLauncherTabInLayout(
|
||||
input: OpenLauncherTabInLayoutInput,
|
||||
): OpenTabInLayoutResult {
|
||||
return insertNewTabIntoFocusedPane({
|
||||
layout: input.layout,
|
||||
target: {
|
||||
kind: "launcher",
|
||||
launcherId: createLauncherId(),
|
||||
},
|
||||
now: input.now,
|
||||
});
|
||||
}
|
||||
|
||||
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
|
||||
const internalLayout = asInternalLayout(input.layout);
|
||||
const pane = findPaneContainingTab(internalLayout.root, input.tabId);
|
||||
@@ -1054,11 +1138,34 @@ export function retargetTabInLayout(
|
||||
};
|
||||
}
|
||||
|
||||
const existingTargetTab =
|
||||
collectAllTabs(layout.root).find(
|
||||
(tab) => tab.tabId !== input.tabId && workspaceTabTargetsEqual(tab.target, input.target),
|
||||
) ?? null;
|
||||
if (existingTargetTab) {
|
||||
const nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: input.layout,
|
||||
tabId: input.tabId,
|
||||
}) ?? input.layout;
|
||||
return {
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: existingTargetTab.tabId,
|
||||
}) ?? nextLayout,
|
||||
tabId: existingTargetTab.tabId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// Preserve the existing tab id so launcher->entity transitions keep the same
|
||||
// React key during the first render. Reconciliation can canonicalize later.
|
||||
tabId: input.tabId,
|
||||
layout: {
|
||||
root: updateTabInTree(layout.root, {
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId: input.tabId,
|
||||
target: input.target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
@@ -1066,6 +1173,52 @@ export function retargetTabInLayout(
|
||||
};
|
||||
}
|
||||
|
||||
export function convertDraftToAgentInLayout(
|
||||
input: ConvertDraftToAgentInLayoutInput,
|
||||
): ConvertDraftToAgentInLayoutResult | null {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null;
|
||||
if (!currentTab || currentTab.target.kind !== "draft") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target: WorkspaceTabTarget = {
|
||||
kind: "agent",
|
||||
agentId: input.agentId,
|
||||
};
|
||||
const canonicalTabId = buildDeterministicWorkspaceTabId(target);
|
||||
const existingCanonicalTab =
|
||||
collectAllTabs(layout.root).find((tab) => tab.tabId === canonicalTabId) ?? null;
|
||||
|
||||
if (existingCanonicalTab && existingCanonicalTab.tabId !== input.tabId) {
|
||||
const nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: input.layout,
|
||||
tabId: input.tabId,
|
||||
}) ?? input.layout;
|
||||
return {
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: canonicalTabId,
|
||||
}) ?? nextLayout,
|
||||
tabId: canonicalTabId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tabId: canonicalTabId,
|
||||
layout: {
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderFocusedPaneTabsInLayout(
|
||||
input: ReorderFocusedPaneTabsInLayoutInput,
|
||||
): WorkspaceLayout | null {
|
||||
@@ -1234,3 +1387,201 @@ export function reorderPaneTabsInLayout(
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStringSet(values: Iterable<string>): Set<string> {
|
||||
const next = new Set<string>();
|
||||
for (const value of values) {
|
||||
const normalized = trimNonEmpty(value);
|
||||
if (normalized) {
|
||||
next.add(normalized);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isEntityTarget(
|
||||
target: WorkspaceTabTarget,
|
||||
): target is Extract<WorkspaceTabTarget, { kind: "agent" | "terminal" }> {
|
||||
return target.kind === "agent" || target.kind === "terminal";
|
||||
}
|
||||
|
||||
function isAgentTab(tab: WorkspaceTab): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string } } {
|
||||
return tab.target.kind === "agent";
|
||||
}
|
||||
|
||||
function isTerminalTab(
|
||||
tab: WorkspaceTab,
|
||||
): tab is WorkspaceTab & { target: { kind: "terminal"; terminalId: string } } {
|
||||
return tab.target.kind === "terminal";
|
||||
}
|
||||
|
||||
function openEntityTabWithoutFocusing(layout: WorkspaceLayout, target: WorkspaceTabTarget): WorkspaceLayout {
|
||||
const internalLayout = asInternalLayout(layout);
|
||||
const focusedPane =
|
||||
findPaneById(internalLayout.root, internalLayout.focusedPaneId) ??
|
||||
collectAllPanes(internalLayout.root)[0] ??
|
||||
findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID);
|
||||
invariant(focusedPane, "Workspace layout must always have a pane");
|
||||
|
||||
const tabId = buildDeterministicWorkspaceTabId(target);
|
||||
return {
|
||||
root: insertTabIntoPane(internalLayout.root, {
|
||||
paneId: focusedPane.id,
|
||||
tab: {
|
||||
tabId,
|
||||
target,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
focusTabId: focusedPane.focusedTabId ?? tabId,
|
||||
}),
|
||||
focusedPaneId: internalLayout.focusedPaneId,
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileWorkspaceTabs(
|
||||
state: WorkspaceTabReconcileState,
|
||||
snapshot: WorkspaceTabSnapshot,
|
||||
): WorkspaceTabReconcileState {
|
||||
let nextLayout = state.layout;
|
||||
const originalFocusedTabId =
|
||||
findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null;
|
||||
let reconciledFocusedTabId = originalFocusedTabId;
|
||||
const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []);
|
||||
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
|
||||
const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds);
|
||||
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
|
||||
const visibleAgentIds = new Set(activeAgentIds);
|
||||
for (const agentId of pinnedAgentIds) {
|
||||
if (knownAgentIds.has(agentId)) {
|
||||
visibleAgentIds.add(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
const initialTabs = collectAllTabs(nextLayout.root);
|
||||
const representedAgentIds = new Set(initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId));
|
||||
|
||||
const entityGroups = new Map<
|
||||
string,
|
||||
{
|
||||
target: WorkspaceTabTarget;
|
||||
tabs: WorkspaceTab[];
|
||||
}
|
||||
>();
|
||||
for (const tab of initialTabs) {
|
||||
if (!isEntityTarget(tab.target)) {
|
||||
continue;
|
||||
}
|
||||
const canonicalTarget = normalizeWorkspaceTabTarget(tab.target);
|
||||
if (!canonicalTarget) {
|
||||
continue;
|
||||
}
|
||||
const canonicalTabId = buildDeterministicWorkspaceTabId(canonicalTarget);
|
||||
const currentGroup = entityGroups.get(canonicalTabId);
|
||||
if (currentGroup) {
|
||||
currentGroup.tabs.push(tab);
|
||||
continue;
|
||||
}
|
||||
entityGroups.set(canonicalTabId, {
|
||||
target: canonicalTarget,
|
||||
tabs: [tab],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [canonicalTabId, group] of entityGroups) {
|
||||
const keeper = group.tabs.find((tab) => tab.tabId === canonicalTabId) ?? group.tabs[0] ?? null;
|
||||
if (!keeper) {
|
||||
continue;
|
||||
}
|
||||
if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) {
|
||||
reconciledFocusedTabId = canonicalTabId;
|
||||
}
|
||||
if (
|
||||
keeper.tabId !== canonicalTabId ||
|
||||
!workspaceTabTargetsEqual(keeper.target, group.target)
|
||||
) {
|
||||
nextLayout = {
|
||||
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
|
||||
tabId: keeper.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target: group.target,
|
||||
}),
|
||||
focusedPaneId: nextLayout.focusedPaneId,
|
||||
};
|
||||
}
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.tabId === keeper.tabId) {
|
||||
continue;
|
||||
}
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: tab.tabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
}
|
||||
|
||||
for (const tab of collectAllTabs(nextLayout.root)) {
|
||||
if (isAgentTab(tab) && snapshot.agentsHydrated && !visibleAgentIds.has(tab.target.agentId)) {
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: tab.tabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
if (isTerminalTab(tab) && snapshot.terminalsHydrated && !standaloneTerminalIds.has(tab.target.terminalId)) {
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: tab.tabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
}
|
||||
|
||||
const currentEntityTabs = collectAllTabs(nextLayout.root);
|
||||
const currentAgentIds = new Set(
|
||||
currentEntityTabs.filter(isAgentTab).map((tab) => tab.target.agentId),
|
||||
);
|
||||
const currentTerminalIds = new Set(
|
||||
currentEntityTabs.filter(isTerminalTab).map((tab) => tab.target.terminalId),
|
||||
);
|
||||
|
||||
const sortedVisibleAgentIds = [...visibleAgentIds].sort();
|
||||
for (const agentId of sortedVisibleAgentIds) {
|
||||
if (currentAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
if (snapshot.hasActivePendingDraftCreate && !representedAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
|
||||
kind: "agent",
|
||||
agentId,
|
||||
});
|
||||
currentAgentIds.add(agentId);
|
||||
}
|
||||
|
||||
const sortedTerminalIds = [...standaloneTerminalIds].sort();
|
||||
for (const terminalId of sortedTerminalIds) {
|
||||
if (currentTerminalIds.has(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
|
||||
kind: "terminal",
|
||||
terminalId,
|
||||
});
|
||||
currentTerminalIds.add(terminalId);
|
||||
}
|
||||
|
||||
if (reconciledFocusedTabId) {
|
||||
nextLayout =
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: reconciledFocusedTabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
layout: nextLayout,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user