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
2 changes: 1 addition & 1 deletion src/apps/desktop/capabilities/browser-webview.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"webviews": ["embedded-browser-*"],
"local": true,
"remote": {
"urls": ["https://*", "http://*"]
"urls": ["https://*", "https://*:*", "http://*", "http://*:*"]
},
"permissions": [
"core:event:allow-emit"
Expand Down
19 changes: 17 additions & 2 deletions src/apps/desktop/src/api/browser_api.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
//! Browser API — commands for the embedded browser feature.
//!
//! Browser webviews are created on the Rust side so that we can attach an
//! `on_page_load` handler that safely catches panics from the upstream wry
//! `url_from_webview` bug (WKWebView.URL() returning nil).
//! See: <https://github.com/tauri-apps/wry/pull/1554>

use serde::Deserialize;
use tauri::Manager;
Expand Down Expand Up @@ -31,6 +36,10 @@ pub struct WebviewLabelRequest {
}

/// Return the current URL of a browser webview.
///
/// Uses `catch_unwind` to guard against a known wry bug where
/// `WKWebView::URL()` returns nil (e.g. after navigating to an invalid
/// address), causing an `unwrap()` panic inside `url_from_webview`.
#[tauri::command]
pub async fn browser_get_url(
app: tauri::AppHandle,
Expand All @@ -40,6 +49,12 @@ pub async fn browser_get_url(
.get_webview(&request.label)
.ok_or_else(|| format!("Webview not found: {}", request.label))?;

let url = webview.url().map_err(|e| format!("url failed: {e}"))?;
Ok(url.to_string())
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| webview.url()));

match result {
Ok(Ok(url)) => Ok(url.to_string()),
Ok(Err(e)) => Err(format!("url failed: {e}")),
Err(_) => Err("url unavailable (webview URL is nil)".to_string()),
}
}

12 changes: 12 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,18 @@ fn setup_panic_hook() {

log::error!("Application panic at {}: {}", location, message);

// Known wry bug: WKWebView.URL() returns nil after navigating to an
// invalid address, causing url_from_webview to panic on unwrap().
// This is non-fatal — the webview is still alive — so we log and
// continue instead of killing the process.
// See: https://github.com/tauri-apps/wry/pull/1554
if location.contains("wry") && location.contains("wkwebview") {
log::warn!(
"Suppressed non-fatal wry/wkwebview panic, application continues"
);
return;
}

if message.contains("WSAStartup") || message.contains("10093") || message.contains("hyper")
{
log::error!("Network-related crash detected, possible solutions:");
Expand Down
28 changes: 18 additions & 10 deletions src/crates/core/src/agentic/tools/implementations/bash_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ impl BashTool {
Self
}

/// Build environment variables that suppress interactive behaviors
/// (pagers, editors, prompts) so agent-driven commands never block.
pub fn noninteractive_env() -> std::collections::HashMap<String, String> {
let mut env = std::collections::HashMap::new();
env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string());
// Disable git pager globally (prevents `less`/`more` from blocking)
env.insert("GIT_PAGER".to_string(), "cat".to_string());
// Disable generic pager for other tools (man, etc.)
env.insert("PAGER".to_string(), "cat".to_string());
// Prevent git from prompting for credentials or SSH passphrases
env.insert("GIT_TERMINAL_PROMPT".to_string(), "0".to_string());
// Ensure git never opens an interactive editor (e.g. for commit messages)
env.insert("GIT_EDITOR".to_string(), "true".to_string());
env
}

/// Resolve shell configuration for bash tool.
/// If configured shell doesn't support integration, falls back to system default.
async fn resolve_shell() -> ResolvedShell {
Expand Down Expand Up @@ -452,11 +468,7 @@ Usage notes:
&chat_session_id[..8.min(chat_session_id.len())]
)),
shell_type: shell_type.clone(),
env: Some({
let mut env = std::collections::HashMap::new();
env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string());
env
}),
env: Some(Self::noninteractive_env()),
..Default::default()
},
)
Expand Down Expand Up @@ -670,11 +682,7 @@ impl BashTool {
session_id: None,
session_name: None,
shell_type,
env: Some({
let mut env = std::collections::HashMap::new();
env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string());
env
}),
env: Some(Self::noninteractive_env()),
..Default::default()
},
)
Expand Down
8 changes: 3 additions & 5 deletions src/crates/core/src/service/remote_connect/remote_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1687,11 +1687,9 @@ impl RemoteExecutionDispatcher {
working_directory: workspace,
session_id: Some(sid.clone()),
session_name: Some(name),
env: Some({
let mut m = std::collections::HashMap::new();
m.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string());
m
}),
env: Some(
crate::agentic::tools::implementations::bash_tool::BashTool::noninteractive_env(),
),
..Default::default()
},
)
Expand Down
51 changes: 42 additions & 9 deletions src/mobile-web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -518,11 +518,36 @@ const MarkdownContent: React.FC<MarkdownContentProps> = ({ content, onFileDownlo

// ─── Thinking (ModelThinkingDisplay-style) ───────────────────────────────────

const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ thinking, streaming }) => {
const ThinkingBlock: React.FC<{
thinking: string;
streaming?: boolean;
isLastItem?: boolean;
}> = ({ thinking, streaming, isLastItem = false }) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(!!streaming);
const userToggledRef = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const [scrollState, setScrollState] = useState({ atTop: true, atBottom: true });
const displayedThinking = useTypewriter(thinking, !!streaming);

useEffect(() => {
if (userToggledRef.current) return;
if (streaming) {
setOpen(true);
} else if (!isLastItem) {
setOpen(false);
}
}, [streaming, isLastItem]);

useEffect(() => {
if (!streaming || !open) return;
const el = wrapperRef.current;
if (!el) return;
const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
if (gap < 80) {
el.scrollTop = el.scrollHeight;
}
}, [displayedThinking, streaming, open]);

const handleScroll = useCallback(() => {
const el = wrapperRef.current;
Expand All @@ -533,6 +558,11 @@ const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ th
});
}, []);

