Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
a40d57a
fix(mcp): validate params and reject unknown parameters with helpful …
Mar 28, 2026
6a69e1a
docs: add trycycle title search plan
Mar 27, 2026
6c96e41
docs: tighten title-search implementation plan
Mar 27, 2026
6e1c572
docs: fix title search subdir implementation plan
Mar 27, 2026
4b6360b
docs: tighten title search implementation plan
Mar 27, 2026
d59ae95
docs: add title search subdir test plan
Mar 27, 2026
b1f228d
feat: extend title search with subdirectory matches
Mar 27, 2026
3b3019e
refactor: track applied sidebar search state
Mar 27, 2026
8193a6e
feat: finalize applied sidebar search behavior
Mar 27, 2026
7546801
fix: honor applied sidebar search state
Mar 27, 2026
ed3e3da
test: remove remaining skipped coverage
Mar 27, 2026
fdde6ff
fix: sync sidebar search controls with requested state
Mar 27, 2026
bd7736c
fix: restore sidebar request state contract
Mar 27, 2026
a240949
fix: hide sidebar search chrome during browse refresh
Mar 27, 2026
ca50ad0
fix: preserve visible sidebar refresh state
Mar 27, 2026
c9460a6
docs: revise title search implementation plan
Mar 27, 2026
13279f9
docs: fix trycycle title-search plan
Mar 27, 2026
5b4eaad
docs: refocus title-search implementation plan
Mar 27, 2026
8408ab6
docs: revise title-search subdir test plan
Mar 27, 2026
461ea90
refactor: split sidebar replacement and refresh commits
Mar 27, 2026
bdf68e6
fix: refresh sidebar results by visible identity
Mar 27, 2026
9f742de
test: lock refresh drift regressions
Mar 27, 2026
f4577dd
test: lock direct refresh regressions
Mar 28, 2026
0f67a76
fix: preserve sidebar search debounce on stale commits
Mar 28, 2026
c21c473
docs: add plan for fixing tool strip showTools toggle
Mar 27, 2026
d9da0e1
fix: make tool strip toggle session-only, controlled by showTools prop
Mar 27, 2026
e8452d6
fix: remove dead code from AgentChatView and browserPreferencesPersis…
Mar 28, 2026
67e3287
fix: always show tool strip chevron, showTools only controls default …
Mar 28, 2026
2f373a1
plan: clickable terminal URLs with context menu integration
Mar 29, 2026
b0a4455
plan: improve clickable-terminal-urls plan with verified code references
Mar 29, 2026
572d822
plan: refine clickable-terminal-urls plan after code verification
Mar 29, 2026
dca0d9c
plan: fix xterm link provider priority order and test file references
Mar 29, 2026
cfe933b
test-plan: concrete enumerated test plan for clickable terminal URLs
Mar 29, 2026
ee7516a
feat: add terminal-hovered-url and url-utils utility modules with tests
Mar 29, 2026
bb5fefd
feat: URL click opens browser pane, add hover/leave tracking and URL …
Mar 29, 2026
e91e3e1
feat: add URL context menu items for terminal panes
Mar 29, 2026
e71a33d
test: add integration tests for URL click and context menu
Mar 29, 2026
988aefa
test: add hidden state cleanup test for hovered URL
Mar 29, 2026
ed423a9
fix: update file link test to capture first provider, not last
Mar 29, 2026
f74b6c4
refactor: fix lint issues in url-utils and TerminalView cleanup
Mar 29, 2026
3803106
fix: balanced parens in URL detection, scheme validation on OSC 8 lin…
Mar 29, 2026
adc4b5a
fix: add button guard to prevent right/middle-click link activation
Mar 29, 2026
9b25f5e
chore: gitignore opencode MCP state and ephemeral files
Mar 29, 2026
bf2be81
fix: defer terminal link pane splits
Mar 29, 2026
3b42d33
refactor: tighten terminal link split deferral
Mar 29, 2026
ad85bf9
fix: support precheck in worktrees
Mar 29, 2026
4e6acb8
fix: type queued terminal pane splits as pane inputs
Mar 29, 2026
d418b4a
ci: add client typecheck workflow
Mar 29, 2026
118efc9
docs: add editor auto-sync plan
Mar 29, 2026
43399ea
docs: revise auto-sync plan addressing fresheyes review
Mar 29, 2026
a2f55f7
docs: apply fresheyes review #2 fixes to auto-sync plan
Mar 29, 2026
1442277
docs: add timer-cancellation order to handleKeepLocal
Mar 29, 2026
97895c2
docs: add fresheyes errata section with 8 required fixes
Mar 29, 2026
b9d1816
feat: add GET /api/files/stat endpoint for lightweight file metadata
Mar 30, 2026
8cd9ea4
test: prepare auto-save tests for stat-polling
Mar 30, 2026
0ab0450
feat: add stat-polling auto-sync to editor pane
Mar 30, 2026
dd1c900
merge: resolve conflicts with main
Mar 31, 2026
c71a8aa
chore: remove .opencode config and add to .gitignore
mattleaverton Mar 31, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ artifacts/perf/

