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
32 changes: 16 additions & 16 deletions src-tauri/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,17 +370,17 @@ pub struct WindowPlacement {
pub anchor_bottom_y: Option<f64>,
}

/// Returns the bottom-center position for the no-selection spawn point.
fn bottom_center(
/// Returns the top-center position for the no-selection spawn point.
fn top_center(
screen_width: f64,
screen_height: f64,
_screen_height: f64,
window_width: f64,
window_height: f64,
_window_height: f64,
) -> WindowPlacement {
let x_min = SCREEN_MARGIN;
let x_max = (screen_width - window_width - SCREEN_MARGIN).max(x_min);
let x = ((screen_width - window_width) / 2.0).clamp(x_min, x_max);
let y = screen_height - window_height - SCREEN_MARGIN - 32.0;
let y = MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0;
WindowPlacement {
x,
y,
Expand Down Expand Up @@ -480,11 +480,11 @@ pub fn calculate_window_position(
window_height,
)
} else {
bottom_center(screen_width, screen_height, window_width, window_height)
top_center(screen_width, screen_height, window_width, window_height)
}
} else {
// No selection → bottom center of screen.
bottom_center(screen_width, screen_height, window_width, window_height)
// No selection → top center of screen.
top_center(screen_width, screen_height, window_width, window_height)
};

// Secondary check: if the flip logic above did not set an anchor, determine
Expand Down Expand Up @@ -545,16 +545,16 @@ mod tests {
const WH: f64 = 80.0;

#[test]
fn no_selection_returns_bottom_center() {
fn no_selection_returns_top_center() {
let p = calculate_window_position(&ctx_no_selection(), SW, SH, WW, WH);
assert_eq!(p.x, (SW - WW) / 2.0);
assert_eq!(p.y, SH - WH - SCREEN_MARGIN - 32.0);
assert_eq!(p.anchor_bottom_y, Some(SH - WH - SCREEN_MARGIN - 32.0 + WH));
assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0);
assert_eq!(p.anchor_bottom_y, None);
}

#[test]
fn text_with_no_bounds_and_no_mouse_falls_back_to_bottom_center() {
// Same bottom-center position anchor pinned.
fn text_with_no_bounds_and_no_mouse_falls_back_to_top_center() {
// Same top-center position — no anchor needed since the bar grows downward.
let ctx = ActivationContext {
selected_text: Some("hello world".to_string()),
bounds: None,
Expand All @@ -564,8 +564,8 @@ mod tests {
let x_min = SCREEN_MARGIN;
let x_max = (SW - WW - SCREEN_MARGIN).max(x_min);
assert_eq!(p.x, ((SW - WW) / 2.0).clamp(x_min, x_max));
assert_eq!(p.y, SH - WH - SCREEN_MARGIN - 32.0);
assert_eq!(p.anchor_bottom_y, Some(SH - WH - SCREEN_MARGIN - 32.0 + WH));
assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0);
assert_eq!(p.anchor_bottom_y, None);
}

#[test]
Expand Down Expand Up @@ -688,7 +688,7 @@ mod tests {
}

#[test]
fn bottom_center_on_small_screen() {
fn top_center_on_small_screen() {
let small_w = WW + 2.0 * SCREEN_MARGIN;
let p = calculate_window_position(&ctx_no_selection(), small_w, SH, WW, WH);
assert_eq!(p.x, SCREEN_MARGIN);
Expand Down
104 changes: 86 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ function App() {
* but rendered differently based on `isChatMode`).
*/
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
/**
* True when the user clicked + while an unsaved conversation is active.
* Causes the history dropdown to show a SwitchConfirmation prompt instead
* of the conversation list.
*/
const [pendingNewConversation, setPendingNewConversation] = useState(false);

/**
* Direct reference to the morphing container DOM node, stored alongside the
Expand All @@ -75,6 +81,7 @@ function App() {
conversationId,
isSaved,
save,
unsave,
persistTurn,
loadConversation,
deleteConversation,
Expand Down Expand Up @@ -362,6 +369,11 @@ function App() {
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [isChatMode, isHistoryOpen]);

// Clear any pending new-conversation confirmation whenever the panel closes.
useEffect(() => {
if (!isHistoryOpen) setPendingNewConversation(false);
}, [isHistoryOpen]);

/**
* Observes the dropdown's height while it's open and mutates the morphing
* container's `min-height` style directly (bypassing React state) so the
Expand Down Expand Up @@ -397,16 +409,23 @@ function App() {
/* v8 ignore stop */
}, [isChatMode, isHistoryOpen]);

/** Saves the current conversation to SQLite. */
/**
* Toggles the save state of the current conversation.
* - Not saved → saves to SQLite (bookmark fills).
* - Already saved → deletes from SQLite, marks unsaved (bookmark empties);
* messages remain in the UI so the session can be re-saved if desired.
*/
const handleSave = useCallback(async () => {
try {
await save(messages, MODEL_NAME);
if (isSaved) {
await unsave();
} else {
await save(messages, MODEL_NAME);
}
} catch {
// Save failed — bookmark state stays unchanged; the error is surfaced by
// the Tauri runtime. No UI banner here; save is a user-initiated fire-and-
// forget action with visible feedback via the bookmark icon state.
// State stays unchanged on failure; feedback is implicit in the icon.
}
}, [save, messages]);
}, [isSaved, unsave, save, messages]);

/**
* Loads a conversation from history, replacing the current session.
Expand Down Expand Up @@ -461,30 +480,76 @@ function App() {
/**
* Deletes a conversation from the history panel.
*
* When the deleted conversation is the currently active one, both the
* message history (`reset`) and the persistence state (`resetHistory`) are
* cleared so the UI returns to the blank ask-bar state. The error is
* When the deleted conversation is the currently active one, only the
* persistence state (`resetHistory`) is cleared — messages remain visible
* so the user can continue chatting or re-save. The error is intentionally
* re-thrown so `HistoryPanel` can roll back its optimistic removal.
*/
const handleDeleteConversation = useCallback(
async (id: string) => {
await deleteConversation(id);
if (id === conversationId) {
reset();
resetHistory();
}
},
[deleteConversation, conversationId, reset, resetHistory],
[deleteConversation, conversationId, resetHistory],
);

/** Starts a fresh conversation from within conversation view. */
const handleNewConversation = useCallback(() => {
/**
* Shared reset sequence for all "start a new conversation" paths.
*
* Mirrors what `replayEntranceAnimation` does for the anchor-mode state so
* the Tauri window shrinks back to ask-bar height regardless of whether the
* session was launched from a text-selection anchor:
*
* - `isPreExpandedRef.current = false` unblocks the ResizeObserver in anchor
* mode so it can call `set_window_frame` with the (smaller) ask-bar height.
* - Clearing `outerContainerRef.current.style.minHeight` removes the inline
* CSS constraint that was keeping the outer container at the expanded height.
*/
const resetForNewConversation = useCallback(() => {
isPreExpandedRef.current = false;
/* v8 ignore start -- DOM ref null guard */
if (outerContainerRef.current) {
outerContainerRef.current.style.minHeight = '';
}
/* v8 ignore stop */
reset();
resetHistory();
setIsHistoryOpen(false);
setQuery('');
}, [reset, resetHistory]);

/**
* Starts a fresh conversation from within conversation view.
* If the current conversation has unsaved messages, opens the history
* dropdown and surfaces a SwitchConfirmation prompt instead of resetting
* immediately.
*/
const handleNewConversation = useCallback(() => {
if (!isSaved && messages.length > 0) {
setPendingNewConversation(true);
setIsHistoryOpen(true);
return;
}
resetForNewConversation();
}, [isSaved, messages.length, resetForNewConversation]);

/** Saves the current conversation then starts a fresh one. */
const handleSaveAndNew = useCallback(async () => {
try {
await save(messages, MODEL_NAME);
} catch {
return;
}
resetForNewConversation();
}, [save, messages, resetForNewConversation]);

/** Discards the current conversation and starts a fresh one. */
const handleJustNew = useCallback(() => {
resetForNewConversation();
}, [resetForNewConversation]);

const handleSubmit = useCallback(() => {
if (query.trim().length === 0 || isGenerating) return;
// Sanitize externally-sourced context: strip control characters and enforce
Expand Down Expand Up @@ -667,10 +732,10 @@ function App() {
style={{
transition: 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)',
}}
className={`morphing-container relative flex flex-col bg-surface-base backdrop-blur-2xl border border-surface-border ${
className={`morphing-container relative flex flex-col bg-surface-base backdrop-blur-2xl border border-surface-border max-h-[600px] overflow-hidden ${
isChatMode
? 'rounded-lg shadow-chat max-h-[600px] overflow-hidden'
: 'rounded-2xl shadow-bar overflow-hidden'
? 'rounded-lg shadow-chat'
: 'rounded-2xl shadow-bar'
}`}
>
{/* Chat Messages Area — morphs in when in chat mode */}
Expand All @@ -685,6 +750,7 @@ function App() {
onSave={handleSave}
isSaved={isSaved}
canSave={canSave}
onNewConversation={handleNewConversation}
onHistoryOpen={handleHistoryToggle}
/>
) : null}
Expand Down Expand Up @@ -765,8 +831,10 @@ function App() {
onDeleteConversation={handleDeleteConversation}
hasCurrentMessages={messages.length > 0 && !isSaved}
currentConversationId={conversationId}
showNewConversation={true}
onNewConversation={handleNewConversation}
showNewConversation={false}
pendingNewConversation={pendingNewConversation}
onSaveAndNew={handleSaveAndNew}
onJustNew={handleJustNew}
/>
</motion.div>
) : null}
Expand Down
Loading
Loading