cli-jaw 2.0.2 → 2.0.4

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 (99) hide show
  1. package/README.ja.md +6 -2
  2. package/README.ko.md +6 -2
  3. package/README.md +6 -2
  4. package/dist/src/agent/alert-escalation.js +12 -1
  5. package/dist/src/agent/alert-escalation.js.map +1 -1
  6. package/dist/src/agent/error-classifier.js +14 -8
  7. package/dist/src/agent/error-classifier.js.map +1 -1
  8. package/dist/src/agent/lifecycle-handler.js +81 -4
  9. package/dist/src/agent/lifecycle-handler.js.map +1 -1
  10. package/dist/src/agent/session-persistence.js +2 -0
  11. package/dist/src/agent/session-persistence.js.map +1 -1
  12. package/dist/src/agent/spawn.js +70 -9
  13. package/dist/src/agent/spawn.js.map +1 -1
  14. package/dist/src/browser/connection.js +69 -15
  15. package/dist/src/browser/connection.js.map +1 -1
  16. package/dist/src/browser/runtime-diagnostics.js +39 -0
  17. package/dist/src/browser/runtime-diagnostics.js.map +1 -1
  18. package/dist/src/cli/compact.js +5 -1
  19. package/dist/src/cli/compact.js.map +1 -1
  20. package/dist/src/core/compact.js +5 -1
  21. package/dist/src/core/compact.js.map +1 -1
  22. package/dist/src/manager/lifecycle.js +1 -1
  23. package/dist/src/manager/lifecycle.js.map +1 -1
  24. package/dist/src/manager/notes/routes.js +7 -0
  25. package/dist/src/manager/notes/routes.js.map +1 -1
  26. package/dist/src/manager/notes/search.js +282 -0
  27. package/dist/src/manager/notes/search.js.map +1 -0
  28. package/dist/src/manager/preview-origin-proxy.js +12 -2
  29. package/dist/src/manager/preview-origin-proxy.js.map +1 -1
  30. package/dist/src/memory/bootstrap.js +8 -1
  31. package/dist/src/memory/bootstrap.js.map +1 -1
  32. package/dist/src/memory/indexing.js +221 -134
  33. package/dist/src/memory/indexing.js.map +1 -1
  34. package/dist/src/memory/keyword-expand.js +26 -4
  35. package/dist/src/memory/keyword-expand.js.map +1 -1
  36. package/dist/src/memory/reflect.js +119 -8
  37. package/dist/src/memory/reflect.js.map +1 -1
  38. package/dist/src/memory/synonyms.js +60 -0
  39. package/dist/src/memory/synonyms.js.map +1 -0
  40. package/dist/src/orchestrator/gateway.js +1 -1
  41. package/dist/src/orchestrator/gateway.js.map +1 -1
  42. package/dist/src/orchestrator/pipeline.js +15 -18
  43. package/dist/src/orchestrator/pipeline.js.map +1 -1
  44. package/dist/src/orchestrator/state-machine.js +12 -2
  45. package/dist/src/orchestrator/state-machine.js.map +1 -1
  46. package/package.json +1 -1
  47. package/public/dist/assets/{MilkdownWysiwygEditor-Cm3uXfWf.js → MilkdownWysiwygEditor-DIebNZF7.js} +1 -1
  48. package/public/dist/assets/{app-Be58Cs3Y.js → app-DJ8ys0j5.js} +4 -4
  49. package/public/dist/assets/{employees-CxdghzoD.js → employees-RJ_wRL09.js} +1 -1
  50. package/public/dist/assets/insert-image-markdown-kk053MvN.js +22 -0
  51. package/public/dist/assets/manager-DAe38I94.js +25 -0
  52. package/public/dist/assets/{manager-DEiyrWDP.css → manager-fQR46YFa.css} +1 -1
  53. package/public/dist/assets/{memory-CsMNkYtv.js → memory-dJGp6QBv.js} +1 -1
  54. package/public/dist/assets/memory-w3yQettQ.js +1 -0
  55. package/public/dist/assets/{render-DGQX46ei.js → render-KVGsbWj1.js} +1 -1
  56. package/public/dist/assets/{settings-BH213Yv3.js → settings-C7QWaUHB.js} +1 -1
  57. package/public/dist/assets/settings-DmUCo6lz.js +1 -0
  58. package/public/dist/assets/{skills-CQtCtHPA.js → skills-CHkTgM7L.js} +1 -1
  59. package/public/dist/assets/skills-SxG_nfwn.js +1 -0
  60. package/public/dist/assets/{slash-commands-Dzk1xHWS.js → slash-commands-2ThyUGvX.js} +1 -1
  61. package/public/dist/assets/slash-commands-BxJkKdhB.js +1 -0
  62. package/public/dist/assets/{trace-drawer-SRKcfm2S.js → trace-drawer-Dis80M6X.js} +1 -1
  63. package/public/dist/assets/ui-LhD1VfQs.js +1 -0
  64. package/public/dist/assets/ui-kS1ZJfez.js +143 -0
  65. package/public/dist/assets/{ws-CTHQFzM1.js → ws-DVE3eWRj.js} +2 -2
  66. package/public/dist/index.html +1 -1
  67. package/public/dist/manager/index.html +2 -2
  68. package/public/js/features/chat.ts +6 -1
  69. package/public/js/features/process-block.ts +34 -6
  70. package/public/js/features/process-step-match.ts +2 -1
  71. package/public/js/ui.ts +100 -13
  72. package/public/js/virtual-scroll-bootstrap.ts +8 -1
  73. package/public/js/virtual-scroll.ts +83 -13
  74. package/public/js/ws.ts +3 -3
  75. package/public/locales/en.json +2 -1
  76. package/public/locales/ja.json +2 -1
  77. package/public/locales/ko.json +2 -1
  78. package/public/locales/zh.json +2 -1
  79. package/public/manager/src/App.tsx +10 -3
  80. package/public/manager/src/api.ts +17 -0
  81. package/public/manager/src/main.tsx +1 -0
  82. package/public/manager/src/notes/NotesFileTree.tsx +14 -22
  83. package/public/manager/src/notes/NotesSearchSidebar.tsx +118 -0
  84. package/public/manager/src/notes/NotesSidebar.tsx +65 -23
  85. package/public/manager/src/notes/NotesWorkspace.tsx +13 -0
  86. package/public/manager/src/notes/notes-api.ts +1 -0
  87. package/public/manager/src/notes/notes-search.css +90 -0
  88. package/public/manager/src/notes/notes-types.ts +2 -0
  89. package/public/manager/src/preview.ts +20 -1
  90. package/public/manager/src/types.ts +8 -0
  91. package/scripts/install-wsl.sh +48 -14
  92. package/public/dist/assets/insert-image-markdown-DIEa-zjk.js +0 -22
  93. package/public/dist/assets/manager-UEXd1_9T.js +0 -25
  94. package/public/dist/assets/memory-DXad_DPO.js +0 -1
  95. package/public/dist/assets/settings-DXT87G2U.js +0 -1
  96. package/public/dist/assets/skills-5o_1v0nz.js +0 -1
  97. package/public/dist/assets/slash-commands-D4-hrrmh.js +0 -1
  98. package/public/dist/assets/ui-CdRKN2S6.js +0 -141
  99. package/public/dist/assets/ui-n43jmg_f.js +0 -1