# Runtime / agent artifacts
.superpowers/
.opencode/
sdk.session.snapshot
1,009 changes: 1,009 additions & 0 deletions docs/plans/2026-03-29-editor-auto-sync.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions server/files-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,33 @@ export function createFilesRouter(deps: FilesRouterDeps): Router {
}
})

router.get('/stat', validatePath, async (req, res) => {
const filePath = req.query.path as string
if (!filePath) {
return res.status(400).json({ error: 'path query parameter required' })
}

const resolved = await resolveUserFilesystemPath(filePath)

try {
const stat = await fsp.stat(resolved)
if (stat.isDirectory()) {
return res.json({ exists: false, size: null, modifiedAt: null })
}

res.json({
exists: true,
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
})
} catch (err: any) {
if (err.code === 'ENOENT') {
return res.json({ exists: false, size: null, modifiedAt: null })
}
return res.status(500).json({ error: err.message })
}
})

router.post('/write', validatePath, async (req, res) => {
const { path: filePath, content } = req.body

Expand Down
145 changes: 143 additions & 2 deletions src/components/panes/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ export default function EditorPane({
const [terminalCwds, setTerminalCwds] = useState<Record<string, string>>({})
const [filePickerMessage, setFilePickerMessage] = useState<string | null>(null)

const lastSavedContent = useRef<string>(content)
const lastKnownMtime = useRef<string | null>(null)
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null)

const [conflictState, setConflictState] = useState<{
diskContent: string
diskMtime: string
} | null>(null)

const firstTerminalCwd = useMemo(
() => (layout ? getFirstTerminalCwd(layout, terminalCwds) : null),
[layout, terminalCwds]
Expand Down Expand Up @@ -351,6 +360,7 @@ export default function EditorPane({
content: string
language?: string
filePath?: string
modifiedAt?: string
}>(`/api/files/read?path=${encodeURIComponent(resolvedPath)}`)

const resolvedFilePath = response.filePath || resolvedPath
Expand All @@ -370,6 +380,8 @@ export default function EditorPane({
setCurrentLanguage(resolvedLanguage)
setCurrentViewMode(nextViewMode)
pendingContent.current = response.content
lastSavedContent.current = response.content
lastKnownMtime.current = response.modifiedAt || null
Comment on lines 382 to +384
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reset conflict state when switching to another file

When a disk-conflict is active, selecting a new path through handlePathSelect updates the editor content but leaves conflictState untouched. In that state, the banner/actions still point to the previous file’s snapshot, so clicking “Reload” can inject old-file content into the newly selected file and later saves can overwrite the wrong path; polling also remains disabled because poll returns early while conflictState is set. Clear conflict state after a successful file load (or on file-path change).

Useful? React with 👍 / 👎.


if (editorRef.current) {
const model = editorRef.current.getModel()
Expand Down Expand Up @@ -581,10 +593,12 @@ export default function EditorPane({
if (filePath) {
const resolved = resolvePath(filePath)
if (!resolved) return
await api.post('/api/files/write', {
const saveResult = await api.post<{ success: boolean; modifiedAt?: string }>('/api/files/write', {
path: resolved,
content: value,
})
lastKnownMtime.current = saveResult?.modifiedAt || null
lastSavedContent.current = value
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
Expand Down Expand Up @@ -616,10 +630,12 @@ export default function EditorPane({
if (filePath) {
const resolved = resolvePath(filePath)
if (!resolved) return
await api.post('/api/files/write', {
const saveResult = await api.post<{ success: boolean; modifiedAt?: string }>('/api/files/write', {
path: resolved,
content: value,
})
lastKnownMtime.current = saveResult?.modifiedAt || null
lastSavedContent.current = value
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
Expand Down Expand Up @@ -676,6 +692,106 @@ export default function EditorPane({
updateContent({ viewMode: nextMode })
}, [currentViewMode, updateContent])

const handleReloadFromDisk = useCallback(() => {
if (!conflictState) return
if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current)
autoSaveTimer.current = null
}
setEditorValue(conflictState.diskContent)
pendingContent.current = conflictState.diskContent
lastSavedContent.current = conflictState.diskContent
lastKnownMtime.current = conflictState.diskMtime
updateContent({ content: conflictState.diskContent })
setConflictState(null)
}, [conflictState, updateContent])

const handleKeepLocal = useCallback(() => {
if (!conflictState) return
if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current)
autoSaveTimer.current = null
}
lastKnownMtime.current = conflictState.diskMtime
setConflictState(null)
scheduleAutoSave(pendingContent.current)
}, [conflictState, scheduleAutoSave])

useEffect(() => {
if (!filePath) return
if (fileHandleRef.current) return

const poll = async () => {
if (!mountedRef.current) return
if (conflictState) return

const resolved = resolvePath(filePath)
if (!resolved) return

try {
const statResult = await api.get<{
exists: boolean
size: number | null
modifiedAt: string | null
}>(`/api/files/stat?path=${encodeURIComponent(resolved)}`)

if (!mountedRef.current) return

if (!statResult.exists || !statResult.modifiedAt) return
if (statResult.modifiedAt === lastKnownMtime.current) return

const wasClean = pendingContent.current === lastSavedContent.current
const response = await api.get<{
content: string
language?: string
filePath?: string
modifiedAt?: string
}>(`/api/files/read?path=${encodeURIComponent(resolved)}`)

if (!mountedRef.current) return

const stillClean = pendingContent.current === lastSavedContent.current
if (wasClean && stillClean) {
setEditorValue(response.content)
pendingContent.current = response.content
lastSavedContent.current = response.content
lastKnownMtime.current = response.modifiedAt || null

updateContent({
content: response.content,
})
} else {
if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current)
autoSaveTimer.current = null
}
setConflictState({
diskContent: response.content,
diskMtime: response.modifiedAt || statResult.modifiedAt!,
})
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
log.error(
JSON.stringify({
severity: 'error',
event: 'editor_stat_poll_failed',
error: message,
})
)
}
}

pollIntervalRef.current = setInterval(poll, 3000)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Serialize stat polling to avoid stale overwrite races

The polling loop is scheduled with setInterval around an async poll function, so a slow /api/files/read request can overlap with the next tick. If responses arrive out of order, an older read can still apply response.content/modifiedAt and overwrite newer state, causing temporary rollback or incorrect conflict decisions. Use an in-flight guard (or recursive setTimeout) so only one poll request chain runs at a time.

Useful? React with 👍 / 👎.


return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
pollIntervalRef.current = null
}
}
}, [filePath, resolvePath, conflictState, updateContent])

useEffect(() => {
return registerEditorActions(paneId, {
cut: () => editorRef.current?.getAction('editor.action.clipboardCutAction')?.run(),
Expand Down Expand Up @@ -722,6 +838,31 @@ export default function EditorPane({
{filePickerMessage}
</div>
)}
{conflictState && (
<div
className="flex items-center gap-2 px-3 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-sm"
role="alert"
data-testid="editor-conflict-banner"
>
<span className="flex-1 text-yellow-700 dark:text-yellow-400">
File changed on disk
</span>
<button
className="rounded px-2 py-1 text-xs font-medium bg-yellow-500/20 hover:bg-yellow-500/30"
onClick={handleReloadFromDisk}
aria-label="Reload file from disk"
>
Reload
</button>
<button
className="rounded px-2 py-1 text-xs font-medium bg-muted hover:bg-muted/80"
onClick={handleKeepLocal}
aria-label="Keep local changes"
>
Keep Mine
</button>
</div>
)}
<div className="flex-1 relative min-h-0 overflow-hidden">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
Expand Down
1 change: 1 addition & 0 deletions src/lib/terminal-hovered-url.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Tracks which URL is currently hovered per terminal pane for context menus.


const hoveredUrls = new Map<string, string>()

export function setHoveredUrl(paneId: string, url: string): void {
Expand Down
1 change: 1 addition & 0 deletions src/lib/url-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Detects http/https URLs in terminal output text with balanced-paren handling.


export type UrlMatch = {
url: string
startIndex: number
Expand Down
3 changes: 1 addition & 2 deletions test/integration/client/editor-pane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,7 @@ describe('Editor Pane Integration', () => {
})

afterEach(async () => {
// Flush all pending timers (e.g., debounced functions) before cleanup
await vi.runAllTimersAsync()
await vi.runOnlyPendingTimersAsync()
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
Expand Down
Loading
Loading