Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to Zync are documented in this file. The format is based on

## [Unreleased]

## [2.22.2] - 2026-07-16

### Fixed
- **Vault list privacy**: Credential labels, assignment/detail/history/restore modals, and related confirms/toasts respect **Settings → General → Show host addresses in lists**, so host addresses stay hidden in vault UI when that option is off. New secured-key labels avoid embedding `user@ip`. ([5dd0c07])
- **AI provider selection**: Partial `settings.ai` (e.g. Mistral without `enabled`) no longer fails parse and silently falls back to Ollama. Serde defaults, soft recovery on read, and full AI object persistence when changing provider/model. ([5dd0c07])
- **Agent mode routing**: Short greetings no longer force Ask mode while Agent is selected. ([5dd0c07])
- **Missing API key UX**: Cloud providers fail fast in the AI sidebar with a clear in-chat message and **Open Settings → AI** deep link (all BYOK providers, not only Mistral). ([5dd0c07])

### Changed
- Shared privacy label helpers and setup-error copy; AI settings rollback is key-scoped so concurrent AI updates are preserved. ([5dd0c07])

## [2.22.1] - 2026-07-14

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zync",
"private": true,
"version": "2.22.1",
"version": "2.22.2",
"type": "module",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "zync"
version = "2.22.1"
version = "2.22.2"
description = "A modern SSH client"
authors = ["Gajendra"]
edition = "2021"
Expand Down
40 changes: 29 additions & 11 deletions src-tauri/src/ai/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,7 @@ fn merge_secret_keys(app: &AppHandle, mut config: AiConfig) -> AiConfig {
}

fn default_ai_config() -> AiConfig {
AiConfig {
provider: "ollama".to_string(),
keys: None,
model: None,
ollama_url: Some("http://localhost:11434".to_string()),
enabled: true,
}
AiConfig::default()
}

