Skip to content
Open
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
13 changes: 12 additions & 1 deletion src/apps/desktop/src/appearance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ struct StartupBootstrapConfig {

const MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES: usize = 64 * 1024;
const MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES: usize = 64 * 1024;
const MAX_BOOTSTRAP_STARTUP_COMMAND_JSON_BYTES: usize = 4 * 1024;

impl Default for AppearanceConfig {
fn default() -> Self {
Expand Down Expand Up @@ -363,6 +364,7 @@ impl AppearanceConfig {
startup_trace_id: &str,
bootstrap_config: &StartupBootstrapConfig,
workspace_startup_state: Option<&serde_json::Value>,
startup_command: Option<&serde_json::Value>,
) -> String {
let appearance_mode = if self.is_light { "light" } else { "dark" };
let startup_locale = &bootstrap_config.locale;
Expand Down Expand Up @@ -396,6 +398,11 @@ impl AppearanceConfig {
.filter(|json| json.len() <= MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES)
.map(|json| format!("window.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__ = {json};"))
.unwrap_or_default();
let bootstrap_startup_command_assignment = startup_command
.and_then(|cmd| serde_json::to_string(cmd).ok())
.filter(|json| json.len() <= MAX_BOOTSTRAP_STARTUP_COMMAND_JSON_BYTES)
.map(|json| format!("window.__BITFUN_STARTUP_COMMAND__ = {json};"))
.unwrap_or_default();

format!(
r#"
Expand All @@ -410,6 +417,7 @@ impl AppearanceConfig {
window.__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__ = {bootstrap_appearance_selection_json};
{bootstrap_keybindings_assignment}
{bootstrap_workspace_startup_state_assignment}
{bootstrap_startup_command_assignment}
function applyAppearance() {{
var root = document.documentElement;
if (!root) return false;
Expand Down Expand Up @@ -462,6 +470,7 @@ impl AppearanceConfig {
bootstrap_keybindings_assignment = bootstrap_keybindings_assignment,
bootstrap_workspace_startup_state_assignment =
bootstrap_workspace_startup_state_assignment,
bootstrap_startup_command_assignment = bootstrap_startup_command_assignment,
)
}

Expand Down Expand Up @@ -497,7 +506,7 @@ mod startup_appearance_tests {
keybindings: None,
};

let script = appearance.generate_init_script("trace-id", &bootstrap, None);
let script = appearance.generate_init_script("trace-id", &bootstrap, None, None);

assert!(script.contains("__BITFUN_BOOTSTRAP_APPEARANCE_ID__"));
assert!(script.contains("__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__"));
Expand Down Expand Up @@ -537,6 +546,7 @@ pub fn create_main_window(
startup_trace_id: &str,
startup_trace: &DesktopStartupTrace,
workspace_startup_state: Option<serde_json::Value>,
startup_command: Option<serde_json::Value>,
) {
let total_started_at = Instant::now();
let bootstrap_config = AppearanceConfig::load_startup_bootstrap_config();
Expand All @@ -546,6 +556,7 @@ pub fn create_main_window(
startup_trace_id,
&bootstrap_config,
workspace_startup_state.as_ref(),
startup_command.as_ref(),
);
startup_trace.record_step(
"native_step_end",
Expand Down
79 changes: 79 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,71 @@ use tauri::Emitter;
use tauri::Manager;
use tauri_plugin_window_state::{AppHandleExt, StateFlags, WindowExt};

/// Parsed CLI startup command (--agent, --model, --session).
///
/// When the desktop app is launched from the command line, the user may pass
/// `--agent <name>`, `--model <name>`, or `--session <id>` to pre-select the
/// agent, model, or session for the initial conversation. These are injected
/// into the frontend bootstrap as `window.__BITFUN_STARTUP_COMMAND__`.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StartupCommand {
/// Override the initial agent/mode (e.g. "agentic", "plan", "debug").
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
/// Override the initial model id.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Restore an existing session by id instead of creating a new one.
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
}

/// Parse `--agent`, `--model`, and `--session` from process arguments.
///
/// Returns `None` when none of the flags are present. Unknown flags are
/// silently ignored so that Tauri's own arguments (e.g. `--dev`) are not
/// disturbed.
fn parse_startup_command() -> Option<StartupCommand> {
let args: Vec<String> = std::env::args().collect();
let mut agent: Option<String> = None;
let mut model: Option<String> = None;
let mut session: Option<String> = None;

let mut i = 1; // skip program name
while i < args.len() {
match args[i].as_str() {
"--agent" if i + 1 < args.len() => {
agent = Some(args[i + 1].clone());
i += 2;
continue;
}
"--model" if i + 1 < args.len() => {
model = Some(args[i + 1].clone());
i += 2;
continue;
}
"--session" if i + 1 < args.len() => {
session = Some(args[i + 1].clone());
i += 2;
continue;
}
_ => {}
}
i += 1;
}

if agent.is_none() && model.is_none() && session.is_none() {
return None;
}

Some(StartupCommand {
agent,
model,
session,
})
}

// Re-export API
pub use api::*;

Expand Down Expand Up @@ -426,6 +491,19 @@ fn get_startup_native_trace(
/// Tauri application entry point
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub async fn run() {
let startup_command = parse_startup_command();
let startup_command_json = startup_command
.as_ref()
.and_then(|cmd| serde_json::to_value(cmd).ok());
if let Some(ref cmd) = startup_command {
log::info!(
"Startup command parsed: agent={:?}, model={:?}, session={:?}",
cmd.agent,
cmd.model,
cmd.session
);
}

let startup_started = Instant::now();
let startup_trace_id = SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand Down Expand Up @@ -913,6 +991,7 @@ pub async fn run() {
&startup_trace_id,
&startup_trace,
workspace_startup_bootstrap_snapshot,
startup_command_json,
);
let window_duration_ms = elapsed_ms(window_started);
startup_trace.record_step(
Expand Down
41 changes: 32 additions & 9 deletions src/web-ui/src/flow_chat/hooks/useFlowChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getNextDefaultSessionTitleCount,
normalizeDefaultSessionTitleMode,
} from '../utils/sessionTitle';
import { globalStateAPI } from '@/shared/types/global-state';

const log = createLogger('useFlowChat');

Expand Down Expand Up @@ -65,18 +66,40 @@ export const useFlowChat = () => {

// Create a session using Agentic API v2.
const createSession = useCallback(async (config?: Partial<SessionConfig>): Promise<string> => {


// Apply CLI startup overrides (--agent/--model) on the first session only.
// consumeBootstrapStartupCommand is one-shot: it clears the global after
// reading, so subsequent calls return undefined.
const startupCommand = globalStateAPI.getStartupCommand();
const effectiveAgentType = (config?.agentType || startupCommand?.agent || 'agentic').trim() || 'agentic';
const effectiveModelName = config?.modelName || startupCommand?.model;

try {
if (!workspacePath) {
throw new Error('Workspace path is required to create a session');
}

const isRemote = workspace?.workspaceKind === WorkspaceKind.Remote;
const remoteConnectionId = isRemote ? workspace?.connectionId : undefined;
const remoteSshHost = isRemote ? workspace?.sshHost : undefined;

const agentTypeForSession = (config?.agentType || 'agentic').trim() || 'agentic';
const maxContextTokens = await getModelMaxTokens(config?.modelName, agentTypeForSession);
// If --session is specified, try to restore that session instead of creating new.
if (startupCommand?.session) {
try {
const sessions = await agentAPI.listSessions(workspacePath, remoteConnectionId, remoteSshHost);
if (sessions.some(s => s.sessionId === startupCommand.session)) {
await flowChatManager.switchChatSession(startupCommand.session);
log.info('Restored startup session', { sessionId: startupCommand.session });
return startupCommand.session;
}
log.warn('Startup session not found, creating new session', { requestedSession: startupCommand.session });
} catch (error) {
log.warn('Failed to restore startup session, creating new', { requestedSession: startupCommand.session, error });
}
}

const agentTypeForSession = effectiveAgentType;
const maxContextTokens = await getModelMaxTokens(effectiveModelName, agentTypeForSession);
const sessionTitleMode =
workspace?.workspaceKind === WorkspaceKind.Assistant
? 'claw'
Expand Down Expand Up @@ -106,7 +129,7 @@ export const useFlowChat = () => {
remoteConnectionId,
remoteSshHost,
config: {
modelName: config?.modelName || 'default',
modelName: effectiveModelName || 'default',
enableTools: true,
safeMode: true,
autoCompact: true,
Expand Down Expand Up @@ -156,21 +179,21 @@ export const useFlowChat = () => {

try {
await aiApi.createAISession({
agent_type: config?.agentType || 'agentic',
model_name: config?.modelName || 'default',
agent_type: effectiveAgentType,
model_name: effectiveModelName || 'default',
description: `FlowChat session ${sessionId}`
});
} catch (snapshotError) {
log.warn('Failed to create snapshot session in fallback mode', { error: snapshotError });
}

const sessionConfig: SessionConfig = {
modelName: config?.modelName || 'default',
modelName: effectiveModelName || 'default',
...config,
workspaceId: workspace?.id ?? config?.workspaceId,
};

const fallbackAgentType = (config?.agentType || 'agentic').trim() || 'agentic';
const fallbackAgentType = effectiveAgentType;
const fallbackTitleMode =
workspace?.workspaceKind === WorkspaceKind.Assistant
? 'claw'
Expand Down
44 changes: 44 additions & 0 deletions src/web-ui/src/shared/types/global-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ declare global {
var __BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__:
| APIWorkspaceStartupStateSnapshot
| undefined;
// Native startup may inject this once when the app is launched with
// --agent/--model/--session CLI flags.
var __BITFUN_STARTUP_COMMAND__: StartupCommand | undefined;
}

/// CLI startup overrides parsed from `--agent`, `--model`, `--session`.
export interface StartupCommand {
agent?: string;
model?: string;
session?: string;
}


Expand Down Expand Up @@ -225,6 +235,11 @@ export interface WorkspaceStartupState {
export interface GlobalStateAPI {

initializeWorkspaceStartupState(): Promise<WorkspaceStartupState>;

/// Returns CLI startup overrides (`--agent`, `--model`, `--session`) if the
/// app was launched with them, then clears the bootstrap global. Only
/// meaningful on the first call; subsequent calls return undefined.
getStartupCommand(): StartupCommand | undefined;


getAppState(): Promise<ApplicationState>;
Expand Down Expand Up @@ -481,10 +496,39 @@ function consumeBootstrapWorkspaceStartupStateSnapshot():
return snapshot;
}

function consumeBootstrapStartupCommand(): StartupCommand | undefined {
if (
!Object.prototype.hasOwnProperty.call(globalThis, '__BITFUN_STARTUP_COMMAND__')
) {
return undefined;
}

const cmd = globalThis.__BITFUN_STARTUP_COMMAND__;
delete globalThis.__BITFUN_STARTUP_COMMAND__;

if (
cmd !== undefined &&
cmd !== null &&
typeof cmd === 'object' &&
(typeof cmd.agent === 'string' || cmd.agent === undefined) &&
(typeof cmd.model === 'string' || cmd.model === undefined) &&
(typeof cmd.session === 'string' || cmd.session === undefined)
) {
return cmd as StartupCommand;
}

logger.warn('Ignored invalid bootstrap startup command');
return undefined;
}


export function createGlobalStateAPI(): GlobalStateAPI {
return {

getStartupCommand(): StartupCommand | undefined {
return consumeBootstrapStartupCommand();
},

async initializeWorkspaceStartupState(): Promise<WorkspaceStartupState> {
const bootstrapSnapshot = consumeBootstrapWorkspaceStartupStateSnapshot();
if (bootstrapSnapshot) {
Expand Down