Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ and versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- **Incognito chats:** start one from the button next to New chat, the command palette or Ctrl+Shift+N
(Cmd+Shift+N on a Mac). Nothing you say in it is remembered: Sentient saves no memories from it, learns no skills
from it, leaves it out of what it learns about you and of chat search, and deletes it when you leave it or close
Sentient (or after 30 minutes without a message). The chat has a tinted header and an Incognito label so you always
know. It can still use what Sentient already knows about you; turn that off in Settings > General > Conversations.
- **One local model job at a time, your chats first:** a local Ollama model now works on one thing at a time, so
background work no longer pushes it off the graphics card and slows your chat to a crawl. Your chat always goes
next; tasks, suggestions, memory upkeep and chat titles wait while you are chatting (and for 30 seconds after), and
Expand Down
33 changes: 31 additions & 2 deletions desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { MotionConfig } from 'motion/react'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { HashRouter, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from 'react-router'
import { toast, Toaster } from 'sonner'
import { AppShell } from '@/components/shell/AppShell'
Expand Down Expand Up @@ -30,7 +30,7 @@ import { installDemoData } from '@/lib/demo'
import { installDomainEvents } from '@/lib/events'
import { queryClient } from '@/lib/queryClient'
import { resolveTheme } from '@/lib/theme'
import { installChatEvents } from '@/stores/chat'
import { installChatEvents, isIncognito, useChat } from '@/stores/chat'
import { useConnection } from '@/stores/connection'
import { useNotificationStore } from '@/stores/notifications'
import { useUI } from '@/stores/ui'
Expand All @@ -55,6 +55,7 @@ export function App() {
<TooltipProvider delayDuration={450} skipDelayDuration={200}>
<HashRouter>
<GlobalCommands />
<IncognitoCleanup />
<Routes>
<Route element={<EngineGate />}>
<Route element={<BootstrapGate />}>
Expand Down Expand Up @@ -178,6 +179,33 @@ function BootstrapGate() {
)
}

/** Leaving an incognito chat closes it: it is deleted (#154). The engine deletes leftovers when Sentient closes. */
function IncognitoCleanup() {
const location = useLocation()
const known = useChat((s) => s.incognito)
const current = location.pathname.match(/^\/chat\/([^/?#]+)/)?.[1] ?? null
// the new incognito chat view, which moves to its chat as soon as the engine names it
const state = location.state as { incognito?: boolean } | null
const onNewIncognito = location.pathname === '/chat' && (!!state?.incognito || /(^|[?&])incognito=1\b/.test(location.search))
const previous = useRef<string | null>(null)
const visited = useRef(new Set<string>())
useEffect(() => {
const prev = previous.current
previous.current = current
if (current) visited.current.add(current)
const leaving = new Set(Object.keys(known))
if (prev && isIncognito(prev)) leaving.add(prev)
for (const id of leaving) {
if (id === current) continue
// named while its view was still open: that view is about to show it. Named after the user left: close it.
if (!visited.current.has(id) && onNewIncognito) continue
visited.current.delete(id)
useChat.getState().closeIncognito(id)
}
}, [current, known, onNewIncognito])
return null
}

function SmokeReporter() {
useSmokeReady(true)
return null
Expand Down Expand Up @@ -224,6 +252,7 @@ function GlobalCommands() {

useHotkey('mod+k', () => useUI.getState().setPaletteOpen(!useUI.getState().paletteOpen))
useHotkey('mod+n', () => navigate('/chat', { state: { fresh: Date.now() } }))
useHotkey('mod+shift+n', () => navigate('/chat', { state: { fresh: Date.now(), incognito: true } }))
useHotkey('mod+,', () => navigate('/settings'))
useHotkey('mod+b', () => useUI.getState().toggleSidebar())
return null
Expand Down
11 changes: 10 additions & 1 deletion desktop/src/components/shell/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
IconSearch,
IconSettings,
IconSparkles,
IconSpy,
IconSun,
type Icon
} from '@tabler/icons-react'
Expand Down Expand Up @@ -90,6 +91,14 @@ function PaletteBody({ close }: { close: () => void }) {
<Item icon={IconEdit} onSelect={() => go('/chat', { fresh: Date.now() })} shortcut={`${modKey}+N`}>
New chat
</Item>
<Item
icon={IconSpy}
onSelect={() => go('/chat', { fresh: Date.now(), incognito: true })}
shortcut={`${modKey}+Shift+N`}
keywords={['incognito', 'private', 'secret', 'forget']}
>
New incognito chat
</Item>
<Item icon={IconListCheck} onSelect={() => go('/tasks?compose=1')} keywords={['task', 'schedule', 'recurring', 'automation']}>
New task
</Item>
Expand Down Expand Up @@ -160,7 +169,7 @@ function PaletteBody({ close }: { close: () => void }) {

{!!sessions.data?.length && (
<Command.Group heading="Recent chats">
{sessions.data.slice(0, 30).map((s) => (
{sessions.data.filter((s) => !s.incognito).slice(0, 30).map((s) => (
<Item key={s.id} icon={IconMessageCircle} value={`chat ${s.id} ${s.title ?? 'New chat'}`} onSelect={() => go(`/chat/${s.id}`)}>
{s.title?.trim() || 'New chat'}
</Item>
Expand Down
41 changes: 27 additions & 14 deletions desktop/src/components/shell/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
IconSearch,
IconSettings,
IconSparkles,
IconSpy,
IconTrash,
IconUserHeart,
IconX,
Expand Down Expand Up @@ -88,18 +89,29 @@ export function Sidebar() {
className="mb-1.5"
/>
) : (
<button
type="button"
onClick={() => navigate('/chat', { state: { fresh: Date.now() } })}
className="group mb-1.5 flex h-8.5 items-center gap-2 rounded-lg border border-border-strong bg-surface px-2.5 text-sm font-medium text-fg shadow-soft transition-colors hover:bg-elevated"
>
<IconEdit size={16} className="text-accent-text" />
<span className="flex-1 text-left">New chat</span>
<span className="flex gap-0.5 opacity-70 transition-opacity group-hover:opacity-100">
<Kbd>{modKey}</Kbd>
<Kbd>N</Kbd>
</span>
</button>
<div className="mb-1.5 flex items-center gap-1.5">
<button
type="button"
onClick={() => navigate('/chat', { state: { fresh: Date.now() } })}
className="group flex h-8.5 min-w-0 flex-1 items-center gap-2 rounded-lg border border-border-strong bg-surface px-2.5 text-sm font-medium text-fg shadow-soft transition-colors hover:bg-elevated"
>
<IconEdit size={16} className="text-accent-text" />
<span className="flex-1 text-left">New chat</span>
<span className="flex gap-0.5 opacity-70 transition-opacity group-hover:opacity-100">
<Kbd>{modKey}</Kbd>
<Kbd>N</Kbd>
</span>
</button>
<IconButton
label="New incognito chat"
shortcut={`${modKey}+Shift+N`}
variant="secondary"
size="md"
icon={<IconSpy size={16} className="text-incognito" />}
onClick={() => navigate('/chat', { state: { fresh: Date.now(), incognito: true } })}
className="size-8.5"
/>
</div>
)}
{NAV.map((item) => (
<NavItem key={item.to} {...item} collapsed={collapsed} active={location.pathname.startsWith(item.match)} />
Expand Down Expand Up @@ -149,9 +161,10 @@ function ChatList() {

const filtered = useMemo(() => {
if (!sessions) return []
if (!q) return sessions
const kept = sessions.filter((s) => !s.incognito) // incognito chats never show up in your history (#154)
if (!q) return kept
const hitIds = new Set((search.data ?? []).map((h) => h.session_id))
return sessions.filter((s) => (s.title ?? 'New chat').toLowerCase().includes(q) || hitIds.has(s.id))
return kept.filter((s) => (s.title ?? 'New chat').toLowerCase().includes(q) || hitIds.has(s.id))
}, [sessions, q, search.data])

const groups = useMemo(() => {
Expand Down
45 changes: 35 additions & 10 deletions desktop/src/features/chat/ChatPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconArrowDown, IconFileUpload, IconMessageOff, IconWorldWww } from '@tabler/icons-react'
import { IconArrowDown, IconFileUpload, IconMessageOff, IconSpy, IconWorldWww } from '@tabler/icons-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router'
Expand All @@ -16,32 +16,38 @@ import { Composer } from './Composer'
import { RuleProposalCard } from './RuleProposalCard'
import { takeScreenShare } from './screenShare'
import { EmptyChat, type Suggestion } from './EmptyChat'
import { IncognitoBadge, IncognitoIntro } from './Incognito'
import { useAttachments } from './useAttachments'
import { UserMessage } from './UserMessage'

export function ChatPage() {
const { sessionId } = useParams()
const location = useLocation()
// `pendingKey`: a message already sent from outside the chat view (push to talk) whose new chat this view shows.
const state = location.state as { fresh?: number; prompt?: { text: string; autoSend?: boolean }; pendingKey?: string } | null
// `incognito` (or `#/chat?incognito=1`): a new incognito chat (#154).
const state = location.state as { fresh?: number; prompt?: { text: string; autoSend?: boolean }; pendingKey?: string; incognito?: boolean } | null
const newIncognito = !sessionId && (!!state?.incognito || /(^|[?&])incognito=1\b/.test(location.search))
return (
<ChatView
key={sessionId ?? `new-${state?.fresh ?? ''}`}
key={sessionId ?? `new-${state?.fresh ?? ''}${newIncognito ? '-incognito' : ''}`}
sessionId={sessionId}
initialPrompt={sessionId ? undefined : state?.prompt}
initialPendingKey={sessionId ? undefined : state?.pendingKey}
newIncognito={newIncognito}
/>
)
}

function ChatView({
sessionId,
initialPrompt,
initialPendingKey
initialPendingKey,
newIncognito = false
}: {
sessionId?: string
initialPrompt?: { text: string; autoSend?: boolean }
initialPendingKey?: string
newIncognito?: boolean
}) {
const navigate = useNavigate()
const [pendingKey, setPendingKey] = useState<string | null>(initialPendingKey ?? null)
Expand Down Expand Up @@ -69,6 +75,9 @@ function ChatView({
const assistantName = bootstrap.data?.assistant.name ?? 'Sentient'
const showThinking = config.data?.chat.show_thinking ?? true
const session = sessions.data?.find((s) => s.id === sessionId)
const knownIncognito = useChat((s) => (sessionId ? !!s.incognito[sessionId] : false))
const incognito = newIncognito || knownIncognito || !!session?.incognito || !!liveSession?.request.incognito
const usesMemory = config.data?.chat.incognito_uses_memory ?? true

// A brand-new chat navigates to its session once the engine assigns an id.
useEffect(() => {
Expand All @@ -86,10 +95,10 @@ function ChatView({

const onSend = useCallback(
async (text: string, files: AttachmentView[], model: string | undefined) => {
const key = await send(sessionId ?? null, { text, attachments: files, model })
const key = await send(sessionId ?? null, { text, attachments: files, model, incognito: !sessionId && newIncognito })
if (!sessionId) setPendingKey(key)
},
[send, sessionId]
[send, sessionId, newIncognito]
)

const onPick = (s: Pick<Suggestion, 'prompt' | 'autoSend'>) => {
Expand Down Expand Up @@ -151,11 +160,17 @@ function ChatView({
}}
>
{!empty && (
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-border px-6">
<div
className={cn(
'flex h-12 shrink-0 items-center gap-3 border-b px-6',
incognito ? 'border-incognito/25 bg-incognito/8' : 'border-border'
)}
>
{incognito && <IconSpy size={17} className="shrink-0 text-incognito" aria-hidden />}
<h2 className="min-w-0 truncate text-sm font-medium text-fg">
{session?.title?.trim() || (streaming ? 'New chat' : 'Chat')}
{session?.title?.trim() || (incognito ? 'Incognito chat' : streaming ? 'New chat' : 'Chat')}
</h2>
<ChannelBadge channel={session?.channel} withLabel />
{incognito ? <IncognitoBadge /> : <ChannelBadge channel={session?.channel} withLabel />}
<span className="flex-1" />
{streaming && <span className="text-xs text-fg-subtle">{assistantName} is replying…</span>}
<HelpersTray sessionId={sessionId} />
Expand All @@ -166,7 +181,11 @@ function ChatView({
{empty ? (
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 py-10">
<div className="my-auto w-full max-w-[720px]">
<EmptyChat userName={bootstrap.data?.assistant.user_name} onPick={onPick} />
{newIncognito ? (
<IncognitoIntro usesMemory={usesMemory} />
) : (
<EmptyChat userName={bootstrap.data?.assistant.user_name} onPick={onPick} />
)}
<div className="mt-8">
<Composer
draftKey="new"
Expand Down Expand Up @@ -220,6 +239,12 @@ function ChatView({
</Timeline>
<div className="shrink-0 px-6 pb-4 pt-2">
<div className="mx-auto w-full max-w-[760px]">
{incognito && (
<p className="mb-2 flex items-center justify-center gap-1.5 text-xs text-fg-subtle">
<IconSpy size={13} className="text-incognito" aria-hidden />
Incognito: nothing here is remembered, and the chat is deleted when you leave it.
</p>
)}
<Composer
draftKey={sessionId ?? 'pending'}
assistantName={assistantName}
Expand Down
57 changes: 57 additions & 0 deletions desktop/src/features/chat/Incognito.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { IconBrain, IconDatabaseOff, IconSpy, IconTrash } from '@tabler/icons-react'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'

/** What an incognito chat does, in plain words (#154). `usesMemory`: chat.incognito_uses_memory. */
export function incognitoLines(usesMemory: boolean) {
return [
{ icon: IconDatabaseOff, text: 'Nothing you say here is remembered, and Sentient doesn’t learn from it.' },
{
icon: IconBrain,
text: usesMemory
? 'Sentient can still use what it already knows about you.'
: 'Sentient won’t use what it already knows about you either.'
},
{ icon: IconTrash, text: 'The chat is deleted when you leave it or close Sentient.' }
]
}

/** The start screen of a new incognito chat. */
export function IncognitoIntro({ usesMemory }: { usesMemory: boolean }) {
return (
<div className="flex flex-col items-center text-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.35 }}
className="flex size-14 items-center justify-center rounded-2xl border border-incognito/30 bg-incognito/12 text-incognito"
>
<IconSpy size={30} stroke={1.6} />
</motion.div>
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-fg">Incognito chat</h1>
<ul className="mt-5 w-full max-w-[460px] space-y-2.5 text-left">
{incognitoLines(usesMemory).map(({ icon: Icon, text }) => (
<li key={text} className="flex items-start gap-3 rounded-xl border border-border bg-surface px-3.5 py-2.5 text-sm text-fg-muted">
<Icon size={17} className="mt-0.5 shrink-0 text-incognito" />
<span>{text}</span>
</li>
))}
</ul>
</div>
)
}

/** The tinted label in an incognito chat's header. */
export function IncognitoBadge({ className }: { className?: string }) {
return (
<span
className={cn(
'inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-incognito/30 bg-incognito/12 px-2 text-xs font-medium text-incognito',
className
)}
>
<IconSpy size={14} />
Incognito
</span>
)
}
2 changes: 1 addition & 1 deletion desktop/src/features/settings/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const SETTINGS_SECTIONS: SettingsSectionMeta[] = [
label: 'General',
description: 'Names, timezone, language, appearance, startup and shortcuts.',
icon: IconSettings,
keywords: ['name', 'timezone', 'location', 'language', 'theme', 'dark', 'light', 'accent', 'color', 'login', 'startup', 'tray', 'shortcut', 'hotkey', 'keyboard', 'screen', 'share', 'screenshot', 'window', 'region']
keywords: ['name', 'timezone', 'location', 'language', 'theme', 'dark', 'light', 'accent', 'color', 'login', 'startup', 'tray', 'shortcut', 'hotkey', 'keyboard', 'screen', 'share', 'screenshot', 'window', 'region', 'incognito', 'private']
},
{
id: 'models',
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,8 @@ export const api = {

sessions: {
list: (limit = 100) => http.get<Session[]>('/api/sessions', { query: { limit } }),
create: () => http.post<{ session_id: string }>('/api/sessions'),
create: (opts: { incognito?: boolean } = {}) =>
http.post<{ session_id: string }>('/api/sessions', undefined, { query: opts.incognito ? { incognito: true } : undefined }),
rename: (id: string, title: string) => http.patch<OkResponse>(`/api/sessions/${enc(id)}`, { title }),
delete: (id: string) => http.delete<OkResponse>(`/api/sessions/${enc(id)}`),
messages: (id: string, limit = 500) =>
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* memory.updated -> invalidate ['memories']
* skill.updated -> invalidate ['skills']
* session.updated -> rename in ['sessions']
* session.deleted -> remove from ['sessions'] and refetch its transcript (an incognito chat that was closed)
* rule_proposal.updated -> refetch that chat's ['rule-proposals'] ("Make this a rule?" cards)
* config.updated -> invalidate config/bootstrap/model presets/ChatGPT sign-in (+ secrets/providers)
* voice.state -> ['voice', 'state']
Expand Down Expand Up @@ -182,6 +183,14 @@ export function installDomainEvents(qc: QueryClient): () => void {
if (!found) void qc.invalidateQueries({ queryKey: qk.sessions })
})
)
offs.push(
live.onDomain('session.deleted', (e) => {
const { session_id } = e.data
qc.setQueryData<Session[]>(qk.sessions, (old) => old?.filter((s) => s.id !== session_id))
// an open chat that was deleted (an incognito chat left idle) shows "This chat doesn't exist anymore"
void qc.invalidateQueries({ queryKey: qk.messages(session_id) })
})
)

offs.push(
live.onDomain('rule_proposal.updated', (e) => {
Expand Down
Loading
Loading