Fix subagent crash and add external OpenCode server support (#188)

* 🐛 Fix UI crash when subagent is active

Remove sessions dependency from hooks to prevent cascading re-renders.
Use getState() instead and switch to getGlobalSessionStatus().

*  Add support for connecting to external OpenCode server

Add OPENCODE_SKIP_START env var to skip starting embedded server.
Use OPENCODE_PORT to connect to existing OpenCode instance.
Update help text to document the new environment variables.

* 📝 Document external OpenCode server support

Add OPENCODE_PORT and OPENCODE_SKIP_START to READMEs.
Update AGENTS.md with external server integration notes.

*  Add URL-based routing for shareable session links

- Add react-router-dom dependency
- Create URL store for bi-directional sync with Zustand
- Add WebRouter/DesktopRouter context for runtime-aware routing
- Add useURLSync hook to sync URL with session/tab state
- Add useNavigation hook with copySessionLink utility
- Add share button in Header for copying session links
- Update App.tsx to use router wrappers

URL structure:
  /session/:sessionId?tab={chat|git|diff|terminal|files}&directory=/path
  /settings

This enables shareable links and deep-linking to specific sessions.

* Revert " Add URL-based routing for shareable session links"

This reverts commit b53ee304950a789538fa3d4a236d6361e634ea61.
This commit is contained in:
Taylor Beeston
2026-01-22 18:32:04 +02:00
committed by GitHub
parent 1937dbfcd2
commit 5fabc88f9e
7 changed files with 68 additions and 35 deletions
+38 -30
View File
@@ -129,7 +129,6 @@ export const useEventStream = () => {
dismissQuestion,
currentSessionId,
applySessionMetadata,
sessions,
getWorktreeMetadata,
loadMessages,
loadSessions,
@@ -152,13 +151,15 @@ export const useEventStream = () => {
console.warn('Failed to inspect worktree metadata for session directory:', error);
}
const sessionRecord = sessions.find((entry) => entry.id === currentSessionId);
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
const sessionRecord = currentSessions.find((entry) => entry.id === currentSessionId);
if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) {
return sessionRecord.directory.trim();
}
return undefined;
}, [currentSessionId, getWorktreeMetadata, sessions]);
}, [currentSessionId, getWorktreeMetadata]);
const effectiveDirectory = React.useMemo(() => {
if (activeSessionDirectory && activeSessionDirectory.length > 0) {
@@ -180,9 +181,9 @@ export const useEventStream = () => {
return;
}
pending.forEach((request) => {
for (const request of pending) {
addPermission(request as unknown as PermissionRequest);
});
}
} catch {
// ignored
}
@@ -192,7 +193,9 @@ export const useEventStream = () => {
try {
const projects = useProjectsStore.getState().projects;
const projectDirs = projects.map((project) => project.path);
const sessionDirs = sessions.map((session) => (session as { directory?: string | null }).directory);
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
const sessionDirs = currentSessions.map((session) => (session as { directory?: string | null }).directory);
const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs];
@@ -201,9 +204,9 @@ export const useEventStream = () => {
return;
}
pending.forEach((request) => {
for (const request of pending) {
addQuestion(request as unknown as QuestionRequest);
});
}
} catch {
// ignored
}
@@ -215,7 +218,7 @@ export const useEventStream = () => {
return () => {
cancelled = true;
};
}, [addPermission, addQuestion, effectiveDirectory, sessions]);
}, [addPermission, addQuestion, effectiveDirectory]);
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
if (typeof value !== 'string') return null;
@@ -236,10 +239,12 @@ export const useEventStream = () => {
// ignored
}
const record = sessions.find((entry) => entry.id === sessionId);
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
const record = currentSessions.find((entry) => entry.id === sessionId);
return normalizeDirectory((record as { directory?: string | null })?.directory ?? null);
},
[getWorktreeMetadata, normalizeDirectory, sessions]
[getWorktreeMetadata, normalizeDirectory]
);
const setEventStreamStatus = useUIStore((state) => state.setEventStreamStatus);
@@ -417,7 +422,9 @@ export const useEventStream = () => {
// ignored
}
const sessionRecord = sessions.find((entry) => entry.id === id) as Session & { directory?: string | null };
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
const sessionRecord = currentSessions.find((entry) => entry.id === id) as Session & { directory?: string | null };
if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) {
return sessionRecord.directory.trim();
}
@@ -449,7 +456,7 @@ export const useEventStream = () => {
}
}, 100);
},
[applySessionMetadata, getWorktreeMetadata, sessions]
[applySessionMetadata, getWorktreeMetadata]
);
@@ -498,15 +505,17 @@ export const useEventStream = () => {
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
const observed = new Set<string>();
const knownSessionIds = new Set(sessions.map((session) => session.id));
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
const knownSessionIds = new Set(currentSessions.map((session) => session.id));
Object.entries(statusMap).forEach(([sessionId, raw]) => {
if (!sessionId || !raw) return;
for (const [sessionId, raw] of Object.entries(statusMap)) {
if (!sessionId || !raw) continue;
observed.add(sessionId);
const phase: 'idle' | 'busy' =
raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle';
updateSessionActivityPhase(sessionId, phase);
});
}
// OpenCode's /session/status may omit idle sessions (returns only busy/retry).
// Treat missing entries as idle to avoid sessions getting stuck "working".
@@ -530,10 +539,12 @@ export const useEventStream = () => {
}
const directories = new Set<string>();
sessions.forEach((session) => {
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
for (const session of currentSessions) {
const directory = resolveSessionDirectoryForStatus(session.id);
if (directory) directories.add(directory);
});
}
const effective = normalizeDirectory(effectiveDirectory ?? null);
if (effective) directories.add(effective);
@@ -553,10 +564,10 @@ export const useEventStream = () => {
);
const merged: Record<string, { type?: string }> = {};
results.forEach((result) => {
if (result.status !== 'fulfilled' || !result.value) return;
for (const result of results) {
if (result.status !== 'fulfilled' || !result.value) continue;
Object.assign(merged, result.value);
});
}
if (Object.keys(merged).length === 0) {
const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
@@ -581,7 +592,7 @@ export const useEventStream = () => {
sessionStatusRefreshInFlightRef.current = task;
return task;
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, sessions, updateSessionActivityPhase]);
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionActivityPhase]);
React.useEffect(() => {
const nextSessionId = currentSessionId ?? null;
@@ -1452,13 +1463,10 @@ export const useEventStream = () => {
lastEventTimestampRef.current = Date.now();
publishStatus('connected', null);
checkConnection();
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
(phase) => phase === 'busy' || phase === 'cooldown'
);
if (hasBusySessions) {
void refreshSessionActivityStatus();
}
// Always refresh session activity status on connect to detect any
// already-running sessions (e.g., started via CLI before UI opened)
void refreshSessionActivityStatus();
if (shouldRefresh) {
void bootstrapState('sse_reconnected');
@@ -15,7 +15,9 @@ export const useSessionStatusBootstrap = () => {
const bootstrap = async () => {
try {
const statusMap = await opencodeClient.getSessionStatus();
// Use global status to detect busy sessions across all directories,
// including sessions started externally (e.g., via CLI) before UI opened
const statusMap = await opencodeClient.getGlobalSessionStatus();
if (cancelled || !statusMap) return;
const phases = new Map<string, 'idle' | 'busy' | 'cooldown'>();