fix(agent): critical agent chat bugs — backend startup & frontend streaming state

Backend (tools.py):
- Add Python docstrings to all 17 @tool functions (LangChain ValueError)
- Add @INVARIANT ADR: docstring requirement documented in module header
- Fix 2 f-string escaped-quote syntax errors (Python 3.13)

Frontend — compile errors (+page.svelte):
- Fix mismatched <button>/</Button> tags
- Fix missing Button import for mobile sidebar close

Frontend — streaming state loss on conversation switch (AgentChatModel):
- Add _commitStreamingPartial() helper — saves in-progress text before cancelling
- selectConversation() commits partial text to OLD conversation before switching
- createConversation() commits partial text before clearing state
- loadHistory() sets _userCancelled=true to suppress fallback messages

Frontend — ConnectionManager:
- Pass error reason through onDisconnectedPermanent callback → model.error

Frontend — null safety:
- Guard _client.submit() calls against null _client in _sendNow and resumeConfirm
This commit is contained in:
2026-06-30 13:25:28 +03:00
parent 7090ec979f
commit e8d6d7d0db
5 changed files with 626 additions and 80 deletions

View File

@@ -25,7 +25,7 @@ function getGradioBaseUrl(): string {
export interface ConnectionManagerCallbacks {
onConnected: (client: GradioClient) => void;
onDisconnected: () => void;
onDisconnectedPermanent: () => void;
onDisconnectedPermanent: (error?: string) => void;
onStreamingStateChange: (s: StreamingState) => void;
}
@@ -48,7 +48,7 @@ export class ConnectionManager {
this.cb.onStreamingStateChange("idle");
log("AgentChat.ConnectionManager", "REFLECT", "Reconnected successfully");
} catch (e: unknown) {
this.cb.onDisconnectedPermanent();
this.cb.onDisconnectedPermanent(e instanceof Error ? e.message : "Manual reconnect failed");
log("AgentChat.ConnectionManager", "EXPLORE", "Reconnect failed", {}, e instanceof Error ? e.message : "Unknown");
}
}
@@ -88,7 +88,7 @@ export class ConnectionManager {
this._reconnectTimer = setInterval(async () => {
this._reconnectAttempts++;
if (this._reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
this.cb.onDisconnectedPermanent();
this.cb.onDisconnectedPermanent("Max reconnect attempts reached");
this._clearTimer();
log("AgentChat.ConnectionManager", "EXPLORE", "Max reconnect attempts reached", { attempts: this._reconnectAttempts }, "Max 5 reconnect attempts exhausted");
return;
@@ -96,8 +96,8 @@ export class ConnectionManager {
try {
const client = await Client.connect(getGradioBaseUrl());
this.onReconnect(client);
} catch {
log("AgentChat.ConnectionManager", "EXPLORE", "Reconnect attempt failed", { attempt: this._reconnectAttempts }, "Client.connect threw");
} catch (e: unknown) {
log("AgentChat.ConnectionManager", "EXPLORE", "Reconnect attempt failed", { attempt: this._reconnectAttempts }, e instanceof Error ? e.message : "Client.connect threw");
}
}, RECONNECT_INTERVAL_MS);
}

View File

@@ -23,6 +23,11 @@ export interface StreamProcessorHost {
error: string | null;
pendingThreadId: string | null;
currentConversationId: string | null;
confirmationMessage: string | null;
pendingToolName: string | null;
pendingToolArgs: Record<string, unknown>;
pendingConfirmationRisk: "read" | "write" | "unknown" | null;
pendingRiskLevel: string | null;
captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void;
}
@@ -177,7 +182,15 @@ export class StreamProcessor {
case "confirm_required":
this.host.streamingState = "awaiting_confirmation";
this.host.pendingThreadId = meta.thread_id ?? null;
this.host.pendingThreadId = meta.thread_id ?? this.host.currentConversationId ?? null;
this.host.confirmationMessage = meta.prompt ?? meta.detail ?? null;
this.host.pendingToolName = meta.tool_name ?? meta.tool ?? null;
this.host.pendingToolArgs = meta.tool_args ?? {};
this.host.pendingConfirmationRisk =
meta.risk === "read" || meta.risk === "write" || meta.risk === "unknown"
? meta.risk
: null;
this.host.pendingRiskLevel = meta.risk_level ?? null;
break;
case "confirm_resolved":
@@ -185,6 +198,8 @@ export class StreamProcessor {
if (msg.text) this.host.partialText += msg.text + "\n\n";
this.host.streamingState = meta.result === "confirmed" ? "streaming" : "idle";
this.host.pendingThreadId = null;
this.host.pendingConfirmationRisk = null;
this.host.pendingRiskLevel = null;
break;
case "error":

View File

@@ -6,6 +6,7 @@
// @INVARIANT HITL resume: confirmation via second submit() with additional_inputs=[conversationId, "confirm"|"deny"]. Primary stream TERMINATES on confirm_required, second stream resumes from checkpoint.
// @INVARIANT Optimistic cancel: submission.cancel() stops generation, partial text preserved.
// @INVARIANT Conversation list loaded from FastAPI REST — Gradio serves only live agent streaming.
// @INVARIANT Screen chrome state lives in the model: debug panel and conversation sidebar are view-rendered only.
// @INVARIANT DECOMPOSITION GATE (630→~340 lines): Stream processing → AgentChat.StreamProcessor; Connection lifecycle → AgentChat.ConnectionManager; Persistence → AgentChat.LocalStorage; Types → AgentChatTypes.
// @STATE idle | streaming | awaiting_confirmation | error | disconnected | disconnected_permanent
// @ACTION sendMessage(text, files?) — submit("/chat", {text, files}, [conversationId, null]), iterate stream events, process metadata.
@@ -52,11 +53,25 @@ export interface AgentChatModelOptions {
envId?: string;
}
export type AgentPhase = "ready" | "thinking" | "tooling" | "confirming" | "failed" | "offline";
export type AgentPhaseTone = "success" | "warning" | "destructive" | "muted";
export type ConfirmationRisk = "read" | "write" | "unknown";
export interface AgentQuickAction {
id: "dashboards" | "migration" | "tasks" | "health";
icon: string;
labelKey: string;
descriptionKey: string;
prompt: string;
}
export class AgentChatModel {
// ── Atoms ──────────────────────────────────────────────────────
messages: AgentMessage[] = $state([]);
conversations: Conversation[] = $state([]);
currentConversationId: string | null = $state(null);
isDebugPanelOpen: boolean = $state(false);
isConversationSidebarOpen: boolean = $state(false);
streamingState: StreamingState = $state("idle");
connectionState: ConnectionState = $state("connected");
error: string | null = $state(null);
@@ -66,6 +81,18 @@ export class AgentChatModel {
isLoadingHistory: boolean = $state(false);
inputText: string = $state("");
pendingThreadId: string | null = $state(null);
/** Human-readable confirmation prompt (replaces misuse of `error` for this purpose). */
confirmationMessage: string | null = $state(null);
/** Tool name being requested for HITL confirmation. */
pendingToolName: string | null = $state(null);
/** Tool arguments for the pending HITL confirmation call. */
pendingToolArgs: Record<string, unknown> = $state({});
/** Backend-provided coarse confirmation risk; heuristic is fallback only. */
pendingConfirmationRisk: ConfirmationRisk | null = $state(null);
/** Backend-provided detailed risk level: safe, guarded, dangerous, unknown. */
pendingRiskLevel: string | null = $state(null);
/** Set to true when user clicks Stop — prevents false 'agent unavailable' fallback. */
_userCancelled: boolean = $state(false);
userId: string = "";
userJwt: string = "";
envId: string = "";
@@ -79,6 +106,36 @@ export class AgentChatModel {
private _historyHasNext: boolean = $state(false);
private _messageQueue: Array<{ text: string; files?: File[] }> = $state([]);
private _processingQueue: boolean = false;
readonly quickActions: AgentQuickAction[] = [
{
id: "dashboards",
icon: "dashboard",
labelKey: "quick_dashboards",
descriptionKey: "quick_dashboards_desc",
prompt: "Покажи доступные дашборды",
},
{
id: "migration",
icon: "layers",
labelKey: "quick_migration",
descriptionKey: "quick_migration_desc",
prompt: "Запусти миграцию",
},
{
id: "tasks",
icon: "activity",
labelKey: "quick_tasks",
descriptionKey: "quick_tasks_desc",
prompt: "Проверь статус текущих задач",
},
{
id: "health",
icon: "warning",
labelKey: "quick_health",
descriptionKey: "quick_health_desc",
prompt: "Проверь статус системы",
},
];
// ── Sub-helpers ────────────────────────────────────────────────
readonly connection: ConnectionManager;
@@ -102,6 +159,89 @@ export class AgentChatModel {
);
queuePosition = $derived(this._messageQueue.length);
conversationsHasNext = $derived(this._conversationsHasNext);
conversationIdLabel = $derived(this.currentConversationId || "new");
pendingThreadIdLabel = $derived(this.pendingThreadId || "none");
userIdLabel = $derived(this.userId || "anonymous");
envIdLabel = $derived(this.envId || "—");
lastMessageIdLabel = $derived(this.messages.at(-1)?.id || "none");
messageCountLabel = $derived(String(this.messages.length));
conversationListItems = $derived(
this.conversations.map((conversation) => ({
...conversation,
title: this.normalizeConversationTitle(conversation.title),
})),
);
currentConversationTitle = $derived(
this.currentConversationId
? (this.conversationListItems.find((c) => c.id === this.currentConversationId)?.title || "Агент-чат")
: "Агент-чат",
);
agentPhase = $derived.by((): AgentPhase => {
if (this.connectionState !== "connected") return "offline";
if (this.streamingState === "awaiting_confirmation") return "confirming";
if (this.streamingState === "error") return "failed";
if (this.activeToolCalls.some((tool) => tool.status === "executing")) return "tooling";
if (this.streamingState === "streaming") return "thinking";
return "ready";
});
agentPhaseTone = $derived.by((): AgentPhaseTone => {
if (this.agentPhase === "offline" || this.agentPhase === "failed") return "destructive";
if (this.agentPhase === "confirming" || this.agentPhase === "tooling") return "warning";
if (this.agentPhase === "ready") return "success";
return "muted";
});
confirmationRisk = $derived.by((): ConfirmationRisk => {
if (this.pendingConfirmationRisk) return this.pendingConfirmationRisk;
if (this.pendingRiskLevel === "safe") return "read";
if (this.pendingRiskLevel === "guarded" || this.pendingRiskLevel === "dangerous") return "write";
const tool = (this.pendingToolName || "").toLowerCase();
if (!tool) return "unknown";
if (
tool.startsWith("list_") ||
tool.startsWith("get_") ||
tool.startsWith("show_") ||
tool.startsWith("search_") ||
tool.startsWith("check_") ||
tool.includes("status") ||
tool.includes("summary")
) {
return "read";
}
return "write";
});
confirmationTone = $derived.by((): AgentPhaseTone => {
if (this.confirmationRisk === "write") return "warning";
if (this.confirmationRisk === "read") return "success";
return "muted";
});
confirmationTitleKey = $derived.by(() => {
if (this.confirmationRisk === "read") return "confirmation_read_title";
if (this.confirmationRisk === "write") return "confirmation_write_title";
return "confirmation_card_title";
});
confirmationDescriptionKey = $derived.by(() => {
if (!this.pendingThreadId) return "confirmation_missing_checkpoint";
if (this.confirmationRisk === "read") return "confirmation_read_description";
if (this.confirmationRisk === "write") return "confirmation_scope_fallback";
return "confirmation_unknown_description";
});
confirmationMessageOverride = $derived.by(() => {
const message = (this.confirmationMessage || "").trim();
if (!message) return "";
const genericMessages = new Set([
"Подтвердить операцию?",
"Подтвердите действие",
"Confirm operation?",
"Confirm action",
]);
return genericMessages.has(message) ? "" : message;
});
pendingToolLabel = $derived.by(() => {
if (!this.pendingToolName) return "";
return this.pendingToolName
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
});
constructor(options?: AgentChatModelOptions) {
if (options?.userId) this.userId = options.userId;
@@ -115,9 +255,10 @@ export class AgentChatModel {
this.streamingState = "disconnected";
}
},
onDisconnectedPermanent: () => {
onDisconnectedPermanent: (error?: string) => {
this.connectionState = "disconnected_permanent";
this.streamingState = "disconnected_permanent";
if (error) this.error = error;
},
onStreamingStateChange: (s) => { this.streamingState = s; },
};
@@ -131,6 +272,43 @@ export class AgentChatModel {
/** External callback to sync reactive values (userId, userJwt, envId) before each send */
onBeforeSend?: () => void;
toggleDebugPanel(): void {
this.isDebugPanelOpen = !this.isDebugPanelOpen;
}
setDebugPanelOpen(open: boolean): void {
this.isDebugPanelOpen = open;
}
toggleConversationSidebar(): void {
this.isConversationSidebarOpen = !this.isConversationSidebarOpen;
}
setConversationSidebarOpen(open: boolean): void {
this.isConversationSidebarOpen = open;
}
async selectConversation(id: string, closeSidebar: boolean = false): Promise<void> {
// Commit partial streaming text to the CURRENT conversation BEFORE switching.
// Without this, switching dialogs during active streaming silently loses
// the in-progress response.
if (this.streamingState === "streaming" && this.currentConversationId && id !== this.currentConversationId) {
this._commitStreamingPartial();
}
this.currentConversationId = id;
await this.loadHistory(id);
if (closeSidebar) this.isConversationSidebarOpen = false;
}
normalizeConversationTitle(rawTitle: unknown): string {
const title = String(rawTitle ?? "")
.replace(/\s*\[PRE-FETCHED DATA[\s\S]*$/i, "")
.replace(/\s+Available(?:\s+\S+)*$/i, "")
.replace(/\s+/g, " ")
.trim();
return title || "Новый диалог";
}
async sendMessage(text: string, files?: File[]): Promise<void> {
// Sync reactive values from the page component
this.onBeforeSend?.();
@@ -154,14 +332,27 @@ export class AgentChatModel {
}
const convId = this.currentConversationId;
this.streamingState = "streaming";
this._userCancelled = false;
this.error = null;
this.partialText = "";
this.partialTokens = [];
this.activeToolCalls = [];
this.confirmationMessage = null;
this.pendingToolName = null;
this.pendingToolArgs = {};
this.pendingConfirmationRisk = null;
this.pendingRiskLevel = null;
log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) });
try {
this._submission = this._client!.submit("chat",
// Guard: client may be null if connection reset during send attempt.
if (!this._client) {
this.streamingState = "error";
this.error = "Agent client not available — please reconnect.";
log("AgentChat.Model", "EXPLORE", "Submit rejected: _client is null", {}, this.error);
return;
}
this._submission = this._client.submit("chat",
[{ text, files }, null, convId, null, this.userId, this.userJwt, this.envId],
);
@@ -217,27 +408,131 @@ export class AgentChatModel {
cancelGeneration(): void {
if (this.streamingState === "idle") return;
this._submission?.cancel();
this._userCancelled = true;
this.streamingState = "idle";
this._submission = null;
this.confirmationMessage = null;
this.pendingToolName = null;
this.pendingToolArgs = {};
this.pendingConfirmationRisk = null;
this.pendingRiskLevel = null;
log("AgentChat.Model", "REASON", "Generation cancelled");
}
private _clearPendingConfirmation(): void {
this.streamingState = "idle";
this._submission = null;
this.pendingThreadId = null;
this.confirmationMessage = null;
this.pendingToolName = null;
this.pendingToolArgs = {};
this.pendingConfirmationRisk = null;
this.pendingRiskLevel = null;
}
/** Commit in-progress streaming text + tool calls as an assistant message.
* Called before switching conversations or creating a new one while streaming. */
private _commitStreamingPartial(): void {
const partialText = this.partialText;
const toolCalls = [...this.activeToolCalls];
if (!partialText && toolCalls.length === 0) return;
const text = partialText
|| toolCalls.map((tc) => `🛠️ ${tc.tool}${tc.output ? " ✅" : ""}`).join("\n")
|| "[Генерация прервана]";
this.messages = [
...this.messages,
{
id: `msg-${Date.now()}`,
conversation_id: this.currentConversationId || "",
role: "assistant",
text,
toolCalls,
created_at: new Date().toISOString(),
},
];
this._saveToStorage();
log("AgentChat.Model", "REFLECT", "Committed streaming partial on context switch", {
textLen: partialText.length,
toolCalls: toolCalls.length,
conversationId: this.currentConversationId,
});
}
private _appendLocalDeniedMessage(): void {
this.messages = [
...this.messages,
{
id: `deny-${Date.now()}`,
conversation_id: this.currentConversationId || "",
role: "assistant",
text: "⏹️ Операция отменена",
toolCalls: [],
created_at: new Date().toISOString(),
},
];
}
async resumeConfirm(action: "confirm" | "deny"): Promise<void> {
if (this.streamingState !== "awaiting_confirmation") return;
if (!this.pendingThreadId || !this.currentConversationId) return;
if (!this.pendingThreadId || !this.currentConversationId) {
if (action === "deny") {
this._appendLocalDeniedMessage();
this._clearPendingConfirmation();
this._persistMessages();
log("AgentChat.Model", "REASON", "HITL deny handled locally without checkpoint", {
hasThreadId: Boolean(this.pendingThreadId),
hasConversationId: Boolean(this.currentConversationId),
});
return;
}
this.error = "Не удалось подтвердить действие: отсутствует checkpoint. Повторите запрос.";
this._clearPendingConfirmation();
log("AgentChat.Model", "EXPLORE", "HITL confirm blocked: missing checkpoint", {
hasThreadId: Boolean(this.pendingThreadId),
hasConversationId: Boolean(this.currentConversationId),
}, this.error);
return;
}
log("AgentChat.Model", "REASON", `HITL resume: ${action}`, { threadId: this.pendingThreadId });
try {
this._userCancelled = false;
this.streamingState = "streaming";
const submission = this._client!.submit("chat",
[{ text: action === "confirm" ? "confirm" : "deny", files: null }, null, this.currentConversationId, action, this.userId, this.userJwt, this.envId],
if (!this._client) {
this.streamingState = "error";
this.error = "Agent client not available — please reconnect.";
log("AgentChat.Model", "EXPLORE", "HITL resume rejected: _client is null", {}, this.error);
return;
}
this._submission = this._client.submit("chat",
[{ text: action === "confirm" ? "confirm" : "deny", files: [] }, null, this.currentConversationId, action, this.userId, this.userJwt, this.envId],
);
await this.streamProcessor.processStream(submission, this.currentConversationId);
this.streamingState = "idle";
// Safety timeout: same pattern as _sendNow — prevents hanging
// if Gradio stream closes without a clean completion event.
const streamDone = await Promise.race([
this.streamProcessor.processStream(this._submission, this.currentConversationId),
this.streamProcessor.streamCloseWatcher(this._client!, 180_000),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 180_000)),
]);
if (streamDone === false) {
try { this._submission?.return?.(); } catch { /* ignore */ }
}
if (this.streamingState !== "awaiting_confirmation") {
this.streamingState = "idle";
}
this._submission = null;
this.pendingThreadId = null;
this.confirmationMessage = null;
this.pendingToolName = null;
this.pendingToolArgs = {};
this.pendingConfirmationRisk = null;
this.pendingRiskLevel = null;
this._persistMessages();
this.loadConversations(true);
} catch (e: unknown) {
this.streamingState = "error";
this.error = e instanceof Error ? e.message : "Resume failed";
@@ -259,12 +554,20 @@ export class AgentChatModel {
title: item.title ?? "",
updated_at: item.updated_at ?? "",
message_count: item.message_count ?? 0,
last_role: item.last_role ?? null,
has_tool_calls: item.has_tool_calls ?? false,
has_error: item.has_error ?? false,
risk_level: item.risk_level ?? null,
}))
: [...this.conversations, ...items.map((item: Record<string, unknown>) => ({
id: item.conversation_id ?? item.id ?? "",
title: item.title ?? "",
updated_at: item.updated_at ?? "",
message_count: item.message_count ?? 0,
last_role: item.last_role ?? null,
has_tool_calls: item.has_tool_calls ?? false,
has_error: item.has_error ?? false,
risk_level: item.risk_level ?? null,
}))];
this._conversationsHasNext = Boolean(res.has_next);
this._conversationsPage++;
@@ -284,6 +587,10 @@ export class AgentChatModel {
title: item.title ?? "",
updated_at: item.updated_at ?? "",
message_count: item.message_count ?? 0,
last_role: item.last_role ?? null,
has_tool_calls: item.has_tool_calls ?? false,
has_error: item.has_error ?? false,
risk_level: item.risk_level ?? null,
}));
this._conversationsHasNext = Boolean(res.has_next);
this._conversationsPage++;
@@ -296,16 +603,22 @@ export class AgentChatModel {
async loadHistory(conversationId: string | null = null): Promise<void> {
this.isLoadingHistory = true;
this.error = null;
// Reset stream state when switching conversations — prevents state leak
// from a failed/cancelled stream in the previous conversation.
// Cancel any in-flight submission first to prevent stale continuation
// from writing to the new conversation's localStorage.
// _userCancelled suppresses fallback "Agent unavailable" messages.
try { this._submission?.cancel(); } catch { /* ignore */ }
this._userCancelled = true;
this._submission = null;
this.streamingState = "idle";
this.pendingThreadId = null;
this.activeToolCalls = [];
this.partialText = "";
this.confirmationMessage = null;
this.pendingToolName = null;
this.pendingToolArgs = {};
this.pendingConfirmationRisk = null;
this.pendingRiskLevel = null;
log("AgentChat.Model", "REASON", "Loading history", { conversationId });
try {
const targetId = conversationId ?? this.currentConversationId;
@@ -340,6 +653,13 @@ export class AgentChatModel {
}
createConversation(): void {
// Commit streaming partial text before starting a new conversation
if (this.streamingState === "streaming") {
this._commitStreamingPartial();
this._userCancelled = true;
try { this._submission?.cancel(); } catch { /* ignore */ }
this._submission = null;
}
if (this.currentConversationId && this.messages.length > 0) {
this._saveToStorage();
}
@@ -350,6 +670,12 @@ export class AgentChatModel {
this.partialText = "";
this.activeToolCalls = [];
this.pendingThreadId = null;
this.confirmationMessage = null;
this.pendingToolName = null;
this.pendingToolArgs = {};
this.pendingConfirmationRisk = null;
this.pendingRiskLevel = null;
this._userCancelled = false;
this._historyPage = 1;
this._historyHasNext = false;
log("AgentChat.Model", "REASON", "New conversation created");

View File

@@ -13,12 +13,11 @@
import { sidebarStore } from "$lib/stores/sidebar.svelte.js";
import { auth } from "$lib/auth/store.svelte.js";
import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js";
import { Icon, Button } from "$lib/ui";
let model = $state<AgentChatModel | null>(null);
let sidebarOpen = $state(true);
let sidebarExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
import { Icon } from "$lib/ui";
// Get current user identity + JWT for Gradio conversation persistence and tool auth
// $auth is Svelte auto-subscription syntax for stores with .subscribe()
@@ -37,6 +36,10 @@
m.envId = $environmentContextStore.selectedEnvId ?? "";
}
$effect(() => {
if (model) syncModel(model);
});
onMount(() => {
if (_initialized) return;
_initialized = true;
@@ -44,6 +47,13 @@
const m = new AgentChatModel();
m.onBeforeSend = () => syncModel(m);
syncModel(m);
const desktopQuery = window.matchMedia("(min-width: 768px)");
const syncSidebarForViewport = (matches: boolean) => {
m.setConversationSidebarOpen(matches);
};
syncSidebarForViewport(desktopQuery.matches);
const handleViewportChange = (event: MediaQueryListEvent) => syncSidebarForViewport(event.matches);
desktopQuery.addEventListener("change", handleViewportChange);
model = m;
m.connectionState = "disconnected";
@@ -58,29 +68,28 @@
m.connectionState = "disconnected";
});
return () => { _initialized = false; };
return () => {
desktopQuery.removeEventListener("change", handleViewportChange);
_initialized = false;
};
});
</script>
{#if model}
<!-- Fixed full-height chat below TopNavbar, avoids breadcrumbs/PROD-context/footer flow -->
<div
class="fixed top-16 right-0 bottom-0 z-10 flex"
class:left-60={sidebarExpanded}
class:left-16={!sidebarExpanded}
class={`fixed top-16 right-0 bottom-0 left-0 z-10 flex ${sidebarExpanded ? "md:left-60" : "md:left-16"}`}
>
<!-- Sidebar group: conversation list + toggle button (desktop) -->
<div class="relative hidden md:flex shrink-0">
{#if sidebarOpen}
{#if model.isConversationSidebarOpen}
<ConversationList
conversations={model.conversations}
searchFieldId="agent-conversation-search-desktop"
conversations={model.conversationListItems}
currentConversationId={model.currentConversationId}
isLoading={model.isLoadingHistory}
hasNext={model.conversationsHasNext}
onselect={(id) => {
model!.currentConversationId = id;
model!.loadHistory(id);
}}
onselect={(id) => model!.selectConversation(id)}
ondelete={(id) => model!.deleteConversation(id)}
onloadmore={() => model!.loadConversations(false)}
onsearch={(q: string) => model!.searchConversations(q)}
@@ -88,14 +97,16 @@
{/if}
<!-- Toggle button — at right edge of sidebar group -->
<!-- @RATIONALE Raw <button> (not $lib/ui/Button): ultra-custom chrome with w-6 h-12, conditional border-l-0/rounded-l-lg.
Button's variant/size system conflicts — no variant matches the border/shadow/size combination needed for this UI control. -->
<button
class="absolute -right-3 top-4 z-10 flex items-center justify-center w-6 h-12 rounded-r-lg bg-surface-card border border-border text-text-muted hover:text-text shadow-sm transition"
class:border-l-0={sidebarOpen}
class:rounded-l-lg={!sidebarOpen}
onclick={() => sidebarOpen = !sidebarOpen}
aria-label={sidebarOpen ? "Закрыть список диалогов" : "Открыть список диалогов"}
class:border-l-0={model.isConversationSidebarOpen}
class:rounded-l-lg={!model.isConversationSidebarOpen}
onclick={() => model!.toggleConversationSidebar()}
aria-label={model.isConversationSidebarOpen ? "Закрыть список диалогов" : "Открыть список диалогов"}
>
{#if sidebarOpen}
{#if model.isConversationSidebarOpen}
<Icon name="chevronLeft" size={14} />
{:else}
<Icon name="chevronRight" size={14} />
@@ -104,34 +115,38 @@
</div>
<!-- Mobile sidebar — overlay -->
{#if sidebarOpen}
{#if model.isConversationSidebarOpen}
<div class="md:hidden fixed inset-0 z-50 flex">
<div class="w-64 bg-surface-card border-r border-border shadow-xl">
<div class="flex items-center justify-between p-3 border-b border-border">
<span class="text-sm font-semibold text-text">Диалоги</span>
<button
<Button
variant="ghost"
size="icon"
class="rounded-lg p-1 text-text-muted hover:bg-surface-muted hover:text-text"
onclick={() => sidebarOpen = false}
onclick={() => model!.setConversationSidebarOpen(false)}
>
<Icon name="close" size={20} />
</button>
</Button>
</div>
<ConversationList
conversations={model.conversations}
searchFieldId="agent-conversation-search-mobile"
conversations={model.conversationListItems}
currentConversationId={model.currentConversationId}
isLoading={model.isLoadingHistory}
hasNext={model.conversationsHasNext}
onselect={(id) => {
model!.currentConversationId = id;
model!.loadHistory(id);
sidebarOpen = false;
}}
onselect={(id) => model!.selectConversation(id, true)}
ondelete={(id) => model!.deleteConversation(id)}
onloadmore={() => model!.loadConversations(false)}
onsearch={(q: string) => model!.searchConversations(q)}
/>
</div>
<div class="flex-1 bg-black/30" onclick={() => sidebarOpen = false}></div>
<button
type="button"
class="flex-1 bg-black/30"
aria-label="Закрыть список диалогов"
onclick={() => model!.setConversationSidebarOpen(false)}
></button>
</div>
{/if}