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:
1 parent
fab8b41966
commit
a5e9fc76a8
3 files changed
+59
-15
No files matched your search
@@ -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 }
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user