Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 92 additions & 2 deletions client/interface/Interface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ const GamepadInterface = () => {
const { isOnline, isOffline, wasOffline } = useOfflineStatus();
const { theme, setTheme } = useTheme();
const [lastMapNodeId, setLastMapNodeId] = useState<string | null>(null);
const [pendingNewNode, setPendingNewNode] = useState<
| {
depth: number;
pathIds: string[];
draft: StoryNode;
}
| null
>(null);

// (select menu navigation now handled in useMenuSystem)

Expand Down Expand Up @@ -85,6 +93,7 @@ const GamepadInterface = () => {
setTrees,
setStoryTree,
setSelectionByPath,
createSiblingNode,
} = useStoryTree(menuParams);

// Compute reverse-chronologically ordered trees for menus
Expand Down Expand Up @@ -190,6 +199,7 @@ const GamepadInterface = () => {
if (key === "Backspace") {
// B button exits map and goes to edit mode
setLastMapNodeId(highlightedNode.id);
setPendingNewNode(null);
setActiveMenu("edit");
return;
} else if (key === "`") {
Expand Down Expand Up @@ -245,6 +255,29 @@ const GamepadInterface = () => {
return;
}
} else {
if (!activeMenu && key === "ArrowRight") {
const options = getOptionsAtDepth(currentDepth);
const currentOption = selectedOptions[currentDepth] ?? 0;
if (
currentDepth > 0 &&
options.length > 0 &&
currentOption >= options.length - 1
) {
const path = getCurrentPath();
const pathIds = path.slice(0, currentDepth + 1).map((node) => node.id);
setPendingNewNode({
depth: currentDepth,
pathIds,
draft: {
id: `draft-${Date.now().toString(36)}`,
Copy link

Copilot AI Sep 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Date.now().toString(36) for ID generation could potentially create collisions in rapid succession. Consider using a more robust ID generation method like crypto.randomUUID() or a dedicated UUID library.

Suggested change
id: `draft-${Date.now().toString(36)}`,
id: `draft-${crypto.randomUUID()}`,

Copilot uses AI. Check for mistakes.
text: "",
continuations: [],
},
});
setActiveMenu("edit");
return;
}
}
await handleStoryNavigation(key);
}

Expand Down Expand Up @@ -277,6 +310,7 @@ const GamepadInterface = () => {
});
});
} else if (key === "Backspace" && !activeMenu) {
setPendingNewNode(null);
setActiveMenu("edit");
}
},
Expand All @@ -291,6 +325,11 @@ const GamepadInterface = () => {
setActiveMenu,
setSelectedTreeIndex,
highlightedNode,
getOptionsAtDepth,
currentDepth,
selectedOptions,
getCurrentPath,
setPendingNewNode,
],
);

Expand Down Expand Up @@ -529,8 +568,55 @@ const GamepadInterface = () => {
) : activeMenu === "edit" ? (
<MenuScreen>
<EditMenu
node={getCurrentPath()[currentDepth]}
node={
pendingNewNode?.draft ??
getCurrentPath()[currentDepth] ??
storyTree.root
}
onSave={(text) => {
if (pendingNewNode) {
const buildNode = () => {
if (menuParams.textSplitting) {
const nodeChain = splitTextToNodes(text);
if (nodeChain) {
return nodeChain;
}
}

return {
id: Math.random().toString(36).substring(2, 15),
Copy link

Copilot AI Sep 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ID generation method using Math.random() is inconsistent with the draft ID generation above and could potentially create collisions. Consider using a consistent, more robust ID generation method throughout the codebase.

Copilot uses AI. Check for mistakes.
text,
continuations: [],
} as StoryNode;
};

const newNode = buildNode();
const created = createSiblingNode(
pendingNewNode.pathIds,
pendingNewNode.depth,
newNode,
);

setActiveMenu(null);
setPendingNewNode(null);

if (created) {
requestAnimationFrame(() => {
const path = getCurrentPath();
const last = path[path.length - 1];
if (last) {
queueScroll({
nodeId: last.id,
reason: "edit-save",
priority: 60,
});
}
});
}

return;
}

const newTree = JSON.parse(JSON.stringify(storyTree)) as {
root: StoryNode;
};
Expand Down Expand Up @@ -582,6 +668,7 @@ const GamepadInterface = () => {
// Mark story as updated for reverse-chronological order
touchStoryUpdated(currentTreeKey);
setActiveMenu(null);
setPendingNewNode(null);

// Align to end of updated content after text splitting
requestAnimationFrame(() => {
Expand All @@ -596,7 +683,10 @@ const GamepadInterface = () => {
}
});
}}
onCancel={() => setActiveMenu(null)}
onCancel={() => {
setPendingNewNode(null);
setActiveMenu(null);
}}
/>
</MenuScreen>
) : null}
Expand Down
62 changes: 62 additions & 0 deletions client/interface/hooks/useStoryTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,67 @@ export function useStoryTree(params: StoryParams) {
[storyTree],
);

const createSiblingNode = useCallback(
(pathIds: string[], depth: number, newNode: StoryNode): StoryNode | null => {
if (depth <= 0) {
console.error("Cannot create sibling for root node");
return null;
}

const newTree = JSON.parse(JSON.stringify(storyTree)) as typeof storyTree;

let parent = newTree.root;
for (let i = 1; i < depth; i++) {
const targetId = pathIds[i];
if (!targetId) {
console.error("Missing path information when creating sibling", {
depth,
pathIds,
});
return null;
}

const idx =
parent.continuations?.findIndex((node) => node.id === targetId) ?? -1;
if (idx === -1 || !parent.continuations) {
console.error("Failed to locate parent while creating sibling", {
depth,
targetId,
available: parent.continuations?.map((node) => node.id),
});
return null;
}

parent = parent.continuations[idx];
}

if (!parent.continuations) {
parent.continuations = [];
}

parent.continuations.push(newNode);
const newIndex = parent.continuations.length - 1;
parent.lastSelectedIndex = newIndex;

setStoryTree(newTree);
setTrees((prev) => ({
...prev,
[currentTreeKey]: newTree,
}));
touchStoryUpdated(currentTreeKey);

setSelectedOptions((prev) => {
const next = [...prev];
next[depth] = newIndex;
return next.slice(0, depth + 1);
});
setCurrentDepth(depth);

return parent.continuations[newIndex];
},
[storyTree, currentTreeKey, setTrees],
Copy link

Copilot AI Sep 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency array is missing touchStoryUpdated which is called within the createSiblingNode function. This could lead to stale closure issues if touchStoryUpdated is redefined.

Suggested change
[storyTree, currentTreeKey, setTrees],
[storyTree, currentTreeKey, setTrees, touchStoryUpdated],

Copilot uses AI. Check for mistakes.
);

const handleStoryNavigation = useCallback(
async (key: string) => {
// Allow arrow/backspace navigation during generation, but prevent new
Expand Down Expand Up @@ -388,5 +449,6 @@ export function useStoryTree(params: StoryParams) {
getOptionsAtDepth,
setTrees,
setStoryTree,
createSiblingNode,
};
}