const handleToggle = useCallback(() => {
userToggledRef.current = true;
setOpen(o => !o);
}, []);

if (!thinking && !streaming) return null;

const charCount = thinking.length;
Expand All @@ -542,7 +572,7 @@ const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ th

return (
<div className={`chat-thinking ${streaming ? 'chat-thinking--streaming' : ''}`}>
<button className="chat-thinking__toggle" onClick={() => setOpen(o => !o)}>
<button className="chat-thinking__toggle" onClick={handleToggle}>
<span className={`chat-thinking__chevron ${open ? 'is-open' : ''}`}>
<svg width="10" height="10" viewBox="0 0 16 16" fill="none">
<path d="M6 4L10 8L6 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
Expand All @@ -560,7 +590,7 @@ const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ th
onScroll={handleScroll}
>
<div className="chat-thinking__content">
<MarkdownContent content={thinking} />
<MarkdownContent content={streaming ? displayedThinking : thinking} />
</div>
</div>
)}
Expand Down Expand Up @@ -1411,11 +1441,13 @@ function renderStandardGroups(
animate?: boolean,
onFileDownload?: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise<void>,
onGetFileInfo?: (path: string) => Promise<{ name: string; size: number; mimeType: string }>,
isActiveTurn?: boolean,
) {
return groups.map((g, gi) => {
if (g.type === 'thinking') {
const text = g.entries.map(e => e.content || '').join('\n\n');
return <ThinkingBlock key={`${keyPrefix}-thinking-${gi}`} thinking={text} />;
const isLast = isActiveTurn && gi === groups.length - 1;
return <ThinkingBlock key={`${keyPrefix}-thinking-${gi}`} thinking={text} streaming={isLast} isLastItem={isLast} />;
}
if (g.type === 'tool') {
const rendered: React.ReactNode[] = [];
Expand Down Expand Up @@ -1533,7 +1565,7 @@ function renderActiveTurnItems(
};

if (askEntries.length === 0) {
return renderStandardGroups(groupChatItems(items), 'active', now, onCancel, true, onFileDownload, onGetFileInfo);
return renderStandardGroups(groupChatItems(items), 'active', now, onCancel, true, onFileDownload, onGetFileInfo, true);
}

const beforeAskItems: ChatMessageItem[] = [];
Expand All @@ -1551,9 +1583,9 @@ function renderActiveTurnItems(

return (
<>
{renderStandardGroups(groupChatItems(beforeAskItems), 'active-before', now, onCancel, true, onFileDownload, onGetFileInfo)}
{renderStandardGroups(groupChatItems(beforeAskItems), 'active-before', now, onCancel, true, onFileDownload, onGetFileInfo, true)}
{renderQuestionEntries(askEntries, 'active', onAnswer)}
{renderStandardGroups(groupChatItems(afterAskItems), 'active-after', now, onCancel, true, onFileDownload, onGetFileInfo)}
{renderStandardGroups(groupChatItems(afterAskItems), 'active-after', now, onCancel, true, onFileDownload, onGetFileInfo, true)}
</>
);
}
Expand Down Expand Up @@ -2532,7 +2564,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ sessionMgr, sessionId, sessionName,
{!hasRunningSubagent && (turn.thinking || turnIsActive) && (
<ThinkingBlock
thinking={turn.thinking}
streaming={turnIsActive && !turn.thinking && !turn.text}
streaming={turnIsActive}
isLastItem={turnIsActive}
/>
)}
{taskTools.map(t => (
Expand Down
9 changes: 4 additions & 5 deletions src/mobile-web/src/pages/SessionListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,8 @@ function SessionTypeIcon({ agentType }: { agentType: string }) {
/* Mode Selection Icons */
const ProModeIcon = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
<line x1="12" y1="2" x2="12" y2="22" />
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
);

Expand Down Expand Up @@ -562,7 +561,7 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectS
)}

{/* Session Creation Options */}
<section className="session-list__panel">
<section className={`session-list__panel ${!isProMode ? 'session-list__panel--assistant' : ''}`}>
<div className="session-list__section-head">
<div>
<div className="session-list__section-kicker">{t('sessions.launch')}</div>
Expand Down Expand Up @@ -632,7 +631,7 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectS
</section>

{/* Session History */}
<section className="session-list__panel session-list__panel--sessions">
<section className={`session-list__panel session-list__panel--sessions ${!isProMode ? 'session-list__panel--assistant' : ''}`}>
<div className="session-list__section-head">
<div>
<div className="session-list__section-kicker">{t('sessions.recent')}</div>
Expand Down
4 changes: 4 additions & 0 deletions src/mobile-web/src/styles/components/sessions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent-500);

.session-list__panel--assistant & {
color: var(--color-pink-500);
}
}

.session-list__section-title {
Expand Down
5 changes: 5 additions & 0 deletions src/web-ui/src/app/scenes/browser/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useSceneStore } from '@/app/stores/sceneStore';
import { useContextStore } from '@/shared/context-system';
import type { WebElementContext } from '@/shared/types/context';
import { createInspectorScript, CANCEL_INSPECTOR_SCRIPT, BLANK_TARGET_INTERCEPT_SCRIPT } from './browserInspectorScript';
import { validateUrl, checkConnectivity } from './browserUrlCheck';
import './BrowserPanel.scss';

const log = createLogger('BrowserPanel');
Expand Down Expand Up @@ -242,6 +243,9 @@ const BrowserPanel: React.FC<BrowserPanelProps> = ({ isActive, initialUrl }) =>
}

try {
validateUrl(nextUrl);
await checkConnectivity(nextUrl);

if (urlPollTimerRef.current) {
clearInterval(urlPollTimerRef.current);
urlPollTimerRef.current = null;
Expand All @@ -265,6 +269,7 @@ const BrowserPanel: React.FC<BrowserPanelProps> = ({ isActive, initialUrl }) =>
currentUrlRef.current = url;
setInputValue(url);
setCurrentUrl(url);
setError(null);
evalWebview(label, BLANK_TARGET_INTERCEPT_SCRIPT).catch(() => {});
}
})
Expand Down
5 changes: 5 additions & 0 deletions src/web-ui/src/app/scenes/browser/BrowserScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IconButton } from '@/component-library';
import { createLogger } from '@/shared/utils/logger';
import { useSceneStore } from '@/app/stores/sceneStore';
import { BLANK_TARGET_INTERCEPT_SCRIPT } from './browserInspectorScript';
import { validateUrl, checkConnectivity } from './browserUrlCheck';
import './BrowserScene.scss';

