Fix streaming content disappearing on window switch and improve skills scanning

- Add useRef + visibilitychange/focus listener to re-sync streaming content
  when Electron window regains focus, preventing blank content flash
- Remove duplicate text SSE events from assistant handler (stream_event
  already provides real-time deltas)
- Make skills scanner recursive to find commands in subdirectories
- Add debug logging for skills directory resolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-07 02:23:53 +08:00
1 parent fab8b41966
commit a5e9fc76a8
3 files changed
+59 -15

No files matched your search

+33 -9
View File
@@ -133,17 +133,30 @@ function scanInstalledSkills(): SkillFile[] {
function scanDirectory(
dir: string,
source: "global" | "project" | "plugin"
source: "global" | "project" | "plugin",
prefix = ""
): SkillFile[] {
const skills: SkillFile[] = [];
if (!fs.existsSync(dir)) return skills;
try {
const files = fs.readdirSync(dir);
for (const file of files) {
if (!file.endsWith(".md")) continue;
const name = file.replace(/\.md$/, "");
const filePath = path.join(dir, file);
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recurse into subdirectories (e.g. ~/.claude/commands/review/pr.md)
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name;
skills.push(...scanDirectory(fullPath, source, subPrefix));
continue;
}
if (!entry.name.endsWith(".md")) continue;
const baseName = entry.name.replace(/\.md$/, "");
const name = prefix ? `${prefix}:${baseName}` : baseName;
const filePath = fullPath;
const content = fs.readFileSync(filePath, "utf-8");
const firstLine = content.split("\n")[0]?.trim() || "";
const description = firstLine.startsWith("#")
@@ -161,8 +174,15 @@ export async function GET(request: NextRequest) {
try {
// Accept optional cwd query param for project-level skills
const cwd = request.nextUrl.searchParams.get("cwd") || undefined;
const globalSkills = scanDirectory(getGlobalCommandsDir(), "global");
const projectSkills = scanDirectory(getProjectCommandsDir(cwd), "project");
const globalDir = getGlobalCommandsDir();
const projectDir = getProjectCommandsDir(cwd);
console.log(`[skills] Scanning global: ${globalDir} (exists: ${fs.existsSync(globalDir)})`);
console.log(`[skills] Scanning project: ${projectDir} (exists: ${fs.existsSync(projectDir)})`);
console.log(`[skills] HOME=${process.env.HOME}, homedir=${os.homedir()}`);
const globalSkills = scanDirectory(globalDir, "global");
const projectSkills = scanDirectory(projectDir, "project");
const installedSkills = scanInstalledSkills();
// Scan installed plugin skills
@@ -171,8 +191,12 @@ export async function GET(request: NextRequest) {
pluginSkills.push(...scanDirectory(dir, "plugin"));
}
return NextResponse.json({ skills: [...globalSkills, ...projectSkills, ...installedSkills, ...pluginSkills] });
const all = [...globalSkills, ...projectSkills, ...installedSkills, ...pluginSkills];
console.log(`[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`);
return NextResponse.json({ skills: all });
} catch (error) {
console.error('[skills] Error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to load skills" },
{ status: 500 }
+23
View File
@@ -66,6 +66,25 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
}
}, [sessionId, setWorkingDirectory, setPanelOpen]);
// Ref to keep accumulated streaming content in sync regardless of React batching
const accumulatedRef = useRef('');
// Re-sync streaming content when the window regains visibility (Electron/browser tab switch)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && accumulatedRef.current) {
setStreamingContent(accumulatedRef.current);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Also handle Electron-specific focus events
window.addEventListener('focus', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleVisibilityChange);
};
}, []);
const initializedRef = useRef(false);
useEffect(() => {
if (initialMessages.length > 0 && !initializedRef.current) {
@@ -138,6 +157,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
setIsStreaming(true);
setStreamingSessionId(sessionId);
setStreamingContent('');
accumulatedRef.current = '';
setToolUses([]);
setToolResults([]);
setStatusText(undefined);
@@ -185,6 +205,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
switch (event.type) {
case 'text': {
accumulated += event.data;
accumulatedRef.current = accumulated;
setStreamingContent(accumulated);
break;
}
@@ -289,6 +310,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
case 'error': {
accumulated += '\n\n**Error:** ' + event.data;
accumulatedRef.current = accumulated;
setStreamingContent(accumulated);
break;
}
@@ -346,6 +368,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
setIsStreaming(false);
setStreamingSessionId('');
setStreamingContent('');
accumulatedRef.current = '';
setToolUses([]);
setToolResults([]);
setStreamingToolOutput('');
+3 -6
View File
@@ -289,13 +289,10 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
switch (message.type) {
case 'assistant': {
const assistantMsg = message as SDKAssistantMessage;
// Text deltas are handled by stream_event for real-time streaming.
// Only track lastAssistantText here and process tool_use blocks.
const text = extractTextFromMessage(assistantMsg);
if (text && text !== lastAssistantText) {
// Send the new delta
const delta = text.slice(lastAssistantText.length);
if (delta) {
controller.enqueue(formatSSE({ type: 'text', data: delta }));
}
if (text) {
lastAssistantText = text;
}