mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
feat(provider): run OpenAI-compatible models through Claude Code proxy
Replace the hand-written direct-provider runtime with a local Anthropic-to-OpenAI-compatible proxy so Qwen, DeepSeek, GLM, Gemini, and other compatible providers use the Claude Code CLI tool/session experience. Normalize strict OpenAI tool_call/tool_result pairing to avoid provider 400 errors, keep session titles stable and synced, allow switching sessions while another chat is streaming, show running indicators, add provider deletion with confirmation, and auto-refresh the file sidebar with refresh feedback.
This commit is contained in:
parent
1b8ec314e5
commit
07c8acaeb6
10 changed files with 1843 additions and 6685 deletions
1103
apps/desktop/src-tauri/src/anthropic_proxy.rs
Normal file
1103
apps/desktop/src-tauri/src/anthropic_proxy.rs
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
#![recursion_limit = "512"]
|
||||
|
||||
mod anthropic_proxy;
|
||||
mod claude;
|
||||
mod history;
|
||||
mod latex;
|
||||
|
|
@ -606,6 +607,7 @@ pub fn run() {
|
|||
claude::list_openai_compatible_credential_models,
|
||||
claude::clear_anthropic_api_key,
|
||||
claude::list_openai_compatible_credentials,
|
||||
claude::delete_openai_compatible_credential,
|
||||
claude::set_active_openai_compatible_credential,
|
||||
claude::execute_claude_code,
|
||||
claude::continue_claude_code,
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ function WorkspaceWithClaude() {
|
|||
return;
|
||||
}
|
||||
|
||||
current.resumeSession(latest.session_id).catch((err) => {
|
||||
current.resumeSession(latest.session_id, latest.title).catch((err) => {
|
||||
log.warn("Failed to auto-resume latest chat session", {
|
||||
sessionId: latest.session_id,
|
||||
error: String(err),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
type FC,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
|
|
@ -25,6 +26,8 @@ import {
|
|||
RabbitIcon,
|
||||
LayersIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs";
|
||||
|
|
@ -35,7 +38,10 @@ import {
|
|||
useClaudeChatStore,
|
||||
offsetToLineCol,
|
||||
} from "@/stores/claude-chat-store";
|
||||
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
|
||||
import {
|
||||
useClaudeSetupStore,
|
||||
type OpenAiCompatibleCredentialInfo,
|
||||
} from "@/stores/claude-setup-store";
|
||||
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
|
||||
import { getUniqueTargetName } from "@/lib/tauri/fs";
|
||||
import { createPdfTextSidecar, isPdfPath } from "@/lib/pdf-text-extractor";
|
||||
|
|
@ -49,9 +55,11 @@ import {
|
|||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SlashCommandPicker, type SlashCommand } from "./slash-command-picker";
|
||||
import { createLogger } from "@/lib/debug/logger";
|
||||
|
|
@ -110,6 +118,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
const setActiveApiCredential = useClaudeSetupStore(
|
||||
(s) => s.setActiveApiCredential,
|
||||
);
|
||||
const deleteApiCredential = useClaudeSetupStore(
|
||||
(s) => s.deleteApiCredential,
|
||||
);
|
||||
const activeProviderCredential =
|
||||
openAiCredentials.find(
|
||||
(credential) => credential.id === activeOpenAiCredentialId,
|
||||
|
|
@ -156,6 +167,14 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
null,
|
||||
);
|
||||
const [providerSetupOpen, setProviderSetupOpen] = useState(false);
|
||||
const [providerDeleteTarget, setProviderDeleteTarget] =
|
||||
useState<OpenAiCompatibleCredentialInfo | null>(null);
|
||||
const [providerDeleteError, setProviderDeleteError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [deletingProviderId, setDeletingProviderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
|
@ -202,6 +221,57 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
setSelectedProviderCredentialId,
|
||||
]);
|
||||
|
||||
const handleDeleteProviderCredential = useCallback(
|
||||
async (credentialId: string) => {
|
||||
if (deletingProviderId) return;
|
||||
|
||||
const remainingCredentials = openAiCredentials.filter(
|
||||
(credential) => credential.id !== credentialId,
|
||||
);
|
||||
const deletingSelected =
|
||||
selectedProviderCredentialId === credentialId ||
|
||||
selectedProviderCredential?.id === credentialId;
|
||||
|
||||
setDeletingProviderId(credentialId);
|
||||
setProviderDeleteError(null);
|
||||
try {
|
||||
const success = await deleteApiCredential(credentialId);
|
||||
if (!success) {
|
||||
setProviderDeleteError("Failed to delete this provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderModelOptions((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[credentialId];
|
||||
return next;
|
||||
});
|
||||
|
||||
if (deletingSelected) {
|
||||
const nextCredential = remainingCredentials[0] ?? null;
|
||||
if (nextCredential) {
|
||||
setSelectedProviderCredentialId(nextCredential.id);
|
||||
void setActiveApiCredential(nextCredential.id);
|
||||
} else {
|
||||
setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID);
|
||||
}
|
||||
}
|
||||
setProviderDeleteTarget(null);
|
||||
} finally {
|
||||
setDeletingProviderId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
deleteApiCredential,
|
||||
deletingProviderId,
|
||||
openAiCredentials,
|
||||
selectedProviderCredential?.id,
|
||||
selectedProviderCredentialId,
|
||||
setActiveApiCredential,
|
||||
setSelectedProviderCredentialId,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelPickerOpen || !selectedProviderCredential) return;
|
||||
|
||||
|
|
@ -990,18 +1060,31 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
const currentModel =
|
||||
selectedProviderModels[credential.id] || credential.model;
|
||||
|
||||
const isDeleting = deletingProviderId === credential.id;
|
||||
const selectCredential = () => {
|
||||
if (isDeleting) return;
|
||||
setSelectedProviderCredentialId(credential.id);
|
||||
void setActiveApiCredential(credential.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={credential.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
"group/provider flex w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pr-1 pl-3 text-left text-sm transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted",
|
||||
isDeleting && "pointer-events-none opacity-70",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedProviderCredentialId(credential.id);
|
||||
void setActiveApiCredential(credential.id);
|
||||
onClick={selectCredential}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
selectCredential();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{iconSrc ? (
|
||||
|
|
@ -1021,8 +1104,29 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
{currentModel}
|
||||
</div>
|
||||
</div>
|
||||
{active && <CheckIcon className="size-3 shrink-0" />}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{active && <CheckIcon className="size-3 shrink-0" />}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Delete ${displayName}`}
|
||||
title="Delete provider"
|
||||
disabled={!!deletingProviderId}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setProviderDeleteError(null);
|
||||
setProviderDeleteTarget(credential);
|
||||
}}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2Icon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
|
|
@ -1169,6 +1273,73 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!providerDeleteTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deletingProviderId) {
|
||||
setProviderDeleteTarget(null);
|
||||
setProviderDeleteError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Delete{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{providerDeleteTarget
|
||||
? getProviderDisplayName({
|
||||
label: providerDeleteTarget.label,
|
||||
baseUrl: providerDeleteTarget.base_url,
|
||||
model: providerDeleteTarget.model,
|
||||
})
|
||||
: "this provider"}
|
||||
</span>{" "}
|
||||
with model{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{providerDeleteTarget?.model || "unknown"}
|
||||
</span>
|
||||
? The API key will be removed from ClaudePrism.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{providerDeleteError && (
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-xs">
|
||||
{providerDeleteError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (deletingProviderId) return;
|
||||
setProviderDeleteTarget(null);
|
||||
setProviderDeleteError(null);
|
||||
}}
|
||||
disabled={!!deletingProviderId}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (providerDeleteTarget) {
|
||||
void handleDeleteProviderCredential(providerDeleteTarget.id);
|
||||
}
|
||||
}}
|
||||
disabled={!providerDeleteTarget || !!deletingProviderId}
|
||||
>
|
||||
{deletingProviderId ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2Icon className="size-3.5" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* @ mention dropdown */}
|
||||
{slashQuery === null &&
|
||||
mentionQuery !== null &&
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
HistoryIcon,
|
||||
|
|
@ -58,10 +58,20 @@ export function SessionSelector() {
|
|||
);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const sessionId = useClaudeChatStore((s) => s.sessionId);
|
||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
||||
const tabs = useClaudeChatStore((s) => s.tabs);
|
||||
const newSession = useClaudeChatStore((s) => s.newSession);
|
||||
const resumeSession = useClaudeChatStore((s) => s.resumeSession);
|
||||
const setSessionTitle = useClaudeChatStore((s) => s._setSessionTitle);
|
||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||
const streamingSessionIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
tabs
|
||||
.filter((tab) => tab.isStreaming && tab.sessionId)
|
||||
.map((tab) => tab.sessionId as string),
|
||||
),
|
||||
[tabs],
|
||||
);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!projectRoot) return;
|
||||
|
|
@ -73,13 +83,16 @@ export function SessionSelector() {
|
|||
});
|
||||
log.debug("loaded sessions", { count: result.length });
|
||||
setSessions(result);
|
||||
for (const session of result) {
|
||||
setSessionTitle(session.session_id, session.title);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to load sessions", { error: String(err) });
|
||||
setSessions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectRoot]);
|
||||
}, [projectRoot, setSessionTitle]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
|
|
@ -91,18 +104,18 @@ export function SessionSelector() {
|
|||
);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sid: string) => {
|
||||
if (isStreaming) return;
|
||||
if (sid === sessionId) return;
|
||||
log.debug(`selecting session: ${sid}`);
|
||||
resumeSession(sid);
|
||||
(session: ClaudeSessionInfo) => {
|
||||
if (deletingId === session.session_id) return;
|
||||
if (session.session_id === sessionId) return;
|
||||
log.debug(`selecting session: ${session.session_id}`);
|
||||
resumeSession(session.session_id, session.title);
|
||||
},
|
||||
[isStreaming, sessionId, resumeSession],
|
||||
[deletingId, sessionId, resumeSession],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
async (sid: string) => {
|
||||
if (isStreaming || deletingId || !projectRoot) return;
|
||||
if (deletingId || !projectRoot || streamingSessionIds.has(sid)) return;
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingId(sid);
|
||||
|
|
@ -128,13 +141,12 @@ export function SessionSelector() {
|
|||
setDeletingId((current) => (current === sid ? null : current));
|
||||
}
|
||||
},
|
||||
[deletingId, isStreaming, newSession, projectRoot, sessionId],
|
||||
[deletingId, newSession, projectRoot, sessionId, streamingSessionIds],
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
if (isStreaming) return;
|
||||
newSession();
|
||||
}, [isStreaming, newSession]);
|
||||
}, [newSession]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -158,7 +170,7 @@ export function SessionSelector() {
|
|||
>
|
||||
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem onSelect={handleNewChat} disabled={isStreaming}>
|
||||
<DropdownMenuItem onSelect={handleNewChat}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span>New Chat</span>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -174,46 +186,62 @@ export function SessionSelector() {
|
|||
No previous sessions
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<DropdownMenuItem
|
||||
key={session.session_id}
|
||||
onSelect={() => handleSelectSession(session.session_id)}
|
||||
disabled={isStreaming || deletingId === session.session_id}
|
||||
className="group flex items-start gap-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{session.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRelativeTime(session.last_modified)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{session.session_id === sessionId && (
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Delete ${session.title}`}
|
||||
title="Delete session"
|
||||
disabled={isStreaming || deletingId === session.session_id}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDeleteError(null);
|
||||
setDeleteTarget(session);
|
||||
}}
|
||||
>
|
||||
{deletingId === session.session_id ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
sessions.map((session) => {
|
||||
const sessionIsStreaming = streamingSessionIds.has(
|
||||
session.session_id,
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={session.session_id}
|
||||
onSelect={() => handleSelectSession(session)}
|
||||
disabled={deletingId === session.session_id}
|
||||
className="group flex items-start gap-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{session.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRelativeTime(session.last_modified)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{sessionIsStreaming ? (
|
||||
<Loader2Icon className="size-4 animate-spin text-primary" />
|
||||
) : (
|
||||
<Trash2Icon className="size-3.5" />
|
||||
session.session_id === sessionId && (
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
|
||||
aria-label={`Delete ${session.title}`}
|
||||
title={
|
||||
sessionIsStreaming
|
||||
? "Cannot delete a running session"
|
||||
: "Delete session"
|
||||
}
|
||||
disabled={
|
||||
sessionIsStreaming ||
|
||||
deletingId === session.session_id
|
||||
}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDeleteError(null);
|
||||
setDeleteTarget(session);
|
||||
}}
|
||||
>
|
||||
{deletingId === session.session_id ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2Icon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -259,7 +287,11 @@ export function SessionSelector() {
|
|||
void handleDeleteSession(deleteTarget.session_id);
|
||||
}
|
||||
}}
|
||||
disabled={!deleteTarget || !!deletingId}
|
||||
disabled={
|
||||
!deleteTarget ||
|
||||
!!deletingId ||
|
||||
streamingSessionIds.has(deleteTarget.session_id)
|
||||
}
|
||||
>
|
||||
{deletingId ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ import { UvSetupDialog } from "@/components/uv-setup";
|
|||
import { createLogger } from "@/lib/debug/logger";
|
||||
|
||||
const log = createLogger("sidebar");
|
||||
const FILES_AUTO_REFRESH_INTERVAL_MS = 12_000;
|
||||
const FILES_REFRESH_MIN_SPIN_MS = 400;
|
||||
|
||||
// ─── Table of Contents ───
|
||||
|
||||
|
|
@ -232,12 +234,73 @@ export function Sidebar() {
|
|||
const refreshFiles = useDocumentStore((s) => s.refreshFiles);
|
||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||
const folders = useDocumentStore((s) => s.folders);
|
||||
const [isRefreshingFiles, setIsRefreshingFiles] = useState(false);
|
||||
const refreshFilesInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const projectName = useMemo(() => {
|
||||
const normalized = projectRoot?.replace(/[\\/]+$/, "");
|
||||
return normalized?.split(/[/\\]/).pop() || "Desktop";
|
||||
}, [projectRoot]);
|
||||
|
||||
const runRefreshFiles = useCallback(
|
||||
async ({ showSpinner = true }: { showSpinner?: boolean } = {}) => {
|
||||
if (!projectRoot) return;
|
||||
if (refreshFilesInFlightRef.current) {
|
||||
await refreshFilesInFlightRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
if (showSpinner) setIsRefreshingFiles(true);
|
||||
|
||||
const refreshTask = (async () => {
|
||||
try {
|
||||
await refreshFiles();
|
||||
} catch (err) {
|
||||
log.error("Refresh files failed", { error: String(err) });
|
||||
} finally {
|
||||
if (showSpinner) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (elapsed < FILES_REFRESH_MIN_SPIN_MS) {
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, FILES_REFRESH_MIN_SPIN_MS - elapsed),
|
||||
);
|
||||
}
|
||||
setIsRefreshingFiles(false);
|
||||
}
|
||||
refreshFilesInFlightRef.current = null;
|
||||
}
|
||||
})();
|
||||
|
||||
refreshFilesInFlightRef.current = refreshTask;
|
||||
await refreshTask;
|
||||
},
|
||||
[projectRoot, refreshFiles],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectRoot) return;
|
||||
|
||||
const refreshIfVisible = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void runRefreshFiles({ showSpinner: false });
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(
|
||||
refreshIfVisible,
|
||||
FILES_AUTO_REFRESH_INTERVAL_MS,
|
||||
);
|
||||
window.addEventListener("focus", refreshIfVisible);
|
||||
document.addEventListener("visibilitychange", refreshIfVisible);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("focus", refreshIfVisible);
|
||||
document.removeEventListener("visibilitychange", refreshIfVisible);
|
||||
};
|
||||
}, [projectRoot, runRefreshFiles]);
|
||||
|
||||
// ─── Native OS file drop (Tauri onDragDropEvent) ───
|
||||
const sidebarFilesRef = useRef<HTMLDivElement>(null);
|
||||
const nativeDropTargetRef = useRef<string | null>(null);
|
||||
|
|
@ -701,9 +764,12 @@ export function Sidebar() {
|
|||
size="icon"
|
||||
className="size-5"
|
||||
title="Refresh"
|
||||
onClick={() => refreshFiles()}
|
||||
disabled={isRefreshingFiles}
|
||||
onClick={() => void runRefreshFiles()}
|
||||
>
|
||||
<RefreshCwIcon className="size-3" />
|
||||
<RefreshCwIcon
|
||||
className={cn("size-3", isRefreshingFiles && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ interface ClaudeErrorPayload {
|
|||
data: string;
|
||||
}
|
||||
|
||||
interface ClaudeSessionInfo {
|
||||
session_id: string;
|
||||
title: string;
|
||||
last_modified: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages Tauri event listeners for Claude CLI streaming output.
|
||||
*
|
||||
|
|
@ -386,10 +392,37 @@ export function useClaudeEvents() {
|
|||
lastErrorRef.current.delete(tabId);
|
||||
directProviderTabRef.current.delete(tabId);
|
||||
|
||||
const completedSessionId = tab.sessionId;
|
||||
chatStore._setStreaming(tabId, false);
|
||||
|
||||
// Snapshot after Claude edit
|
||||
const projectPath = useDocumentStore.getState().projectRoot;
|
||||
if (projectPath && completedSessionId) {
|
||||
void (async () => {
|
||||
try {
|
||||
const sessions = await invoke<ClaudeSessionInfo[]>(
|
||||
"list_claude_sessions",
|
||||
{
|
||||
projectPath,
|
||||
generateTitles: true,
|
||||
},
|
||||
);
|
||||
const session = sessions.find(
|
||||
(item) => item.session_id === completedSessionId,
|
||||
);
|
||||
if (session?.title) {
|
||||
useClaudeChatStore
|
||||
.getState()
|
||||
._setSessionTitle(completedSessionId, session.title);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("failed to refresh completed session title", {
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (projectPath) {
|
||||
try {
|
||||
await useHistoryStore
|
||||
|
|
|
|||
|
|
@ -493,7 +493,7 @@ interface ClaudeChatState {
|
|||
cancelExecution: () => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
newSession: () => void;
|
||||
resumeSession: (sessionId: string) => Promise<void>;
|
||||
resumeSession: (sessionId: string, title?: string) => Promise<void>;
|
||||
|
||||
// Tab actions
|
||||
createTab: () => string;
|
||||
|
|
@ -507,6 +507,7 @@ interface ClaudeChatState {
|
|||
// Internal actions (called by event hook, routed by tabId)
|
||||
_appendMessage: (tabId: string, msg: ClaudeStreamMessage) => void;
|
||||
_setSessionId: (tabId: string, id: string) => void;
|
||||
_setSessionTitle: (sessionId: string, title: string) => void;
|
||||
_setStreaming: (tabId: string, streaming: boolean) => void;
|
||||
_setError: (tabId: string, error: string | null) => void;
|
||||
_cancelledByUser: boolean;
|
||||
|
|
@ -801,7 +802,24 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
|
||||
newSession: () => {
|
||||
log.info("Starting new session");
|
||||
const { activeTabId } = get();
|
||||
const { activeTabId, tabs } = get();
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (activeTab?.isStreaming) {
|
||||
const id = nextTabId();
|
||||
const newTab = makeDefaultTab(id);
|
||||
set({
|
||||
tabs: [...tabs, newTab],
|
||||
activeTabId: id,
|
||||
messages: newTab.messages,
|
||||
sessionId: newTab.sessionId,
|
||||
isStreaming: newTab.isStreaming,
|
||||
error: newTab.error,
|
||||
totalInputTokens: newTab.totalInputTokens,
|
||||
totalOutputTokens: newTab.totalOutputTokens,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
set((s) =>
|
||||
applyTabUpdate(s, activeTabId, {
|
||||
messages: [],
|
||||
|
|
@ -816,9 +834,51 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
);
|
||||
},
|
||||
|
||||
resumeSession: async (sessionId: string) => {
|
||||
resumeSession: async (sessionId: string, title?: string) => {
|
||||
log.info(`Resuming session: ${sessionId.slice(0, 8)}`);
|
||||
const { activeTabId } = get();
|
||||
const sessionTitle = title?.trim() || undefined;
|
||||
const state = get();
|
||||
let { activeTabId } = state;
|
||||
let { tabs } = state;
|
||||
const existingTab = tabs.find((tab) => tab.sessionId === sessionId);
|
||||
|
||||
if (existingTab) {
|
||||
const nextTitle = sessionTitle ?? existingTab.title;
|
||||
activeTabId = existingTab.id;
|
||||
const nextTabs = tabs.map((tab) =>
|
||||
tab.id === existingTab.id ? { ...tab, title: nextTitle } : tab,
|
||||
);
|
||||
set({
|
||||
tabs: nextTabs,
|
||||
activeTabId: existingTab.id,
|
||||
messages: existingTab.messages,
|
||||
sessionId: existingTab.sessionId,
|
||||
isStreaming: existingTab.isStreaming,
|
||||
error: existingTab.error,
|
||||
totalInputTokens: existingTab.totalInputTokens,
|
||||
totalOutputTokens: existingTab.totalOutputTokens,
|
||||
});
|
||||
if (existingTab.isStreaming) return;
|
||||
} else {
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId);
|
||||
if (activeTab?.isStreaming) {
|
||||
const id = nextTabId();
|
||||
const newTab = makeDefaultTab(id);
|
||||
tabs = [...tabs, newTab];
|
||||
activeTabId = id;
|
||||
set({
|
||||
tabs,
|
||||
activeTabId,
|
||||
messages: newTab.messages,
|
||||
sessionId: newTab.sessionId,
|
||||
isStreaming: newTab.isStreaming,
|
||||
error: newTab.error,
|
||||
totalInputTokens: newTab.totalInputTokens,
|
||||
totalOutputTokens: newTab.totalOutputTokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const projectPath = useDocumentStore.getState().projectRoot;
|
||||
|
||||
// Reset state with new session ID
|
||||
|
|
@ -831,6 +891,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
isStreaming: false,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
title: sessionTitle ?? "New Chat",
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -862,7 +923,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
...applyTabUpdate(s, activeTabId, {
|
||||
messages,
|
||||
providerKey,
|
||||
title: titleForMessages(messages) ?? "New Chat",
|
||||
title: sessionTitle ?? titleForMessages(messages) ?? "New Chat",
|
||||
totalInputTokens: totals.inputTokens,
|
||||
totalOutputTokens: totals.outputTokens,
|
||||
}),
|
||||
|
|
@ -1008,6 +1069,16 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
set((state) => applyTabUpdate(state, tabId, { sessionId: id }));
|
||||
},
|
||||
|
||||
_setSessionTitle: (sessionId: string, title: string) => {
|
||||
const cleanTitle = title.trim();
|
||||
if (!cleanTitle) return;
|
||||
set((state) => ({
|
||||
tabs: state.tabs.map((tab) =>
|
||||
tab.sessionId === sessionId ? { ...tab, title: cleanTitle } : tab,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
_setStreaming: (tabId: string, streaming: boolean) => {
|
||||
set((state) => applyTabUpdate(state, tabId, { isStreaming: streaming }));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ interface ClaudeSetupState {
|
|||
) => Promise<boolean>;
|
||||
clearApiKey: () => Promise<boolean>;
|
||||
listApiCredentials: () => Promise<void>;
|
||||
deleteApiCredential: (credentialId: string) => Promise<boolean>;
|
||||
setActiveApiCredential: (credentialId: string) => Promise<boolean>;
|
||||
fetchProviderModels: (apiKey: string, baseUrl: string) => Promise<string[]>;
|
||||
toggleInstallLogs: () => void;
|
||||
|
|
@ -392,6 +393,19 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
|||
}));
|
||||
},
|
||||
|
||||
deleteApiCredential: async (credentialId: string) => {
|
||||
try {
|
||||
await invoke("delete_openai_compatible_credential", {
|
||||
credentialId,
|
||||
});
|
||||
await get().checkStatus();
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
set({ error: err?.message || String(err) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setActiveApiCredential: async (credentialId: string) => {
|
||||
try {
|
||||
await invoke("set_active_openai_compatible_credential", {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue