@oar-sh/oar 0.9.9 → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. package/shared/subagent-timeline.mjs +79 -0
@@ -14,6 +14,10 @@ import {
14
14
  getConversationCurrentWorkspaceRootPath,
15
15
  IS_SHARED_VIEW,
16
16
  conversations,
17
+ openSummaryModal,
18
+ closeSummaryModal,
19
+ renderSummaryModalContent,
20
+ setSummaryModalLoading,
17
21
  } from './store.js';
18
22
  import { openAnnotateEditor } from './annotate-editor.js';
19
23
  import {
@@ -25,7 +29,21 @@ import {
25
29
  loadDrivesRoots,
26
30
  loadDriveChildren,
27
31
  loadSessionRootTree,
32
+ createExplorerFolder,
33
+ renameExplorerEntry,
34
+ deleteExplorerEntry,
28
35
  } from './api-client.js';
36
+ import { MENU_SEPARATOR, bindLongPress, openContextMenu } from './context-menu.mjs';
37
+ import {
38
+ childPathOf,
39
+ descendantsOf,
40
+ dropPathsBelow,
41
+ entryNameOf,
42
+ isSameOrBelow,
43
+ parentPathOf,
44
+ rewritePathPrefix,
45
+ rewritePathSet,
46
+ } from './explorer-edit-state.mjs';
29
47
  import {
30
48
  extractPastedFiles,
31
49
  extractDroppedFiles,
@@ -1752,6 +1770,371 @@ function updateRepoToolbarUi() {
1752
1770
  heavyBtn.textContent = workspaceRoot
1753
1771
  ? `Heavy: ${repoBrowserState.workspaceIncludeHeavy ? 'On' : 'Off'}`
1754
1772
  : 'Heavy: n/a';
1773
+ const newFolderBtn = document.getElementById('repo-new-folder-btn');
1774
+ if (newFolderBtn) newFolderBtn.disabled = !canCreateRepoFolderIn(String(repoBrowserState.currentPath || ''));
1775
+ }
1776
+
1777
+ // ---------------------------------------------------------------------------
1778
+ // Explorer edits: new folder, rename, delete.
1779
+ //
1780
+ // The two inline editors live in module state, not in repoBrowserState: they
1781
+ // are transient, belong to one open explorer, and must not survive a root
1782
+ // switch or a close. Both are rendered by the normal render functions, which
1783
+ // capture the typed text and the focus before they replace the markup, so a
1784
+ // background re-render (a child load landing) does not eat a half-typed name.
1785
+ // ---------------------------------------------------------------------------
1786
+
1787
+ /** `{ parentPath, value, error, busy }` while the New-folder row is open. */
1788
+ let repoNewFolderDraft = null;
1789
+ /** `{ path, value, busy }` while a name is being edited in place. */
1790
+ let repoRenameDraft = null;
1791
+ /** `{ path, name, kind }` while the delete confirmation is open. */
1792
+ let repoDeletePending = null;
1793
+ /** Set while a render swaps markup, so the blur it causes is not a cancel. */
1794
+ let repoInlineRenderInProgress = false;
1795
+
1796
+ /** Rename and delete exist in the workspace and drives roots, outside the CWD picker. */
1797
+ function canEditRepoEntries() {
1798
+ return repoBrowserState.activeRoot !== 'session' && !repoBrowserCwdPickModeActive();
1799
+ }
1800
+
1801
+ /** A folder can be created wherever a real directory is open; the Windows "Drives" list is not one. */
1802
+ function canCreateRepoFolderIn(dirPath) {
1803
+ if (repoBrowserState.loading || repoBrowserState.error || !repoBrowserState.tree) return false;
1804
+ const node = repoBrowserState.nodeMap.get(String(dirPath || ''));
1805
+ if (!node || node.type !== 'dir') return false;
1806
+ if (repoBrowserState.activeRoot === 'drives' && !String(node.path || '')) return false;
1807
+ return true;
1808
+ }
1809
+
1810
+ function clearRepoInlineEdits() {
1811
+ repoNewFolderDraft = null;
1812
+ repoRenameDraft = null;
1813
+ }
1814
+
1815
+ export function startRepoNewFolder(parentPath = null) {
1816
+ const target = parentPath === null ? String(repoBrowserState.currentPath || '') : String(parentPath || '');
1817
+ if (!canCreateRepoFolderIn(target)) return;
1818
+ repoRenameDraft = null;
1819
+ repoNewFolderDraft = { parentPath: target, value: '', error: '', busy: false };
1820
+ renderRepoBrowser();
1821
+ focusRepoInlineInput('[data-repo-new-folder-input]');
1822
+ }
1823
+
1824
+ export function cancelRepoNewFolder() {
1825
+ if (!repoNewFolderDraft || repoNewFolderDraft.busy) return;
1826
+ repoNewFolderDraft = null;
1827
+ renderRepoFolder();
1828
+ }
1829
+
1830
+ export async function commitRepoNewFolder() {
1831
+ const draft = repoNewFolderDraft;
1832
+ if (!draft || draft.busy) return;
1833
+ const name = String(draft.value || '').trim();
1834
+ if (!name) {
1835
+ draft.error = 'Enter a name';
1836
+ renderRepoFolder();
1837
+ focusRepoInlineInput('[data-repo-new-folder-input]');
1838
+ return;
1839
+ }
1840
+ draft.busy = true;
1841
+ draft.error = '';
1842
+ renderRepoFolder();
1843
+ const root = repoBrowserState.activeRoot;
1844
+ try {
1845
+ const result = await createExplorerFolder({
1846
+ root,
1847
+ path: draft.parentPath,
1848
+ name,
1849
+ conversationId: currentConversationId(),
1850
+ });
1851
+ if (repoNewFolderDraft !== draft) return;
1852
+ repoNewFolderDraft = null;
1853
+ const newPath = String(result?.path || childPathOf(draft.parentPath, name));
1854
+ await reloadRepoDirectory(draft.parentPath);
1855
+ if (repoBrowserState.activeRoot === root && repoBrowserState.nodeMap.has(newPath)) {
1856
+ await setRepoCurrentPath(newPath);
1857
+ }
1858
+ showTransientRelayNotice(`Folder "${name}" created.`);
1859
+ } catch (error) {
1860
+ if (repoNewFolderDraft !== draft) return;
1861
+ draft.busy = false;
1862
+ draft.error = error?.message || 'Failed to create the folder';
1863
+ renderRepoFolder();
1864
+ focusRepoInlineInput('[data-repo-new-folder-input]');
1865
+ }
1866
+ }
1867
+
1868
+ export function startRepoRename(pathValue) {
1869
+ const entryPath = String(pathValue || '');
1870
+ const node = repoBrowserState.nodeMap.get(entryPath);
1871
+ if (!node || !entryPath || !canEditRepoEntries()) return;
1872
+ repoNewFolderDraft = null;
1873
+ repoRenameDraft = { path: entryPath, value: String(node.name || entryNameOf(entryPath)), busy: false };
1874
+ renderRepoBrowser();
1875
+ focusRepoInlineInput('[data-repo-rename-input]', { select: true });
1876
+ }
1877
+
1878
+ export function cancelRepoRename() {
1879
+ if (!repoRenameDraft || repoRenameDraft.busy) return;
1880
+ repoRenameDraft = null;
1881
+ renderRepoBrowser();
1882
+ }
1883
+
1884
+ export async function commitRepoRename() {
1885
+ const draft = repoRenameDraft;
1886
+ if (!draft || draft.busy) return;
1887
+ const node = repoBrowserState.nodeMap.get(draft.path);
1888
+ const oldName = String(node?.name || entryNameOf(draft.path));
1889
+ const newName = String(draft.value || '').trim();
1890
+ if (!node || !newName || newName === oldName) {
1891
+ cancelRepoRename();
1892
+ return;
1893
+ }
1894
+ draft.busy = true;
1895
+ renderRepoBrowser();
1896
+ const root = repoBrowserState.activeRoot;
1897
+ try {
1898
+ const result = await renameExplorerEntry({
1899
+ root,
1900
+ path: draft.path,
1901
+ newName,
1902
+ conversationId: currentConversationId(),
1903
+ });
1904
+ if (repoRenameDraft !== draft) return;
1905
+ repoRenameDraft = null;
1906
+ const parent = parentPathOf(draft.path);
1907
+ const newPath = String(result?.path || childPathOf(parent, newName));
1908
+ repoBrowserState.expandedPaths = rewritePathSet(repoBrowserState.expandedPaths, draft.path, newPath);
1909
+ repoBrowserState.collapsedPaths = rewritePathSet(repoBrowserState.collapsedPaths, draft.path, newPath);
1910
+ repoBrowserState.currentPath = rewritePathPrefix(repoBrowserState.currentPath, draft.path, newPath);
1911
+ await reloadRepoDirectory(parent);
1912
+ showTransientRelayNotice(`Renamed to "${newName}".`);
1913
+ } catch (error) {
1914
+ if (repoRenameDraft !== draft) return;
1915
+ draft.busy = false;
1916
+ renderRepoBrowser();
1917
+ focusRepoInlineInput('[data-repo-rename-input]', { select: true });
1918
+ showTransientRelayNotice(error?.message || 'Failed to rename', 6000);
1919
+ }
1920
+ }
1921
+
1922
+ function deleteConfirmationBody(pending, counts) {
1923
+ const name = escHtml(pending.name);
1924
+ const needsTypedName = counts.kind === 'dir' && (counts.files + counts.dirs) > 0;
1925
+ let lead;
1926
+ if (counts.kind !== 'dir') {
1927
+ lead = `<p>Delete the file <strong>${name}</strong>?</p>`;
1928
+ } else if (!needsTypedName) {
1929
+ lead = `<p>Delete the empty folder <strong>${name}</strong>?</p>`;
1930
+ } else {
1931
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
1932
+ const amount = counts.capped
1933
+ ? 'more than 10,000 entries'
1934
+ : `${plural(counts.files, 'file')} and ${plural(counts.dirs, 'folder')}`;
1935
+ lead = `<p>Delete the folder <strong>${name}</strong> and everything in it?</p>
1936
+ <p>It contains ${amount}.</p>
1937
+ <label class="repo-delete-confirm-label">Type the folder name to confirm
1938
+ <input type="text" class="repo-delete-confirm-input" data-repo-delete-confirm-input autocomplete="off" spellcheck="false" autocapitalize="off" placeholder="${name}">
1939
+ </label>`;
1940
+ }
1941
+ return `${lead}
1942
+ <div class="summary-modal-actions">
1943
+ <button class="chat-title-action-btn danger-btn" type="button" data-repo-delete-confirm${needsTypedName ? ' disabled data-keep-disabled="1"' : ''}>🗑 Delete</button>
1944
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
1945
+ </div>`;
1946
+ }
1947
+
1948
+ export async function openRepoDeleteConfirmation(pathValue) {
1949
+ const entryPath = String(pathValue || '');
1950
+ const node = repoBrowserState.nodeMap.get(entryPath);
1951
+ if (!node || !entryPath || !canEditRepoEntries()) return;
1952
+ const pending = { path: entryPath, name: String(node.name || entryNameOf(entryPath)), kind: node.type === 'dir' ? 'dir' : 'file', root: repoBrowserState.activeRoot };
1953
+ repoDeletePending = pending;
1954
+ openSummaryModal({
1955
+ title: 'Delete',
1956
+ subtitle: entryPath,
1957
+ kind: 'explorer-delete',
1958
+ bodyHtml: '<p class="repo-empty">Counting entries…</p>',
1959
+ });
1960
+ let counts;
1961
+ try {
1962
+ counts = await deleteExplorerEntry({ root: pending.root, path: entryPath, conversationId: currentConversationId(), dryRun: true });
1963
+ } catch (error) {
1964
+ if (repoDeletePending !== pending) return;
1965
+ repoDeletePending = null;
1966
+ closeSummaryModal();
1967
+ showTransientRelayNotice(error?.message || 'Failed to read the entry', 6000);
1968
+ return;
1969
+ }
1970
+ if (repoDeletePending !== pending) return;
1971
+ renderSummaryModalContent({
1972
+ title: 'Delete',
1973
+ subtitle: entryPath,
1974
+ kind: 'explorer-delete',
1975
+ bodyHtml: deleteConfirmationBody(pending, {
1976
+ kind: String(counts?.kind || pending.kind),
1977
+ files: Number(counts?.files || 0),
1978
+ dirs: Number(counts?.dirs || 0),
1979
+ capped: counts?.capped === true,
1980
+ }),
1981
+ });
1982
+ const input = document.querySelector('#summary-modal [data-repo-delete-confirm-input]');
1983
+ if (input) input.focus();
1984
+ }
1985
+
1986
+ async function confirmRepoDelete() {
1987
+ const pending = repoDeletePending;
1988
+ if (!pending) return;
1989
+ const button = document.querySelector('#summary-modal [data-repo-delete-confirm]');
1990
+ if (button?.disabled) return;
1991
+ setSummaryModalLoading(true);
1992
+ try {
1993
+ await deleteExplorerEntry({ root: pending.root, path: pending.path, conversationId: currentConversationId() });
1994
+ } catch (error) {
1995
+ if (repoDeletePending !== pending) return;
1996
+ setSummaryModalLoading(false);
1997
+ showTransientRelayNotice(error?.message || 'Failed to delete', 6000);
1998
+ return;
1999
+ }
2000
+ if (repoDeletePending !== pending) return;
2001
+ repoDeletePending = null;
2002
+ closeSummaryModal();
2003
+ if (repoBrowserState.activeRoot !== pending.root) return;
2004
+ const parent = parentPathOf(pending.path);
2005
+ repoBrowserState.expandedPaths = dropPathsBelow(repoBrowserState.expandedPaths, pending.path);
2006
+ repoBrowserState.collapsedPaths = dropPathsBelow(repoBrowserState.collapsedPaths, pending.path);
2007
+ if (isSameOrBelow(repoBrowserState.currentPath, pending.path)) repoBrowserState.currentPath = parent;
2008
+ await reloadRepoDirectory(parent);
2009
+ showTransientRelayNotice(`Deleted "${pending.name}".`);
2010
+ }
2011
+
2012
+ /**
2013
+ * Refetch one folder's children after a change in it, then re-expand what was
2014
+ * open below it. The session root itself is reloaded whole: its listing is
2015
+ * special (a missing folder lists as empty, the transcript is a sibling) and
2016
+ * the plain children route would not do.
2017
+ */
2018
+ async function reloadRepoDirectory(dirPath) {
2019
+ const target = String(dirPath || '');
2020
+ const node = repoBrowserState.nodeMap.get(target);
2021
+ const sessionRootItself = repoBrowserState.activeRoot === 'session' && target === String(repoBrowserState.tree?.path || '');
2022
+ if (!node || node.type !== 'dir' || sessionRootItself) {
2023
+ refreshRepoBrowser();
2024
+ return;
2025
+ }
2026
+ const reopen = descendantsOf(repoBrowserState.expandedPaths, target);
2027
+ node.childrenLoaded = false;
2028
+ node.lazy = true;
2029
+ await ensureRepoChildrenLoaded(target);
2030
+ for (const descendant of reopen) {
2031
+ if (repoBrowserState.nodeMap.has(descendant)) await ensureRepoChildrenLoaded(descendant);
2032
+ }
2033
+ if (!repoBrowserState.nodeMap.has(String(repoBrowserState.currentPath || ''))) {
2034
+ repoBrowserState.currentPath = target;
2035
+ }
2036
+ renderRepoBrowser();
2037
+ }
2038
+
2039
+ function focusRepoInlineInput(selector, { select = false } = {}) {
2040
+ requestAnimationFrame(() => {
2041
+ const input = document.querySelector(`#repo-browser-modal ${selector}`);
2042
+ if (!input) return;
2043
+ input.focus();
2044
+ if (select) {
2045
+ // Select the stem only: the common rename keeps the extension.
2046
+ const value = String(input.value || '');
2047
+ const dot = value.lastIndexOf('.');
2048
+ input.setSelectionRange(0, dot > 0 ? dot : value.length);
2049
+ }
2050
+ });
2051
+ }
2052
+
2053
+ /** Before a render replaces the markup: keep the typed text and whether it had focus. */
2054
+ function captureRepoInlineEdits() {
2055
+ const newFolderInput = document.querySelector('#repo-folder [data-repo-new-folder-input]');
2056
+ if (newFolderInput && repoNewFolderDraft) {
2057
+ repoNewFolderDraft.value = newFolderInput.value;
2058
+ repoNewFolderDraft.focused = document.activeElement === newFolderInput;
2059
+ }
2060
+ const renameInput = document.querySelector('#repo-browser-modal [data-repo-rename-input]');
2061
+ if (renameInput && repoRenameDraft) {
2062
+ repoRenameDraft.value = renameInput.value;
2063
+ repoRenameDraft.focused = document.activeElement === renameInput;
2064
+ repoRenameDraft.selection = [renameInput.selectionStart, renameInput.selectionEnd];
2065
+ }
2066
+ }
2067
+
2068
+ /** After the render: put the focus back where it was. */
2069
+ function restoreRepoInlineEditFocus() {
2070
+ if (repoNewFolderDraft?.focused) {
2071
+ const input = document.querySelector('#repo-folder [data-repo-new-folder-input]');
2072
+ if (input && document.activeElement !== input) input.focus();
2073
+ }
2074
+ if (repoRenameDraft?.focused) {
2075
+ const input = document.querySelector('#repo-browser-modal [data-repo-rename-input]');
2076
+ if (input && document.activeElement !== input) {
2077
+ input.focus();
2078
+ const [start, end] = repoRenameDraft.selection || [];
2079
+ if (Number.isFinite(start) && Number.isFinite(end)) input.setSelectionRange(start, end);
2080
+ }
2081
+ }
2082
+ }
2083
+
2084
+ /** Run `swap` (which replaces markup) with the inline edits preserved across it. */
2085
+ function withRepoInlineEditsPreserved(swap) {
2086
+ captureRepoInlineEdits();
2087
+ repoInlineRenderInProgress = true;
2088
+ try {
2089
+ swap();
2090
+ } finally {
2091
+ repoInlineRenderInProgress = false;
2092
+ }
2093
+ restoreRepoInlineEditFocus();
2094
+ }
2095
+
2096
+ function renameInputHtml(draft) {
2097
+ return `<input type="text" class="repo-rename-input" data-repo-rename-input value="${escHtml(draft.value)}" autocomplete="off" spellcheck="false" autocapitalize="off" aria-label="New name"${draft.busy ? ' disabled' : ''}>`;
2098
+ }
2099
+
2100
+ function newFolderRowHtml(draft) {
2101
+ return `<form class="repo-inline-edit" data-repo-new-folder-form>
2102
+ <span class="repo-inline-edit-icon">📁</span>
2103
+ <input type="text" data-repo-new-folder-input value="${escHtml(draft.value)}" placeholder="New folder name" autocomplete="off" spellcheck="false" autocapitalize="off" aria-label="New folder name"${draft.busy ? ' disabled' : ''}>
2104
+ <button type="submit" class="repo-browser-btn"${draft.busy ? ' disabled' : ''}>${draft.busy ? 'Creating…' : 'Create'}</button>
2105
+ <button type="button" class="repo-browser-btn" data-repo-new-folder-cancel${draft.busy ? ' disabled' : ''}>Cancel</button>
2106
+ <div class="repo-inline-edit-error" aria-live="polite">${escHtml(draft.error || '')}</div>
2107
+ </form>`;
2108
+ }
2109
+
2110
+ function repoEntryMenuItems(entryPath, isDir) {
2111
+ const items = [];
2112
+ if (isDir && canCreateRepoFolderIn(entryPath)) {
2113
+ items.push({ id: 'new-folder', label: '📁 New folder here', onSelect: () => { void setRepoCurrentPath(entryPath).then(() => startRepoNewFolder(entryPath)); } });
2114
+ }
2115
+ // Roots are never renamed or deleted: the workspace root, `/`, the session
2116
+ // root, and a drive (`C:`), which on Windows is a top-level child of the list.
2117
+ const isRoot = !entryPath
2118
+ || entryPath === '/'
2119
+ || entryPath === String(repoBrowserState.tree?.path || '')
2120
+ || (repoBrowserState.activeRoot !== 'workspace' && !parentPathOf(entryPath));
2121
+ if (canEditRepoEntries() && !isRoot) {
2122
+ if (items.length) items.push(MENU_SEPARATOR);
2123
+ items.push({ id: 'rename', label: 'Rename', onSelect: () => startRepoRename(entryPath) });
2124
+ items.push({ id: 'delete', label: 'Delete', danger: true, onSelect: () => { void openRepoDeleteConfirmation(entryPath); } });
2125
+ }
2126
+ return items;
2127
+ }
2128
+
2129
+ function openRepoEntryMenu(element, x, y) {
2130
+ const dirPath = element.getAttribute('data-repo-open-dir');
2131
+ const filePath = element.getAttribute('data-repo-open-file');
2132
+ const entryPath = dirPath !== null ? dirPath : filePath;
2133
+ if (entryPath === null) return false;
2134
+ const items = repoEntryMenuItems(String(entryPath), dirPath !== null);
2135
+ if (!items.length) return false;
2136
+ openContextMenu(x, y, items);
2137
+ return true;
1755
2138
  }
1756
2139
 
1757
2140
  export function renderRepoTreeNode(node) {
@@ -1780,8 +2163,11 @@ export function renderRepoTreeNode(node) {
1780
2163
  : (lazyUnloaded
1781
2164
  ? '<div class="repo-empty">Expand to load entries…</div>'
1782
2165
  : children.map(renderRepoTreeNode).join(''));
2166
+ const label = repoRenameDraft && repoRenameDraft.path === nodePath && nodePath
2167
+ ? renameInputHtml(repoRenameDraft)
2168
+ : escHtml(node.name || '/');
1783
2169
  return `<details class="repo-tree-node" data-repo-dir-path="${escHtml(nodePath)}"${openAttr}>
1784
- <summary class="repo-tree-summary" data-repo-open-dir="${escHtml(nodePath)}">${icon} ${escHtml(node.name || '/')}</summary>
2170
+ <summary class="repo-tree-summary" data-repo-open-dir="${escHtml(nodePath)}">${icon} ${label}</summary>
1785
2171
  <div class="repo-tree-children">${childrenHtml}</div>
1786
2172
  </details>`;
1787
2173
  }
@@ -1895,8 +2281,13 @@ export function renderRepoFolder() {
1895
2281
  const children = Array.isArray(node.children)
1896
2282
  ? node.children.filter((child) => child?.type !== 'dir')
1897
2283
  : [];
2284
+ const draftHtml = repoNewFolderDraft && repoNewFolderDraft.parentPath === String(node.path || '')
2285
+ ? newFolderRowHtml(repoNewFolderDraft)
2286
+ : '';
1898
2287
  if (!children.length) {
1899
- folderHost.innerHTML = '<div class="repo-empty">This folder has no files.</div>';
2288
+ withRepoInlineEditsPreserved(() => {
2289
+ folderHost.innerHTML = `${draftHtml}<div class="repo-empty">This folder has no files.</div>`;
2290
+ });
1900
2291
  return;
1901
2292
  }
1902
2293
 
@@ -1906,6 +2297,8 @@ export function renderRepoFolder() {
1906
2297
  const childPath = String(child.path || '');
1907
2298
  const rawHref = repoRawHref(childPath);
1908
2299
  const icon = repoIcon(child);
2300
+ const renaming = !!repoRenameDraft && repoRenameDraft.path === childPath;
2301
+ const nameHtml = renaming ? renameInputHtml(repoRenameDraft) : escHtml(child.name || childPath);
1909
2302
  if (isGrid) {
1910
2303
  const ext = String(child.ext || '').replace('.', '');
1911
2304
  const isImage = String(child.previewKind || '').toLowerCase() === 'image' || REPO_IMAGE_EXTENSIONS.has(ext.toLowerCase());
@@ -1915,7 +2308,7 @@ export function renderRepoFolder() {
1915
2308
  const fileMeta = `${String(child.previewKind || 'file')} · ${formatBytes(child.size || 0)}`;
1916
2309
  return `<div class="repo-card repo-card-clickable" data-repo-open-file="${escHtml(childPath)}">
1917
2310
  <div class="repo-card-thumb">${thumb}</div>
1918
- <div class="repo-card-name" title="${escHtml(child.name || childPath)}">${escHtml(child.name || childPath)}</div>
2311
+ <div class="repo-card-name" title="${escHtml(child.name || childPath)}">${nameHtml}</div>
1919
2312
  <div class="repo-entry-meta">${escHtml(fileMeta)}</div>
1920
2313
  </div>`;
1921
2314
  }
@@ -1925,7 +2318,7 @@ export function renderRepoFolder() {
1925
2318
  <div class="repo-entry-main">
1926
2319
  <span>${icon}</span>
1927
2320
  <div style="min-width:0">
1928
- <div class="repo-entry-name" title="${escHtml(child.name || childPath)}">${escHtml(child.name || childPath)}</div>
2321
+ <div class="repo-entry-name" title="${escHtml(child.name || childPath)}">${nameHtml}</div>
1929
2322
  <div class="repo-entry-meta">${escHtml(mainMeta)}</div>
1930
2323
  </div>
1931
2324
  </div>
@@ -1933,7 +2326,9 @@ export function renderRepoFolder() {
1933
2326
  }).join('');
1934
2327
 
1935
2328
  const savedScrollTop = folderHost.scrollTop;
1936
- folderHost.innerHTML = `<div class="${wrapperClass}">${rows}</div>`;
2329
+ withRepoInlineEditsPreserved(() => {
2330
+ folderHost.innerHTML = `${draftHtml}<div class="${wrapperClass}">${rows}</div>`;
2331
+ });
1937
2332
  folderHost.scrollTop = savedScrollTop;
1938
2333
  }
1939
2334
 
@@ -1956,7 +2351,9 @@ export function renderRepoTree() {
1956
2351
  // The rebuild resets the pane's scroll to 0; put it back so re-renders
1957
2352
  // (child loads, refreshes) don't rubber-band a scroll in progress.
1958
2353
  const savedScrollTop = treeHost.scrollTop;
1959
- treeHost.innerHTML = renderRepoTreeNode(root);
2354
+ withRepoInlineEditsPreserved(() => {
2355
+ treeHost.innerHTML = renderRepoTreeNode(root);
2356
+ });
1960
2357
  updateRepoTreeSelection();
1961
2358
  treeHost.scrollTop = savedScrollTop;
1962
2359
  syncRepoTreeToCurrentPath();
@@ -2181,6 +2578,7 @@ export function setRepoBrowserRoot(root) {
2181
2578
  // A root switch invalidates any parked restore: the saved path belongs to the
2182
2579
  // tree we are leaving. The Set wipes below are correct here for the same reason.
2183
2580
  pendingRepoBrowserRestore = null;
2581
+ clearRepoInlineEdits();
2184
2582
  setRepoBrowserState({
2185
2583
  tree: null,
2186
2584
  nodeMap: new Map(),
@@ -2330,6 +2728,7 @@ export function closeRepoBrowser() {
2330
2728
  modal.setAttribute('aria-hidden', 'true');
2331
2729
  repoBrowserState.open = false;
2332
2730
  repoBrowserCwdPickHandler = null;
2731
+ clearRepoInlineEdits();
2333
2732
  }
2334
2733
 
2335
2734
  export function refreshRepoBrowser() {
@@ -2427,6 +2826,8 @@ export async function setRepoCurrentPath(pathValue) {
2427
2826
  updateRepoTreeSelection();
2428
2827
  syncRepoTreeToCurrentPath();
2429
2828
  updateRepoCwdPickBar();
2829
+ // The New-folder button follows the open folder (a drive list has none).
2830
+ updateRepoToolbarUi();
2430
2831
  }
2431
2832
 
2432
2833
  document.addEventListener('click', (event) => {
@@ -2596,7 +2997,105 @@ document.getElementById('repo-browser-modal').addEventListener('click', (event)
2596
2997
  if (event.target.id === 'repo-browser-modal') closeRepoBrowser();
2597
2998
  });
2598
2999
 
3000
+ // The inline editors. Keys inside an input never reach the <summary> (Enter
3001
+ // would toggle the folder) or the document (Escape would close the explorer).
3002
+ function repoInlineInputOf(event) {
3003
+ return eventClosest(event, '[data-repo-rename-input], [data-repo-new-folder-input]');
3004
+ }
3005
+
3006
+ document.getElementById('repo-browser-modal').addEventListener('keydown', (event) => {
3007
+ const input = repoInlineInputOf(event);
3008
+ if (!input) return;
3009
+ const isRename = input.hasAttribute('data-repo-rename-input');
3010
+ if (event.key === 'Enter') {
3011
+ event.preventDefault();
3012
+ event.stopPropagation();
3013
+ if (isRename) void commitRepoRename();
3014
+ else void commitRepoNewFolder();
3015
+ } else if (event.key === 'Escape') {
3016
+ event.preventDefault();
3017
+ event.stopPropagation();
3018
+ if (isRename) cancelRepoRename();
3019
+ else cancelRepoNewFolder();
3020
+ } else if (event.key === ' ' && isRename) {
3021
+ // A space in a <summary> toggles it; in a name it is a space.
3022
+ event.stopPropagation();
3023
+ }
3024
+ });
3025
+
3026
+ document.getElementById('repo-browser-modal').addEventListener('input', (event) => {
3027
+ const input = repoInlineInputOf(event);
3028
+ if (!input) return;
3029
+ if (input.hasAttribute('data-repo-rename-input') && repoRenameDraft) repoRenameDraft.value = input.value;
3030
+ if (input.hasAttribute('data-repo-new-folder-input') && repoNewFolderDraft) repoNewFolderDraft.value = input.value;
3031
+ });
3032
+
3033
+ // Leaving an untouched rename input ends the edit; a changed name stays open
3034
+ // until Enter, so a stray tap cannot lose or commit it. Renders swap the
3035
+ // input out and back, and that blur is not a cancel.
3036
+ document.getElementById('repo-browser-modal').addEventListener('focusout', (event) => {
3037
+ if (repoInlineRenderInProgress) return;
3038
+ const input = eventClosest(event, '[data-repo-rename-input]');
3039
+ if (!input || !repoRenameDraft || repoRenameDraft.busy) return;
3040
+ const node = repoBrowserState.nodeMap.get(repoRenameDraft.path);
3041
+ const original = String(node?.name || entryNameOf(repoRenameDraft.path));
3042
+ if (String(input.value || '').trim() === original) cancelRepoRename();
3043
+ });
3044
+
3045
+ document.getElementById('repo-folder').addEventListener('submit', (event) => {
3046
+ if (!eventClosest(event, '[data-repo-new-folder-form]')) return;
3047
+ event.preventDefault();
3048
+ void commitRepoNewFolder();
3049
+ });
3050
+
3051
+ // Right-click on a desktop, long press on a phone: the entry's menu.
3052
+ for (const hostId of ['repo-tree', 'repo-folder']) {
3053
+ const host = document.getElementById(hostId);
3054
+ const selector = hostId === 'repo-tree' ? '[data-repo-open-dir]' : '[data-repo-open-file]';
3055
+ host.addEventListener('contextmenu', (event) => {
3056
+ if (repoInlineInputOf(event)) return;
3057
+ const element = eventClosest(event, selector);
3058
+ if (!element) return;
3059
+ if (openRepoEntryMenu(element, event.clientX, event.clientY)) event.preventDefault();
3060
+ });
3061
+ bindLongPress(host, selector, (element, x, y) => {
3062
+ if (element.querySelector('[data-repo-rename-input]')) return;
3063
+ openRepoEntryMenu(element, x, y);
3064
+ });
3065
+ }
3066
+
3067
+ // The delete confirmation lives in the shared summary modal.
3068
+ document.getElementById('summary-modal').addEventListener('click', (event) => {
3069
+ if (!eventClosest(event, '[data-repo-delete-confirm]')) return;
3070
+ event.preventDefault();
3071
+ void confirmRepoDelete();
3072
+ });
3073
+
3074
+ document.getElementById('summary-modal').addEventListener('input', (event) => {
3075
+ const input = eventClosest(event, '[data-repo-delete-confirm-input]');
3076
+ if (!input || !repoDeletePending) return;
3077
+ const button = document.querySelector('#summary-modal [data-repo-delete-confirm]');
3078
+ if (!button) return;
3079
+ // The modal's loading toggle re-enables every button it does not see as
3080
+ // kept disabled; the name gate has to be expressed through that mark.
3081
+ const mismatch = String(input.value || '').trim() !== repoDeletePending.name;
3082
+ button.disabled = mismatch;
3083
+ if (mismatch) button.dataset.keepDisabled = '1';
3084
+ else delete button.dataset.keepDisabled;
3085
+ });
3086
+
3087
+ document.getElementById('summary-modal').addEventListener('keydown', (event) => {
3088
+ if (event.key !== 'Enter' || !eventClosest(event, '[data-repo-delete-confirm-input]')) return;
3089
+ event.preventDefault();
3090
+ void confirmRepoDelete();
3091
+ });
3092
+
2599
3093
  document.getElementById('repo-tree').addEventListener('click', (event) => {
3094
+ if (repoInlineInputOf(event)) {
3095
+ // A click in the rename input must not toggle the folder it sits in.
3096
+ event.preventDefault();
3097
+ return;
3098
+ }
2600
3099
  const fileButton = eventClosest(event, '[data-repo-open-file]');
2601
3100
  if (fileButton) {
2602
3101
  event.preventDefault();
@@ -2641,6 +3140,12 @@ document.getElementById('repo-tree').addEventListener('click', (event) => {
2641
3140
  });
2642
3141
 
2643
3142
  document.getElementById('repo-folder').addEventListener('click', (event) => {
3143
+ if (eventClosest(event, '[data-repo-new-folder-cancel]')) {
3144
+ event.preventDefault();
3145
+ cancelRepoNewFolder();
3146
+ return;
3147
+ }
3148
+ if (eventClosest(event, '[data-repo-new-folder-form]') || repoInlineInputOf(event)) return;
2644
3149
  const navDir = eventClosest(event, '[data-repo-nav-dir]');
2645
3150
  if (navDir) {
2646
3151
  event.preventDefault();
@@ -2677,7 +3182,12 @@ document.addEventListener('keydown', (event) => {
2677
3182
  const modal = document.getElementById('file-preview-modal');
2678
3183
  const repoModal = document.getElementById('repo-browser-modal');
2679
3184
  const summaryModal = document.getElementById('summary-modal');
3185
+ // Topmost first. The summary modal sits above the explorer (a delete
3186
+ // confirmation opened from it) except in CWD-pick mode, where the explorer
3187
+ // is raised above the new-chat/summary modal that opened it.
3188
+ const summaryOnTop = summaryModal.classList.contains('visible') && !repoBrowserCwdPickModeActive();
2680
3189
  if (modal.classList.contains('visible')) closeFilePreview();
3190
+ else if (summaryOnTop) window.closeSummaryModal?.();
2681
3191
  else if (repoModal.classList.contains('visible')) closeRepoBrowser();
2682
3192
  else if (summaryModal.classList.contains('visible')) window.closeSummaryModal?.();
2683
3193
  });