Compare commits

...
Author SHA1 Message Date
Elliot Slusky 7959285ad8 fix(cli): preserve extras during self-update (#718)
* fix(cli): preserve extras during self-update

* style(cli): format install detection
2026-08-10 17:22:29 -07:00
goatoush 26e1741059 fix(desktop): keep background streams out of active chat (#654) 2026-08-10 17:15:33 -07:00
10 changed files with 154 additions and 23 deletions
+9 -6
View File
@@ -15,6 +15,7 @@ function getGreeting(): string {
}
export function ChatArea() {
const activeId = useAppStore((s) => s.activeId);
const messages = useAppStore((s) => s.messages);
const streamState = useAppStore((s) => s.streamState);
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
@@ -24,6 +25,8 @@ export function ChatArea() {
const shouldAutoScroll = useRef(true);
const wasStreaming = useRef(false);
const lastScrollTop = useRef(0);
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
const currentStreamContent = isCurrentChatStreaming ? streamState.content : '';
// Check if any data sources are connected
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
@@ -38,14 +41,14 @@ export function ChatArea() {
useEffect(() => {
// Sending a message always pins the view to the bottom, even if the
// user had scrolled up to read earlier messages.
if (streamState.isStreaming && !wasStreaming.current) {
if (isCurrentChatStreaming && !wasStreaming.current) {
shouldAutoScroll.current = true;
}
wasStreaming.current = streamState.isStreaming;
wasStreaming.current = isCurrentChatStreaming;
if (shouldAutoScroll.current && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, streamState.content, streamState.isStreaming]);
}, [messages, currentStreamContent, isCurrentChatStreaming]);
const handleScroll = () => {
if (!listRef.current) return;
@@ -66,7 +69,7 @@ export function ChatArea() {
}
};
const isEmpty = messages.length === 0 && !streamState.isStreaming;
const isEmpty = messages.length === 0 && !isCurrentChatStreaming;
const PanelIcon = systemPanelOpen ? PanelRightClose : PanelRightOpen;
@@ -174,12 +177,12 @@ export function ChatArea() {
<MessageBubble
key={msg.id}
message={msg}
isLive={isLastAssistant && streamState.isStreaming}
isLive={isLastAssistant && isCurrentChatStreaming}
/>
);
})}
{(() => {
if (!streamState.isStreaming || streamState.content !== '') return null;
if (!isCurrentChatStreaming || streamState.content !== '') return null;
// For research messages the ResearchTimeline handles its own
// pre-content loading state — suppress the generic dots.
const last = messages[messages.length - 1];
+4 -2
View File
@@ -96,6 +96,7 @@ export function InputArea() {
const deepResearch = useAppStore((s) => s.deepResearch);
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
const corpusSync = useResearchCorpusSync(deepResearch);
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
const {
state: speechState,
@@ -226,6 +227,7 @@ export function InputArea() {
let ttftMs: number | undefined;
setStreamState({
conversationId: convId,
isStreaming: true,
phase: deepResearch ? 'Researching...' : 'Generating...',
elapsedMs: 0,
@@ -602,7 +604,7 @@ export function InputArea() {
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
disabled={streamState.isStreaming || modelLoading}
/>
{streamState.isStreaming ? (
{isCurrentChatStreaming ? (
<button
onClick={stopStreaming}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
@@ -621,7 +623,7 @@ export function InputArea() {
/>
<button
onClick={sendMessage}
disabled={!input.trim() || modelLoading || !selectedModel}
disabled={streamState.isStreaming || !input.trim() || modelLoading || !selectedModel}
title={selectedModel ? 'Send message' : 'Pick a model first (⌘K)'}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
style={{
@@ -22,6 +22,9 @@ export function ConversationList({ searchQuery }: Props) {
const navigate = useNavigate();
const conversations = useAppStore((s) => s.conversations);
const activeId = useAppStore((s) => s.activeId);
const streamingConversationId = useAppStore((s) =>
s.streamState.isStreaming ? s.streamState.conversationId : null,
);
const selectConversation = useAppStore((s) => s.selectConversation);
const deleteConversation = useAppStore((s) => s.deleteConversation);
@@ -43,6 +46,7 @@ export function ConversationList({ searchQuery }: Props) {
<div className="flex flex-col gap-0.5 py-1">
{filtered.map((conv) => {
const isActive = conv.id === activeId;
const isStreaming = conv.id === streamingConversationId;
return (
<div
key={conv.id}
@@ -82,11 +86,18 @@ export function ConversationList({ searchQuery }: Props) {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
disabled={isStreaming}
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
style={{ color: 'var(--color-text-tertiary)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-error)')}
onMouseEnter={(e) => {
if (!isStreaming) e.currentTarget.style.color = 'var(--color-error)';
}}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
title="Delete conversation"
title={
isStreaming
? 'Stop generating before deleting this conversation'
: 'Delete conversation'
}
>
<Trash2 size={14} />
</button>
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
class MemoryStorage {
private store = new Map<string, string>();
getItem(key: string): string | null {
return this.store.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
removeItem(key: string): void {
this.store.delete(key);
}
}
beforeEach(() => {
vi.resetModules();
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
afterEach(() => {
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
undefined;
});
async function freshStore() {
return (await import('./store')).useAppStore;
}
describe('conversation stream ownership', () => {
it('persists background stream updates without replacing the active messages', async () => {
const store = await freshStore();
const sourceId = store.getState().createConversation('test-model');
store.getState().addMessage(sourceId, {
id: 'assistant',
role: 'assistant',
content: '',
timestamp: 1,
});
const activeId = store.getState().createConversation('test-model');
store.getState().setStreamState({
conversationId: sourceId,
isStreaming: true,
content: 'streamed response',
});
store.getState().updateLastAssistant(sourceId, 'streamed response');
expect(store.getState().activeId).toBe(activeId);
expect(store.getState().messages).toEqual([]);
store.getState().selectConversation(sourceId);
expect(store.getState().messages).toHaveLength(1);
expect(store.getState().messages[0].content).toBe('streamed response');
});
it('keeps the stream-owning conversation until generation stops', async () => {
const store = await freshStore();
const sourceId = store.getState().createConversation('test-model');
const activeId = store.getState().createConversation('test-model');
store.getState().setStreamState({
conversationId: sourceId,
isStreaming: true,
});
store.getState().deleteConversation(sourceId);
expect(
store.getState().conversations.map((conversation) => conversation.id),
).toContain(sourceId);
expect(store.getState().activeId).toBe(activeId);
store.getState().resetStream();
store.getState().deleteConversation(sourceId);
expect(
store.getState().conversations.map((conversation) => conversation.id),
).not.toContain(sourceId);
});
});
+15 -7
View File
@@ -111,6 +111,7 @@ function saveSettings(settings: Settings): void {
// ── Store ─────────────────────────────────────────────────────────────
const INITIAL_STREAM: StreamState = {
conversationId: null,
isStreaming: false,
phase: '',
elapsedMs: 0,
@@ -352,6 +353,9 @@ export const useAppStore = create<AppState>((set, get) => {
},
deleteConversation: (id: string) => {
const streamState = get().streamState;
if (streamState.isStreaming && streamState.conversationId === id) return;
const store = loadConversations();
delete store.conversations[id];
if (store.activeId === id) {
@@ -394,12 +398,14 @@ export const useAppStore = create<AppState>((set, get) => {
(message.content.length > 50 ? '...' : '');
}
saveConversations(store);
set({
messages: [...conv.messages],
conversations: Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
),
});
const conversations = Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
);
if (get().activeId === conversationId) {
set({ messages: [...conv.messages], conversations });
} else {
set({ conversations });
}
},
updateLastAssistant: (
@@ -426,7 +432,9 @@ export const useAppStore = create<AppState>((set, get) => {
if (researchSources) lastMsg.researchSources = researchSources;
conv.updatedAt = Date.now();
saveConversations(store);
set({ messages: [...conv.messages] });
if (get().activeId === conversationId) {
set({ messages: [...conv.messages] });
}
}
},
+1
View File
@@ -147,6 +147,7 @@ export interface ConversationStore {
// --- Stream State ---
export interface StreamState {
conversationId: string | null;
isStreaming: boolean;
phase: string;
elapsedMs: number;
+4 -2
View File
@@ -11,7 +11,9 @@ Three install paths are supported today:
- **Editable git checkout** (``uv sync`` / ``pip install -e .`` from a
cloned repo). The package's ``__file__`` is inside a working tree
with a ``.git`` directory at the repo root. Upgrade with
``git pull && uv sync`` from the checkout.
``git pull && uv sync --inexact`` from the checkout. ``--inexact`` is
important here: a bare ``uv sync`` removes packages installed by extras or
dependency groups that are not part of the base project.
We detect by inspecting ``openjarvis.__file__``. If we can't tell with
confidence we fall back to the PyPI command — that's the most common
@@ -68,7 +70,7 @@ def detect_install() -> InstallInfo:
if (candidate / ".git").exists() and (candidate / "pyproject.toml").exists():
return InstallInfo(
kind="editable-git",
upgrade_command=f"cd {candidate} && git pull && uv sync",
upgrade_command=(f"cd {candidate} && git pull && uv sync --inexact"),
repo_root=candidate,
)
if candidate.parent == candidate:
+3 -1
View File
@@ -4,7 +4,9 @@ Runs the right upgrade command for how the user installed OpenJarvis:
- PyPI installs get ``pip install --upgrade openjarvis``.
- uv-tool installs get ``uv tool upgrade openjarvis``.
- Editable git checkouts get ``git pull && uv sync`` in the checkout.
- Editable git checkouts get ``git pull && uv sync --inexact`` in the checkout.
The inexact sync preserves packages previously installed through extras and
dependency groups.
The detection logic is shared with the post-command "new version
available" hint in ``_version_check.py`` so both surfaces stay in sync.
+1 -1
View File
@@ -35,7 +35,7 @@ def test_editable_git_install_detected(tmp_path, monkeypatch):
info = detect_install()
assert info.kind == "editable-git"
assert "git pull" in info.upgrade_command
assert "uv sync" in info.upgrade_command
assert info.upgrade_command.endswith("uv sync --inexact")
assert info.repo_root == repo
+21 -1
View File
@@ -21,7 +21,7 @@ def _mock_info(kind: str = "pypi") -> InstallInfo:
upgrade_command={
"pypi": "pip install --upgrade openjarvis",
"uv-tool": "uv tool upgrade openjarvis",
"editable-git": "cd /tmp/repo && git pull && uv sync",
"editable-git": "cd /tmp/repo && git pull && uv sync --inexact",
"unknown": "pip install --upgrade openjarvis",
}[kind],
)
@@ -90,6 +90,26 @@ def test_editable_git_uses_shell_true():
assert kwargs.get("shell") is True
def test_editable_git_preserves_extra_dependencies():
"""The update sync must not remove packages from prior extras/groups."""
mock_proc = MagicMock(returncode=0)
with (
patch(
"openjarvis.cli.self_update_cmd.detect_install",
return_value=_mock_info("editable-git"),
),
patch(
"openjarvis.cli.self_update_cmd.subprocess.run",
return_value=mock_proc,
) as mock_run,
):
result = CliRunner().invoke(self_update, ["-y"])
assert result.exit_code == 0
assert "uv sync --inexact" in result.output
assert "uv sync --inexact" in mock_run.call_args.args[0]
def test_failed_upgrade_propagates_exit_code():
mock_proc = MagicMock(returncode=3)
with (