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
8 changes: 8 additions & 0 deletions src/config/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ fn light_css() -> &'static str {
/* Voice dictation: mic button while actively recording */
button.recording { background-color: alpha(#e01b24, 0.18); color: #e01b24; }

/* Tab "needs answer" indicator: amber question mark, gently pulsing */
@keyframes flycrys-q-pulse { 0% { opacity: 1; } 50% { opacity: 0.25; } 100% { opacity: 1; } }
.tab-question { color: #e5a50a; animation: flycrys-q-pulse 1.2s ease-in-out infinite; }

/* Force popover separators visible */
popover separator { background-color: #c0c0c0; min-height: 1px; margin: 4px 8px; }
"#
Expand Down Expand Up @@ -125,6 +129,10 @@ fn dark_css() -> &'static str {
/* Voice dictation: mic button while actively recording */
button.recording { background-color: alpha(#ff6b6b, 0.22); color: #ff6b6b; }

/* Tab "needs answer" indicator: amber question mark, gently pulsing */
@keyframes flycrys-q-pulse { 0% { opacity: 1; } 50% { opacity: 0.25; } 100% { opacity: 1; } }
.tab-question { color: #f6d32d; animation: flycrys-q-pulse 1.2s ease-in-out infinite; }

/* Force popover separators visible */
popover separator { background-color: #555555; min-height: 1px; margin: 4px 8px; }
"#
Expand Down
69 changes: 67 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ struct TabSlot {
wrapper: gtk::Box,
/// Spinner widget shared between the tab label and the workspace.
spinner: gtk::Spinner,
/// Question indicator shared between the tab label and the workspace; shown
/// in place of the spinner while the agent awaits an AskUserQuestion answer.
question_icon: gtk::Image,
/// Set when the tab hasn't been visited yet; consumed by `materialize()`.
pending_config: Option<WorkspaceConfig>,
/// Set once the workspace has been constructed.
Expand All @@ -81,6 +84,17 @@ struct TabSlot {
materializing: bool,
}

/// Build the per-tab "needs answer" indicator: a question-mark icon, hidden
/// until an AskUserQuestion arrives. The `.tab-question` class drives its colour
/// and pulse animation (see `config::theme`).
fn make_tab_question_icon() -> gtk::Image {
let icon = gtk::Image::from_icon_name("dialog-question-symbolic");
icon.set_pixel_size(14);
icon.set_visible(false);
icon.add_css_class("tab-question");
icon
}

impl TabSlot {
/// Create a tab that is already built (for the active tab, or new tabs).
fn new_ready(
Expand All @@ -90,14 +104,22 @@ impl TabSlot {
) -> Self {
let spinner = gtk::Spinner::new();
spinner.set_size_request(12, 12);
let question_icon = make_tab_question_icon();
let wrapper = gtk::Box::new(gtk::Orientation::Vertical, 0);
wrapper.set_vexpand(true);
wrapper.set_hexpand(true);
let ws = Workspace::new(config, theme, notification_level, spinner.clone());
let ws = Workspace::new(
config,
theme,
notification_level,
spinner.clone(),
question_icon.clone(),
);
wrapper.append(&ws.root);
TabSlot {
wrapper,
spinner,
question_icon,
pending_config: None,
workspace: Some(ws),
materializing: false,
Expand All @@ -108,12 +130,14 @@ impl TabSlot {
fn new_pending(config: WorkspaceConfig) -> Self {
let spinner = gtk::Spinner::new();
spinner.set_size_request(12, 12);
let question_icon = make_tab_question_icon();
let wrapper = gtk::Box::new(gtk::Orientation::Vertical, 0);
wrapper.set_vexpand(true);
wrapper.set_hexpand(true);
TabSlot {
wrapper,
spinner,
question_icon,
pending_config: Some(config),
workspace: None,
materializing: false,
Expand Down Expand Up @@ -142,6 +166,7 @@ impl TabSlot {

let wrapper = self.wrapper.clone();
let spinner = self.spinner.clone();
let question_icon = self.question_icon.clone();
glib::idle_add_local_once(move || {
// Take the pending config under a short borrow, then build with the
// AppState borrow released (the build doesn't need it, and freeing it
Expand All @@ -157,7 +182,7 @@ impl TabSlot {
}
};

let ws = Workspace::new(config, theme, notification_level, spinner);
let ws = Workspace::new(config, theme, notification_level, spinner, question_icon);

let mut state = app_state.borrow_mut();
if let Some(slot) = state.slots.iter_mut().find(|s| s.wrapper == wrapper) {
Expand Down Expand Up @@ -450,6 +475,7 @@ fn build_ui(app: &gtk::Application) {
let label = create_tab_label(
&label_text,
&slot.spinner,
&slot.question_icon,
&notebook,
&slot.wrapper,
&app_state,
Expand All @@ -470,6 +496,7 @@ fn build_ui(app: &gtk::Application) {
let label = create_tab_label(
&labels[i],
&slot.spinner,
&slot.question_icon,
&notebook,
&slot.wrapper,
&app_state,
Expand Down Expand Up @@ -529,6 +556,7 @@ fn build_ui(app: &gtk::Application) {
let label = create_tab_label(
&label_text,
&slot.spinner,
&slot.question_icon,
&notebook,
&slot.wrapper,
&app_state,
Expand Down Expand Up @@ -596,6 +624,40 @@ fn build_ui(app: &gtk::Application) {

let window = window_builder.build();

// Notification click → raise the window and switch to the tab the
// notification is about. Desktop notifications set this as their default
// action with the workspace id as target (see agent_panel event handler).
{
let activate_ws =
gio::SimpleAction::new("activate-workspace", Some(glib::VariantTy::STRING));
activate_ws.connect_activate(glib::clone!(
#[weak]
notebook,
#[weak]
window,
#[strong]
app_state,
move |_, param| {
let Some(id) = param.and_then(|v| v.get::<String>()) else {
return;
};
// Resolve the index under a short borrow, then drop it before
// set_current_page — switching fires switch-page, which borrows
// app_state mutably.
let idx = app_state
.borrow()
.slots
.iter()
.position(|s| s.workspace_id() == id);
if let Some(idx) = idx {
notebook.set_current_page(Some(idx as u32));
}
window.present();
}
));
app.add_action(&activate_ws);
}

// Register bundled icons so GTK can find "flycrys" icon by name
{
let display = gtk::gdk::Display::default().unwrap();
Expand Down Expand Up @@ -821,6 +883,7 @@ fn build_settings_menu(
fn create_tab_label(
text: &str,
tab_spinner: &gtk::Spinner,
tab_question_icon: &gtk::Image,
notebook: &gtk::Notebook,
page_widget: &gtk::Box,
app_state: &Rc<RefCell<AppState>>,
Expand All @@ -830,6 +893,7 @@ fn create_tab_label(
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 4);

hbox.append(tab_spinner);
hbox.append(tab_question_icon);

let label = gtk::Label::new(Some(text));
label.set_hexpand(true);
Expand Down Expand Up @@ -1167,6 +1231,7 @@ fn clone_workspace(
let label = create_tab_label(
&label_text,
&slot.spinner,
&slot.question_icon,
notebook,
&slot.wrapper,
app_state,
Expand Down
17 changes: 16 additions & 1 deletion src/ui/agent_panel/event_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ fn notify_workspace(state: &PanelState, prefix: &str) {
let name = (state.workspace_label)();
let notification = gio::Notification::new("FlyCrys");
notification.set_body(Some(&format!("{prefix} \"{name}\"")));
app.send_notification(None, &notification);
// Clicking the notification raises the window and switches to this
// workspace's tab, via the app's `activate-workspace` action (registered
// in main, with the workspace id as the target).
let target = state.workspace_id.to_variant();
notification.set_default_action_and_target_value("app.activate-workspace", Some(&target));
// Key by workspace id so a newer alert for the same tab replaces the
// older one instead of stacking up.
app.send_notification(Some(state.workspace_id.as_str()), &notification);
}
}

Expand Down Expand Up @@ -258,6 +265,9 @@ pub(crate) fn handle_domain_event(
s.chat.webview.append_system_message(&msg);
}
s.tab_spinner.set_spinning(false);
// Clear any lingering question indicator and restore the spinner slot.
s.tab_spinner.set_visible(true);
s.tab_question_icon.set_visible(false);

s.tokens.total_cost_usd = total_cost_usd;
s.tokens
Expand Down Expand Up @@ -336,6 +346,11 @@ pub(crate) fn handle_domain_event(
s.chat.webview.append_question(&request_id, &input_json);
s.chat.webview.scroll_to_bottom();

// Swap the tab spinner for the pulsing question indicator while the
// agent is blocked waiting on the user's answer.
s.tab_spinner.set_visible(false);
s.tab_question_icon.set_visible(true);

// Desktop notification — distinct from the "finished" message so the
// user knows the agent is blocked waiting on their answer.
notify_workspace(&s, "Agent needs your answer in");
Expand Down
15 changes: 15 additions & 0 deletions src/ui/agent_panel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ pub fn create_agent_panel(
theme: Rc<Cell<Theme>>,
notification_level: Rc<Cell<NotificationLevel>>,
tab_spinner: gtk::Spinner,
tab_question_icon: gtk::Image,
working_dir: &std::path::Path,
workspace_label: Rc<dyn Fn() -> String>,
workspace_id: String,
agent_configs: Vec<AgentConfig>,
initial_profile: &str,
resume_session_id: Option<String>,
Expand Down Expand Up @@ -342,6 +344,8 @@ pub fn create_agent_panel(
},
model_status_label: model_status_label.clone(),
tab_spinner,
tab_question_icon,
workspace_id,
workspace_label,
on_open_file,
on_session_id_change,
Expand Down Expand Up @@ -449,6 +453,10 @@ pub fn create_agent_panel(
.process
.process
.answer_question(&request_id, updated_input);
// Agent resumes — swap the question indicator back to the spinner.
s.tab_question_icon.set_visible(false);
s.tab_spinner.set_visible(true);
s.tab_spinner.set_spinning(true);
},
));
}
Expand All @@ -463,6 +471,10 @@ pub fn create_agent_panel(
.set_on_reject_question(Rc::new(move |request_id: String| {
let mut s = state_rej.borrow_mut();
let _ = s.process.process.reject_question(&request_id);
// Agent resumes — swap the question indicator back to the spinner.
s.tab_question_icon.set_visible(false);
s.tab_spinner.set_visible(true);
s.tab_spinner.set_spinning(true);
}));
}

Expand Down Expand Up @@ -591,6 +603,9 @@ pub fn create_agent_panel(
// Show thinking indicator in WebView
let thinking_id = s.chat.webview.show_thinking();
s.chat.thinking_id = Some(thinking_id);
// Sending a new message supersedes any pending question prompt.
s.tab_question_icon.set_visible(false);
s.tab_spinner.set_visible(true);
s.tab_spinner.set_spinning(true);
drop(s);

Expand Down
7 changes: 7 additions & 0 deletions src/ui/agent_panel/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ pub(crate) struct PanelState {
/// actions, the model probe, and the `Started` event.
pub model_status_label: gtk::Label,
pub tab_spinner: gtk::Spinner,
/// Tab indicator shown in place of the spinner while the agent is blocked on
/// an AskUserQuestion. Hidden the rest of the time; the spinner takes over
/// again as soon as the agent resumes.
pub tab_question_icon: gtk::Image,
/// Stable workspace id, used as the desktop-notification target so clicking
/// the notification can switch to this exact tab.
pub workspace_id: String,
/// Returns the workspace's current display name (custom tab label if set,
/// else the directory basename). Used for desktop notification bodies, so it
/// must reflect renames — hence a getter over the shared config, not a
Expand Down
6 changes: 6 additions & 0 deletions src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub struct Workspace {
pub root: gtk::Box,
pub config: Rc<RefCell<WorkspaceConfig>>,
pub tab_spinner: gtk::Spinner,
pub tab_question_icon: gtk::Image,
pub chat_history: Rc<RefCell<Vec<session::ChatMessage>>>,
/// Length of `chat_history` at the last autosave. Chat history is
/// append-only, so a length change is a faithful "dirty" signal — the
Expand All @@ -42,6 +43,7 @@ impl Workspace {
theme: Rc<Cell<Theme>>,
notification_level: Rc<Cell<NotificationLevel>>,
tab_spinner: gtk::Spinner,
tab_question_icon: gtk::Image,
) -> Self {
let working_dir = PathBuf::from(&config.working_directory);
let config = Rc::new(RefCell::new(config));
Expand Down Expand Up @@ -231,14 +233,17 @@ impl Workspace {
let cfg = Rc::clone(&config);
Rc::new(move || cfg.borrow().tab_label())
};
let workspace_id = config.borrow().id.clone();

agent_panel::create_agent_panel(
Rc::clone(&on_open_file),
Rc::clone(&theme),
Rc::clone(&notification_level),
tab_spinner.clone(),
tab_question_icon.clone(),
&working_dir,
workspace_label,
workspace_id,
agent_configs,
&profile,
session_id,
Expand Down Expand Up @@ -429,6 +434,7 @@ impl Workspace {
root,
config,
tab_spinner,
tab_question_icon,
chat_history,
last_saved_chat_len,
run_panel,
Expand Down