@@ -4,6 +4,7 @@ import type {
4
4
  DashboardLifecycleResult,
5
5
  DashboardNoteAssetResponse,
6
6
  DashboardNoteFileResponse,
7
+ DashboardNoteSearchResult,
7
8
  DashboardNotesCapabilities,
8
9
  DashboardNoteTreeEntry,
9
10
  DashboardPutNoteRequest,
@@ -172,6 +173,22 @@ export async function fetchNotesCapabilities(): Promise<DashboardNotesCapabiliti
172
173
  return await parseNotesResponse<DashboardNotesCapabilities>(response, `notes capabilities fetch failed: ${response.status}`);
173
174
  }
174
175
 
176
+ export async function searchNotes(
177
+ query: string,
178
+ options: { limit?: number; regex?: boolean; signal?: AbortSignal } = {},
179
+ ): Promise<DashboardNoteSearchResult[]> {
180
+ const params = new URLSearchParams({ q: query });
181
+ if (options.limit !== undefined) params.set('limit', String(options.limit));
182
+ if (options.regex) params.set('regex', 'true');
183
+ const init: RequestInit = {};
184
+ if (options.signal) init.signal = options.signal;
185
+ const response = await fetch(`/api/dashboard/notes/search?${params}`, init);
186
+ return await parseNotesResponse<DashboardNoteSearchResult[]>(
187
+ response,
188
+ `notes search failed: ${response.status}`,
189
+ );
190
+ }
191
+
175
192
  export async function fetchNoteFile(path: string): Promise<DashboardNoteFileResponse> {
176
193
  const response = await fetch(`/api/dashboard/notes/file?path=${encodeURIComponent(path)}`);
177
194
  return await parseNotesResponse<DashboardNoteFileResponse>(response, `note fetch failed: ${response.status}`);
@@ -12,6 +12,7 @@ import './manager-p0-1-1.css';
12
12
  import './manager-dashboard-settings.css';
13
13
  import 'katex/dist/katex.min.css';
14
14
  import './manager-notes.css';
15
+ import './notes/notes-search.css';
15
16
  import './manager-dashboard-board.css';
16
17
  import './manager-dashboard-board-stages.css';
17
18
  import './manager-dashboard-board-interactions.css';
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useState, type CSSProperties, type DragEvent, type KeyboardEvent, type MouseEvent } from 'react';
1
+ import { useCallback, useEffect, useMemo, useState, type DragEvent, type KeyboardEvent, type MouseEvent } from 'react';
2
2
  import type { NotesTreeEntry } from './notes-types';