const log = createLogger('BrowserScene');
Expand Down Expand Up @@ -259,6 +260,9 @@ const BrowserScene: React.FC = () => {
}

try {
validateUrl(nextUrl);
await checkConnectivity(nextUrl);

if (urlPollTimerRef.current) {
clearInterval(urlPollTimerRef.current);
urlPollTimerRef.current = null;
Expand All @@ -282,6 +286,7 @@ const BrowserScene: React.FC = () => {
currentUrlRef.current = url;
setInputValue(url);
setCurrentUrl(url);
setError(null);
evalWebview(label, BLANK_TARGET_INTERCEPT_SCRIPT).catch(() => {});
}
})
Expand Down
29 changes: 29 additions & 0 deletions src/web-ui/src/app/scenes/browser/browserUrlCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export function validateUrl(url: string): void {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Unsupported protocol: ${parsed.protocol}`);
}
if (!parsed.hostname) {
throw new Error('Missing hostname');
}
} catch (e) {
throw new Error(`Invalid URL: ${url}${e instanceof Error ? ` (${e.message})` : ''}`);
}
}

export async function checkConnectivity(url: string): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
await fetch(url, {
method: 'HEAD',
mode: 'no-cors',
signal: controller.signal,
});
} catch {
throw new Error(`Connection failed: ${new URL(url).hostname} is not reachable`);
} finally {
clearTimeout(timeout);
}
}
Loading
Loading