pub fn read_ai_config(app: &AppHandle) -> AiConfig {
Expand All @@ -44,10 +38,34 @@ pub fn read_ai_config(app: &AppHandle) -> AiConfig {
if let Some(ai) = settings.get("ai") {
match serde_json::from_value::<AiConfig>(ai.clone()) {
Ok(config) => return merge_secret_keys(app, config),
#[cfg(debug_assertions)]
Err(e) => eprintln!("[zync/ai] Failed to parse AI config from effective settings: {e}"),
#[cfg(not(debug_assertions))]
Err(_) => {}
Err(e) => {
// Soft-parse: fill missing fields from defaults instead of
// throwing away a valid provider selection (e.g. mistral without `enabled`).
#[cfg(debug_assertions)]
eprintln!(
"[zync/ai] Partial AI config parse failed ({e}); merging with defaults"
);
let defaults = serde_json::to_value(AiConfig::default())
.unwrap_or_else(|_| serde_json::json!({}));
// Only apply non-null overlay fields so explicit JSON null
// cannot wipe valid defaults (e.g. "enabled": null).
let merged = match (defaults, ai.clone()) {
(serde_json::Value::Object(mut base), serde_json::Value::Object(overlay)) => {
for (k, v) in overlay {
if !v.is_null() {
base.insert(k, v);
}
}
serde_json::Value::Object(base)
}
(_, overlay) => overlay,
};
if let Ok(config) = serde_json::from_value::<AiConfig>(merged) {
return merge_secret_keys(app, config);
}
#[cfg(debug_assertions)]
eprintln!("[zync/ai] Failed to recover AI config after merge with defaults");
}
}
} else {
#[cfg(debug_assertions)]
Expand Down
33 changes: 32 additions & 1 deletion src-tauri/src/ai/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,47 @@ use serde::{Deserialize, Serialize};

use crate::ai::AiTranslateResponse;

#[derive(Debug, Serialize, Deserialize, Clone, Default)]
fn default_ai_provider() -> String {
"ollama".to_string()
}

fn default_ai_enabled() -> bool {
true
}

fn default_ollama_url() -> Option<String> {
Some("http://localhost:11434".to_string())
}

/// Partial `settings.ai` objects (e.g. only `{ provider, model }`) must still deserialize.
/// Missing `enabled` previously failed parse and silently fell back to Ollama defaults.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AiConfig {
#[serde(default = "default_ai_provider")]
pub provider: String,
#[serde(default)]
pub keys: Option<HashMap<String, String>>,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_ollama_url")]
pub ollama_url: Option<String>,
#[serde(default = "default_ai_enabled")]
pub enabled: bool,
}

impl Default for AiConfig {
fn default() -> Self {
Self {
provider: default_ai_provider(),
keys: None,
model: None,
ollama_url: default_ollama_url(),
enabled: default_ai_enabled(),
}
}
}

impl AiConfig {
pub(crate) fn api_key(&self) -> Option<&str> {
self.keys
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "zync",
"version": "2.22.1",
"version": "2.22.2",
"identifier": "zync",
"build": {
"beforeDevCommand": "npm run dev",
Expand Down
11 changes: 6 additions & 5 deletions src/components/ai/AiChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { memo, useCallback } from 'react';
import { Copy, Play, ShieldCheck, ShieldAlert, AlertTriangle, Terminal, User, AlertCircle } from 'lucide-react';
import { Copy, Play, ShieldCheck, ShieldAlert, AlertTriangle, Terminal, User } from 'lucide-react';
import { AgentIcon } from './AgentIcon';
import { cn } from '../../lib/utils';
import type { AiDisplayEntry } from '../../ai/types/common';
import { AiSetupErrorCard } from './AiSetupErrorCard';
import { useAppStore } from '../../store/useAppStore';
import type { ProviderValue } from './providerCatalog';

interface AiChatMessageProps {
entry: AiDisplayEntry;
Expand Down Expand Up @@ -76,6 +79,7 @@ function FormattedText({ text }: { text: string }) {

export const AiChatMessage = memo(function AiChatMessage({ entry, onRunCommand }: AiChatMessageProps) {
const { query, result, error, contextSnapshot, timestamp } = entry;
const aiProvider = useAppStore((s) => s.settings.ai?.provider) as ProviderValue | undefined;
const time = new Date(timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });

return (
Expand Down Expand Up @@ -108,10 +112,7 @@ export const AiChatMessage = memo(function AiChatMessage({ entry, onRunCommand }
</div>
<div className="flex-1 min-w-0 space-y-3 pt-0.5">
{error ? (
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-red-500/8 border border-red-500/15 text-red-400">
<AlertCircle size={12} className="shrink-0 mt-0.5" />
<p className="text-[11px] leading-relaxed">{error}</p>
</div>
<AiSetupErrorCard message={error} provider={aiProvider} />
) : result ? (
<>
{/* Main Answer (for conversational / Q&A responses) */}
Expand Down
44 changes: 44 additions & 0 deletions src/components/ai/AiSetupErrorCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { AlertCircle, Settings } from 'lucide-react';
import { friendlyAiError } from './aiSetupErrors';
import type { ProviderValue } from './providerCatalog';
import { useAppStore } from '../../store/useAppStore';

interface AiSetupErrorCardProps {
message: string;
provider?: ProviderValue;
className?: string;
}

/**
* Chat-inline setup / auth error with optional one-click open to Settings → AI.
*/
export function AiSetupErrorCard({ message, provider, className }: AiSetupErrorCardProps) {
const openSettings = useAppStore((s) => s.openSettings);
const settingsProvider = useAppStore((s) => s.settings.ai?.provider) as ProviderValue | undefined;
const resolvedProvider = provider ?? settingsProvider;
const { text, showSettingsCta } = friendlyAiError(message, resolvedProvider);

return (
<div
className={
className
?? 'flex flex-col gap-2 p-2.5 rounded-lg bg-amber-500/8 border border-amber-500/20 text-amber-100/90'
}
>
<div className="flex items-start gap-2">
<AlertCircle size={12} className="shrink-0 mt-0.5 text-amber-400" />
<p className="text-[11px] leading-relaxed text-amber-100/90">{text}</p>
</div>
{showSettingsCta && (
<button
type="button"
onClick={() => openSettings('ai')}
className="self-start ml-5 inline-flex items-center gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1 text-[11px] font-semibold text-amber-200 hover:bg-amber-500/20 hover:text-amber-100 transition-colors"
>
<Settings size={11} />
Open Settings → AI
</button>
)}
</div>
);
}
94 changes: 61 additions & 33 deletions src/components/ai/AiSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ import {
} from './providerCatalog';
import { useAiProviderModels } from './useAiProviderModels';
import { collectAiRequestContext } from '../../lib/aiContext';
import { startAgentRun, stopAgentRun, clearBrainSessions } from '../../ai/services/aiClient';
import { getSavedProviderKey, startAgentRun, stopAgentRun, clearBrainSessions } from '../../ai/services/aiClient';
import { useAgentRunStore } from '../../ai/store/agentRunStore';
import {
shouldTreatAgentInputAsAsk,
formatMissingApiKeyMessage,
NO_MODEL_SELECTED_MESSAGE,
OLLAMA_NO_MODEL_MESSAGE,
OLLAMA_NOT_RUNNING_MESSAGE,
providerRequiresApiKey,
} from './aiSetupErrors';
import {
submitAgentGoal,
submitAskQuery,
} from './sidebarSubmit';
Expand Down Expand Up @@ -302,42 +308,69 @@ export function AiSidebar({ connectionId, activeTermId: activeTermIdProp, onRunC
}
}, [activeRunId, showToast]);

/** Post a setup/auth error into the visible chat thread (Ask or Agent). */
const postSetupErrorInChat = useCallback((userQuery: string, setupMessage: string) => {
if (isAgentMode) {
// Show the user turn + error in the agent thread without starting a network run.
const localRunId = crypto.randomUUID();
agentAct().startRun(agentScope, localRunId, userQuery);
agentAct().addError(agentScope, setupMessage);
agentAct().endRun(agentScope);
return;
}

if (connectionId) {
addToDisplayHistory(connectionId, {
id: crypto.randomUUID(),
query: userQuery,
result: null,
error: setupMessage,
contextSnapshot: attachedContext?.content ?? null,
timestamp: Date.now(),
});
return;
}

// No host selected — still surface the message.
showToast('warning', setupMessage);
}, [isAgentMode, agentScope, connectionId, attachedContext, addToDisplayHistory, showToast]);

const handleSubmit = useCallback(async () => {
const trimmed = query.trim();
if (!trimmed || isLoading || agentRunning) return;
pushAiHistory(trimmed);

if (providerNeedsSetup) {
const setupMessage = activeProviderValue === 'ollama'
? (!ollamaAvailable
? 'Ollama is not running. Start Ollama or switch to another provider.'
: 'No Ollama model found. Pull a model (for example: ollama pull llama3.2) or switch provider.')
: 'No model selected for the current provider. Please select a model and try again.';

if (isAgentMode) {
agentAct().addError(agentScope, setupMessage);
} else if (connectionId) {
addToDisplayHistory(connectionId, {
id: crypto.randomUUID(),
query: trimmed,
result: null,
error: setupMessage,
contextSnapshot: attachedContext?.content ?? null,
timestamp: Date.now(),
});
} else {
showToast('warning', setupMessage);
}
? (!ollamaAvailable ? OLLAMA_NOT_RUNNING_MESSAGE : OLLAMA_NO_MODEL_MESSAGE)
: NO_MODEL_SELECTED_MESSAGE;

postSetupErrorInChat(trimmed, setupMessage);
setQuery('');
if (inputRef.current) inputRef.current.style.height = 'auto';
return;
}

if (isAgentMode) {
if (shouldTreatAgentInputAsAsk(trimmed)) {
setAiMode('ask');
await handleSubmitAsk(trimmed);
} else {
await handleSubmitAgent(trimmed);
// BYOK providers: fail fast in-chat with a Settings CTA instead of a raw network error.
if (providerRequiresApiKey(activeProviderValue)) {
try {
const key = await getSavedProviderKey(activeProviderValue);
if (!key?.trim()) {
postSetupErrorInChat(trimmed, formatMissingApiKeyMessage(activeProviderValue));
setQuery('');
if (inputRef.current) inputRef.current.style.height = 'auto';
return;
}
} catch (error) {
console.error('[AiSidebar] Failed to check provider API key', error);
// Fall through to normal submit; backend will still return a clear key error.
}
}

// Agent mode always uses the agent path — do not silently flip to Ask
// for greetings (that made it look like the wrong mode was responding).
if (isAgentMode) {
await handleSubmitAgent(trimmed);
} else {
await handleSubmitAsk(trimmed);
}
Expand All @@ -350,14 +383,9 @@ export function AiSidebar({ connectionId, activeTermId: activeTermIdProp, onRunC
providerNeedsSetup,
activeProviderValue,
ollamaAvailable,
agentScope,
connectionId,
attachedContext,
addToDisplayHistory,
showToast,
postSetupErrorInChat,
handleSubmitAgent,
handleSubmitAsk,
setAiMode,
]);

const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
Expand Down
9 changes: 6 additions & 3 deletions src/components/ai/ConversationThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useAgentRunStore } from '../../ai/store/agentRunStore';
import { ToolCallBlock } from './ToolCallBlock';
import { CheckpointBlock } from './CheckpointBlock';
import { PlanBubble } from './PlanBubble';
import { AiSetupErrorCard } from './AiSetupErrorCard';
import { respondToCheckpoint, whitelistCommand } from '../../ai/services/aiClient';
import type {
AgentThinkingEvent,
Expand Down Expand Up @@ -228,9 +229,11 @@ function DoneBubble({ success, summary, actions = [], sessionPath }: { success:

function ErrorBubble({ message }: { message: string }) {
return (
<div className="flex items-start gap-2.5 px-3 py-2.5 rounded-xl border bg-red-500/5 border-red-500/20">
<XCircle size={14} className="shrink-0 text-red-400 mt-0.5" />
<p className="text-[12px] text-red-300/80 leading-relaxed">{message}</p>
<div className="px-1">
<AiSetupErrorCard
message={message}
className="flex flex-col gap-2 px-3 py-2.5 rounded-xl border bg-red-500/5 border-red-500/20 text-red-300/90"
/>
</div>
);
}
Expand Down
Loading
Loading