3
3
 
4
4
  type NotesTrashItem = { path: string; kind: NotesTreeEntry['kind'] };
@@ -9,7 +9,6 @@ type NotesFileTreeProps = {
9
9
  selectedFolderPath: string | null;
10
10
  dirtyPath: string | null;
11
11
  loading: boolean;
12
- width: number;
13
12
  notesRoot: string | null;
14
13
  onSelectPath: (path: string) => void;
15
14
  onSelectFolder: (path: string | null) => void;
@@ -17,9 +16,6 @@ type NotesFileTreeProps = {
17
16
  onRenamePath: (path: string, kind: NotesTreeEntry['kind']) => void;
18
17
  onTrashPath: (path: string, kind: NotesTreeEntry['kind']) => void;
19
18
  onTrashPaths: (items: NotesTrashItem[]) => void;
20
- onCreateNote: () => void;
21
- onCreateFolder: () => void;
22
- onRefresh: () => void;
23
19
  };
24
20
 
25
21
  function TreeChevron({ expanded }: { expanded: boolean }) {
@@ -48,7 +44,7 @@ function FileIcon() {
48
44
  );
49
45
  }
50
46
 
51
- function NewNoteIcon() {
47
+ export function NewNoteIcon() {
52
48
  return (
53
49
  <svg viewBox="0 0 18 18" aria-hidden="true" className="notes-tree-action-icon">
54
50
  <path d="M5 2.8h5.2L13.5 6v9.2H5V2.8Z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
@@ -57,7 +53,7 @@ function NewNoteIcon() {
57
53
  );
58
54
  }
59
55
 
60
- function NewFolderIcon() {
56
+ export function NewFolderIcon() {
61
57
  return (
62
58
  <svg viewBox="0 0 18 18" aria-hidden="true" className="notes-tree-action-icon">
63
59
  <path d="M2.5 5.2h5.1l1.2 1.5h6.7v6.7a1.4 1.4 0 0 1-1.4 1.4H3.9a1.4 1.4 0 0 1-1.4-1.4V5.2Z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
@@ -66,7 +62,7 @@ function NewFolderIcon() {
66
62
  );
67
63
  }
68
64
 
69
- function RefreshIcon() {
65
+ export function RefreshIcon() {
70
66
  return (
71
67
  <svg viewBox="0 0 18 18" aria-hidden="true" className="notes-tree-action-icon">
72
68
  <path d="M14.2 6.3A5.5 5.5 0 0 0 4 5.2L3 6.6M3.8 11.7A5.5 5.5 0 0 0 14 12.8l1-1.4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -201,6 +197,12 @@ function renderEntry(
201
197
  type="button"
202
198
  className="notes-tree-folder-button"
203
199
  aria-expanded={expanded}
200
+ draggable
201
+ onDragStart={(event) => {
202
+ event.dataTransfer.effectAllowed = 'move';
203
+ event.dataTransfer.setData('application/x-cli-jaw-note-path', entry.path);
204
+ event.dataTransfer.setData('text/plain', entry.path);
205
+ }}
204
206
  onClick={(event) => {
205
207
  if (event.shiftKey || event.metaKey || event.ctrlKey) {
206
208
  onEntryClick(entry.path, event);
@@ -224,7 +226,7 @@ function renderEntry(
224
226
  event.stopPropagation();
225
227
  const draggedPath = notePathFromDrag(event);
226
228
  setDropTargetPath(null);
227
- if (draggedPath) props.onMovePath(draggedPath, entry.path);
229
+ if (draggedPath && draggedPath !== entry.path && !entry.path.startsWith(`${draggedPath}/`)) props.onMovePath(draggedPath, entry.path);
228
230
  }}
229
231
  onKeyDown={(event) => {
230
232
  if (event.key === 'ArrowRight' && !expanded) {
@@ -325,7 +327,6 @@ function renderEntry(
325
327
  }
326
328
 
327
329
  export function NotesFileTree(props: NotesFileTreeProps) {
328
- const style = { '--notes-tree-width': `${props.width}px` } as CSSProperties;
329
330
  const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
330
331
  const [dropTargetPath, setDropTargetPath] = useState<string | null>(null);
331
332
  const [multiSelected, setMultiSelected] = useState<Set<string>>(() => new Set());
@@ -446,9 +447,8 @@ export function NotesFileTree(props: NotesFileTreeProps) {
446
447
  }, [multiSelected, pathKindLookup, props]);
447
448
 
448
449
  return (
449
- <aside
450
- className={`notes-tree ${dropTargetPath === null ? 'is-root-drop-target' : ''}`}
451
- style={style}
450
+ <div
451
+ className={`notes-tree-body ${dropTargetPath === null ? 'is-root-drop-target' : ''}`}
452
452
  onDragOver={(event) => {
453
453
  if (!hasNotePathDrag(event)) return;
454
454
  event.preventDefault();
@@ -461,14 +461,6 @@ export function NotesFileTree(props: NotesFileTreeProps) {
461
461
  props.onMovePath(draggedPath, null);
462
462
  }}
463
463
  >
464
- <div className="notes-tree-header">
465
- <strong>Notes</strong>
466
- <div className="notes-tree-actions">
467
- <button type="button" onClick={props.onCreateNote} title="New note" aria-label="New note"><NewNoteIcon /></button>
468
- <button type="button" onClick={props.onCreateFolder} title="New folder" aria-label="New folder"><NewFolderIcon /></button>
469
- <button type="button" onClick={props.onRefresh} disabled={props.loading} title="Refresh notes" aria-label="Refresh notes"><RefreshIcon /></button>
470
- </div>
471
- </div>
472
464
  {multiSelected.size > 0 && (
473
465
  <div className="notes-tree-selection-info">
474
466
  {multiSelected.size} selected
@@ -486,6 +478,6 @@ export function NotesFileTree(props: NotesFileTreeProps) {
486
478
  {!props.loading && props.entries.length > 0 && (
487
479
  <ul className="notes-tree-list">{props.entries.map(entry => renderEntry(entry, props, expandedFolders, toggleFolder, dropTargetPath, setDropTargetPath, multiSelected, pathKindLookup, onEntryClick))}</ul>
488
480
  )}
489
- </aside>
481
+ </div>
490
482
  );
491
483
  }
@@ -0,0 +1,118 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { searchNotes } from './notes-api';
3
+ import type { NoteSearchResult } from './notes-types';
4
+ import type { NotesSidebarMode } from './NotesSidebar';
5
+
6
+ type NotesSearchSidebarProps = {
7
+ focusToken: number;
8
+ onSelect: (path: string) => void;
9
+ onModeChange: (mode: NotesSidebarMode) => void;
10
+ };
11
+
12
+ const MIN_QUERY_LENGTH = 2;
13
+ const SEARCH_DEBOUNCE_MS = 275;
14
+ const SEARCH_LIMIT = 20;
15
+
16
+ function isAbortError(error: unknown): boolean {
17
+ return error instanceof DOMException && error.name === 'AbortError';
18
+ }
19
+
20
+ export function NotesSearchSidebar(props: NotesSearchSidebarProps) {
21
+ const [query, setQuery] = useState('');
22
+ const [results, setResults] = useState<NoteSearchResult[]>([]);
23
+ const [loading, setLoading] = useState(false);
24
+ const [error, setError] = useState<string | null>(null);
25
+ const inputRef = useRef<HTMLInputElement>(null);
26
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
27
+ const abortRef = useRef<AbortController | null>(null);
28
+
29
+ const cancelSearch = useCallback((): void => {
30
+ if (timerRef.current) clearTimeout(timerRef.current);
31
+ timerRef.current = null;
32
+ abortRef.current?.abort();
33
+ abortRef.current = null;
34
+ }, []);
35
+
36
+ useEffect(() => {
37
+ inputRef.current?.focus();
38
+ }, [props.focusToken]);
39
+
40
+ useEffect(() => {
41
+ return () => cancelSearch();
42
+ }, [cancelSearch]);
43
+
44
+ const runSearch = useCallback((value: string): void => {
45
+ const trimmed = value.trim();
46
+ cancelSearch();
47
+ setError(null);
48
+ if (trimmed.length < MIN_QUERY_LENGTH) {
49
+ setResults([]);
50
+ setLoading(false);
51
+ return;
52
+ }
53
+ const controller = new AbortController();
54
+ abortRef.current = controller;
55
+ setLoading(true);
56
+ void searchNotes(trimmed, { limit: SEARCH_LIMIT, signal: controller.signal })
57
+ .then(nextResults => {
58
+ if (!controller.signal.aborted) setResults(nextResults);
59
+ })
60
+ .catch(err => {
61
+ if (controller.signal.aborted || isAbortError(err)) return;
62
+ setResults([]);
63
+ setError((err as Error).message || 'Search failed');
64
+ })
65
+ .finally(() => {
66
+ if (!controller.signal.aborted) setLoading(false);
67
+ });
68
+ }, [cancelSearch]);
69
+
70
+ function handleQueryChange(value: string): void {
71
+ setQuery(value);
72
+ if (timerRef.current) clearTimeout(timerRef.current);
73
+ timerRef.current = setTimeout(() => runSearch(value), SEARCH_DEBOUNCE_MS);
74
+ }
75
+
76
+ function handleKeyDown(event: React.KeyboardEvent): void {
77
+ if (event.key !== 'Escape') return;
78
+ event.preventDefault();
79
+ props.onModeChange('files');
80
+ }
81
+
82
+ return (
83
+ <section className="notes-search-sidebar" aria-label="Search notes" onKeyDown={handleKeyDown}>
84
+ <div className="notes-search-sidebar-header">
85
+ <input
86
+ ref={inputRef}
87
+ className="notes-search-input"
88
+ type="search"
89
+ placeholder="Search notes"
90
+ value={query}
91
+ onChange={event => handleQueryChange(event.currentTarget.value)}
92
+ aria-label="Search notes"
93
+ />
94
+ </div>
95
+ <div className="notes-search-sidebar-results" aria-live="polite">
96
+ {loading && <div className="notes-search-loading">Searching...</div>}
97
+ {error && <div className="notes-search-error">{error}</div>}
98
+ {!error && results.map(result => (
99
+ <button
100
+ key={`${result.kind}:${result.path}:${result.line}:${result.context}`}
101
+ type="button"
102
+ className="notes-search-result"
103
+ onClick={() => props.onSelect(result.path)}
104
+ >
105
+ <span className="notes-search-result-path">{result.path}</span>
106
+ <span className="notes-search-result-line">
107
+ {result.kind === 'path' ? 'Path match' : `Line ${result.line}`}
108
+ </span>
109
+ <span className="notes-search-result-context">{result.context}</span>
110
+ </button>
111
+ ))}
112
+ {!loading && !error && query.trim().length >= MIN_QUERY_LENGTH && results.length === 0 && (
113
+ <div className="notes-search-empty">No results</div>
114
+ )}
115
+ </div>
116
+ </section>
117
+ );
118
+ }
@@ -1,9 +1,12 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useState, type CSSProperties } from 'react';
2
2
  import { createNoteFile, createNoteFolder, renameNotePath, trashNotePath } from './notes-api';
3
- import { NotesFileTree } from './NotesFileTree';
3
+ import { NewFolderIcon, NewNoteIcon, NotesFileTree, RefreshIcon } from './NotesFileTree';
4
+ import { NotesSearchSidebar } from './NotesSearchSidebar';
4
5
  import { publishInvalidation } from '../sync/invalidation-bus';
5
6
  import type { NotesTreeEntry } from './notes-types';
6
7
 
8
+ export type NotesSidebarMode = 'files' | 'search';
9
+
7
10
  type NotesSidebarProps = {
8
11
  tree: NotesTreeEntry[];
9
12
  loading: boolean;
@@ -12,6 +15,10 @@ type NotesSidebarProps = {
12
15
  selectedPath: string | null;
13
16
  dirtyPath: string | null;
14
17
  treeWidth: number;
18
+ mode: NotesSidebarMode;
19
+ searchFocusToken: number;
20
+ onModeChange: (mode: NotesSidebarMode) => void;
21
+ onOpenSearch: () => void;
15
22
  onSelectedPathChange: (path: string | null) => void;
16
23
  onRefreshTree: (selectPath?: string | null) => Promise<void>;
17
24
  };
@@ -75,7 +82,17 @@ function batchTrashConfirmMessage(items: { path: string; kind: NotesTreeEntry['k
75
82
  return dirty ? `${label}\n\nThere are unsaved changes inside this selection.` : label;
76
83
  }
77
84
 
85
+ function SearchIcon() {
86
+ return (
87
+ <svg viewBox="0 0 18 18" aria-hidden="true" className="notes-tree-action-icon">
88
+ <path d="M7.8 3.2a4.6 4.6 0 1 1 0 9.2 4.6 4.6 0 0 1 0-9.2Z" fill="none" stroke="currentColor" strokeWidth="1.5" />
89
+ <path d="m11.3 11.3 3.2 3.2" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
90
+ </svg>
91
+ );
92
+ }
93
+
78
94
  export function NotesSidebar(props: NotesSidebarProps) {
95
+ const style = { '--notes-tree-width': `${props.treeWidth}px` } as CSSProperties;
79
96
  const [error, setError] = useState<string | null>(null);
80
97
  const [status, setStatus] = useState<string | null>(null);
81
98
  const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null);
@@ -121,7 +138,7 @@ export function NotesSidebar(props: NotesSidebarProps) {
121
138
  }
122
139
  }
123
140
 
124
- async function moveNote(from: string, toFolder: string | null): Promise<void> {
141
+ async function movePath(from: string, toFolder: string | null): Promise<void> {
125
142
  const to = movePathToFolder(from, toFolder);
126
143
  if (from === to) return;
127
144
  try {
@@ -232,27 +249,52 @@ export function NotesSidebar(props: NotesSidebarProps) {
232
249
  }
233
250
 
234
251
  return (
235
- <>
252
+ <aside className="notes-tree" style={style}>
236
253
  {(props.error || error) && <section className="state error-state">{props.error || error}</section>}
237
254
  {status && <section className="state notes-status-state">{status}</section>}
238
- <NotesFileTree
239
- entries={props.tree}
240
- selectedPath={props.selectedPath}
241
- selectedFolderPath={selectedFolderPath}
242
- dirtyPath={props.dirtyPath}
243
- loading={props.loading}
244
- width={props.treeWidth}
245
- notesRoot={props.notesRoot}
246
- onSelectPath={props.onSelectedPathChange}
247
- onSelectFolder={setSelectedFolderPath}
248
- onMovePath={(from, toFolder) => void moveNote(from, toFolder)}
249
- onRenamePath={(path, kind) => void renamePath(path, kind)}
250
- onTrashPath={(path, kind) => void trashPath(path, kind)}
251
- onTrashPaths={items => void trashPaths(items)}
252
- onCreateNote={() => void createNote()}
253
- onCreateFolder={() => void createFolder()}
254
- onRefresh={() => void props.onRefreshTree()}
255
- />
256
- </>
255
+ <div className="notes-tree-header">
256
+ <strong>Notes</strong>
257
+ <div className="notes-tree-actions">
258
+ <button
259
+ type="button"
260
+ className={props.mode === 'search' ? 'is-active' : ''}
261
+ onClick={() => {
262
+ if (props.mode === 'search') props.onModeChange('files');
263
+ else props.onOpenSearch();
264
+ }}
265
+ title="Search notes"
266
+ aria-label="Search notes"
267
+ aria-pressed={props.mode === 'search'}
268
+ >
269
+ <SearchIcon />
270
+ </button>
271
+ <button type="button" onClick={() => void createNote()} title="New note" aria-label="New note"><NewNoteIcon /></button>
272
+ <button type="button" onClick={() => void createFolder()} title="New folder" aria-label="New folder"><NewFolderIcon /></button>
273
+ <button type="button" onClick={() => void props.onRefreshTree()} disabled={props.loading} title="Refresh notes" aria-label="Refresh notes"><RefreshIcon /></button>
274
+ </div>
275
+ </div>
276
+ {props.mode === 'files' ? (
277
+ <NotesFileTree
278
+ entries={props.tree}
279
+ selectedPath={props.selectedPath}
280
+ selectedFolderPath={selectedFolderPath}
281
+ dirtyPath={props.dirtyPath}
282
+ loading={props.loading}
283
+ notesRoot={props.notesRoot}
284
+ onSelectPath={props.onSelectedPathChange}
285
+ onSelectFolder={setSelectedFolderPath}
286
+ onMovePath={(from, toFolder) => void movePath(from, toFolder)}
287
+ onRenamePath={(path, kind) => void renamePath(path, kind)}
288
+ onTrashPath={(path, kind) => void trashPath(path, kind)}
289
+ onTrashPaths={items => void trashPaths(items)}
290
+ />
291
+ ) : (
292
+ <NotesSearchSidebar
293
+ focusToken={props.searchFocusToken}
294
+ onSelect={props.onSelectedPathChange}
295
+ onModeChange={props.onModeChange}
296
+ />
297
+ )}
298
+ </aside>
257
299
  );
258
300
  }
@@ -18,6 +18,7 @@ type NotesWorkspaceProps = {
18
18
  authoringMode: NotesAuthoringMode;
19
19
  wordWrap: boolean;
20
20
  treeWidth: number;
21
+ onOpenSidebarSearch: () => void;
21
22
  onSelectedPathChange: (path: string | null) => void;
22
23
  onDirtyPathChange: (path: string | null) => void;
23
24
  onViewModeChange: (mode: NotesViewMode) => void;
@@ -84,6 +85,18 @@ export function NotesWorkspace(props: NotesWorkspaceProps) {
84
85
  return () => window.removeEventListener('keydown', handleModeShortcut);
85
86
  }, [props.active, props.viewMode, props.authoringMode, props.onViewModeChange, props.onAuthoringModeChange]);
86
87
 
88
+ useEffect(() => {
89
+ if (!props.active) return;
90
+ function handleSearchShortcut(event: KeyboardEvent): void {
91
+ if (!(event.metaKey || event.ctrlKey) || !event.shiftKey || event.key.toLowerCase() !== 'f') return;
92
+ event.preventDefault();
93
+ props.onOpenSidebarSearch();
94
+ }
95
+
96
+ window.addEventListener('keydown', handleSearchShortcut);
97
+ return () => window.removeEventListener('keydown', handleSearchShortcut);
98
+ }, [props.active, props.onOpenSidebarSearch]);
99
+
87
100
  async function handleTitleBlur(event: React.FocusEvent<HTMLInputElement>): Promise<void> {
88
101
  if (renamingRef.current || !props.selectedPath) return;
89
102
  const newTitle = event.currentTarget.value.trim().replace(INVALID_TITLE_CHARS, '');
@@ -3,6 +3,7 @@ export {
3
3
  fetchNotesIndex,
4
4
  fetchNotesCapabilities,
5
5
  fetchNotesTree,
6
+ searchNotes,
6
7
  fetchNoteFile,
7
8
  createNoteFile,
8
9
  saveNoteFile,
@@ -0,0 +1,90 @@
1
+ .notes-tree-actions button.is-active {
2
+ border-color: var(--accent-soft);
3
+ background: var(--accent-soft);
4
+ color: var(--text-primary);
5
+ }
6
+
7
+ .notes-search-sidebar {
8
+ min-height: 0;
9
+ display: grid;
10
+ grid-template-rows: max-content minmax(0, 1fr);
11
+ overflow: hidden;
12
+ }
13
+
14
+ .notes-search-sidebar-header {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 8px;
18
+ padding: 8px;
19
+ border-bottom: 1px solid var(--border-subtle);
20
+ }
21
+
22
+ .notes-search-input {
23
+ min-width: 0;
24
+ flex: 1 1 auto;
25
+ height: 34px;
26
+ border: 1px solid var(--border-subtle);
27
+ border-radius: 7px;
28
+ padding: 0 10px;
29
+ background: var(--bg-panel);
30
+ color: var(--text-primary);
31
+ font: inherit;
32
+ }
33
+
34
+ .notes-search-sidebar-results {
35
+ min-height: 0;
36
+ overflow: auto;
37
+ padding: 6px;
38
+ }
39
+
40
+ .notes-search-result {
41
+ width: 100%;
42
+ display: grid;
43
+ gap: 3px;
44
+ padding: 9px 10px;
45
+ border: 1px solid transparent;
46
+ border-radius: 7px;
47
+ background: transparent;
48
+ color: var(--text-primary);
49
+ text-align: left;
50
+ cursor: pointer;
51
+ }
52
+
53
+ .notes-search-result:hover,
54
+ .notes-search-result:focus-visible {
55
+ border-color: var(--border-subtle);
56
+ background: var(--canvas-mid);
57
+ }
58
+
59
+ .notes-search-result-path {
60
+ overflow: hidden;
61
+ text-overflow: ellipsis;
62
+ white-space: nowrap;
63
+ font-size: 13px;
64
+ font-weight: 600;
65
+ }
66
+
67
+ .notes-search-result-line {
68
+ color: var(--text-tertiary);
69
+ font-size: 11px;
70
+ }
71
+
72
+ .notes-search-result-context {
73
+ overflow: hidden;
74
+ text-overflow: ellipsis;
75
+ white-space: nowrap;
76
+ color: var(--text-secondary);
77
+ font-size: 12px;
78
+ }
79
+
80
+ .notes-search-loading,
81
+ .notes-search-empty,
82
+ .notes-search-error {
83
+ padding: 14px 10px;
84
+ color: var(--text-secondary);
85
+ font-size: 13px;
86
+ }
87
+
88
+ .notes-search-error {
89
+ color: var(--danger, #c34a4a);
90
+ }
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  DashboardNoteFileResponse,
3
+ DashboardNoteSearchResult,
3
4
  DashboardNotesCapabilities,
4
5
  DashboardNoteTreeEntry,
5
6
  VaultIndexSnapshot,
@@ -20,5 +21,6 @@ export type NoteConflictState = {
20
21
 
21
22
  export type NotesTreeEntry = DashboardNoteTreeEntry;
22
23
  export type NoteFile = DashboardNoteFileResponse;
24
+ export type NoteSearchResult = DashboardNoteSearchResult;
23
25
  export type NotesVaultIndexSnapshot = VaultIndexSnapshot;
24
26
  export type NotesCapabilities = DashboardNotesCapabilities;
@@ -13,10 +13,29 @@ export type PreviewTransport = 'origin-port' | 'legacy-path' | 'none';
13
13
 
14
14
  type PreviewArgument = PreviewTheme | 'proxy' | 'direct' | undefined;
15
15
 
16
+ function isLoopbackHost(hostname: string): boolean {
17
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]';
18
+ }
19
+
16
20
  function isPreviewTheme(value: PreviewArgument): value is PreviewTheme {
17
21
  return value === 'dark' || value === 'light';
18
22
  }
19
23
 
24
+ export function normalizePreviewUrlForCurrentHost(src: string, currentHref?: string): string {
25
+ const href = currentHref || (typeof window !== 'undefined' ? window.location.href : '');
26
+ if (!href || src.startsWith('/')) return src;
27
+ try {
28
+ const previewUrl = new URL(src);
29
+ const currentUrl = new URL(href);
30
+ if (isLoopbackHost(previewUrl.hostname) && isLoopbackHost(currentUrl.hostname)) {
31
+ previewUrl.hostname = currentUrl.hostname;
32
+ }
33
+ return previewUrl.toString();
34
+ } catch {
35
+ return src;
36
+ }
37
+ }
38
+
20
39
  export function appendPreviewTheme(src: string, theme: PreviewTheme | null | undefined): string {
21
40
  if (!theme) return src;
22
41
  const isRelative = src.startsWith('/');
@@ -51,7 +70,7 @@ export function buildPreviewState(
51
70
  if (proxy.preview?.enabled && originPreview?.status === 'ready' && originPreview.url) {
52
71
  return {
53
72
  canPreview: true,
54
- src: appendPreviewTheme(originPreview.url, theme),
73
+ src: appendPreviewTheme(normalizePreviewUrlForCurrentHost(originPreview.url), theme),
55
74
  reason: null,
56
75
  transport: 'origin-port',
57
76
  warning: 'origin proxy ready',
@@ -285,6 +285,14 @@ export type DashboardNoteFileResponse = {
285
285
  size: number;
286
286
  };
287
287
 
288
+ export type DashboardNoteSearchResult = {
289
+ path: string;
290
+ line: number;
291
+ content: string;
292
+ context: string;
293
+ kind: 'path' | 'content';
294
+ };
295
+
288
296
  export type DashboardPutNoteRequest = {
289
297
  path: string;
290
298
  content: string;