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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ and versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- **Folders and chat apps follow your memory places too.** In Settings, Folders, each folder or Obsidian vault now has
a **Used in** choice: every chat (as before, and what your folders already have), Personal, Work or a project. A
folder kept for Work only comes up in work chats, so a personal chat never brings up your work notes unless you ask.
In Telegram, Discord and WhatsApp, send `/scope work`, `/scope personal` or `/scope` followed by a project's name to
pick which memories that chat uses, or `/scope` to see which it uses now. On the Memory page, where a memory is
kept now reads "Used in Work" next to its source, so it no longer looks like a topic such as "Work & Learning".
- **Say "stop" or "snooze" to a ringing alarm.** While a Sentient alarm rings, typing or saying "stop", "stop the
alarm", "turn it off", "snooze" or "snooze for 5 minutes" in a chat, by voice or in Telegram, Discord or WhatsApp
stops or snoozes it at once, with no model ("Alarm stopped.", "Snoozed until 7:39 AM."). A snooze is 9 minutes
Expand Down
52 changes: 40 additions & 12 deletions desktop/src/features/chat/ChatScopeMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconBriefcase, IconCheck, IconFolder, IconHome } from '@tabler/icons-react'
import { IconBriefcase, IconCheck, IconFolder, IconHome, IconInfinity } from '@tabler/icons-react'
import { useState, type ReactNode } from 'react'
import { Input, Popover, PopoverContent, PopoverTrigger, Tooltip } from '@/components/ui'
import { ScopeIcon, scopeLabel } from '@/features/memory/bits'
Expand All @@ -8,45 +8,73 @@ import { cn } from '@/lib/utils'
/**
* Which memories this chat uses (#155): Personal, Work or a project. Recall reads that scope plus the memories kept
* for every chat, and new memories from the chat are saved there.
*
* `folder` (#392): the same picker for an added folder in Settings > Folders, with Every chat first (its default) and
* no counts, since a folder's notes aren't memories.
*/
export function ChatScopeMenu({ value, onChange, className }: { value: string | null | undefined; onChange: (v: string) => void; className?: string }) {
export function ChatScopeMenu({
value,
onChange,
className,
folder
}: {
value: string | null | undefined
onChange: (v: string) => void
className?: string
/** The folder's name: this picks where its notes are used. */
folder?: string
}) {
const [open, setOpen] = useState(false)
const [custom, setCustom] = useState('')
const scopes = useMemoryScopes()
const current = value || 'personal'
const fallback = folder ? 'always' : 'personal'
const current = value || fallback
const projects = (scopes.data ?? []).filter((s) => !s.built_in).map((s) => s.name)
if (!['personal', 'work', ...projects].includes(current)) projects.unshift(current)
const count = (name: string) => scopes.data?.find((s) => s.name === name)?.count
if (!['always', 'personal', 'work', ...projects].includes(current)) projects.unshift(current)
const count = (name: string) => (folder ? undefined : scopes.data?.find((s) => s.name === name)?.count)

const pick = (v: string) => {
if (v !== current) onChange(v)
setOpen(false)
setCustom('')
}
const tooltip = folder
? current === 'always'
? `Notes in ${folder} are used in every chat`
: `Notes in ${folder} are used in ${scopeLabel(current)} chats`
: `This chat uses your ${scopeLabel(current)} memories`

return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip content={`This chat uses your ${scopeLabel(current)} memories`}>
<Tooltip content={tooltip}>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Memories for this chat"
data-chat-scope={current}
aria-label={folder ? `Where notes in ${folder} are used` : 'Memories for this chat'}
data-chat-scope={folder ? undefined : current}
data-folder-scope={folder ? current : undefined}
className={cn(
'flex h-8 max-w-48 shrink-0 items-center gap-1.5 rounded-lg px-2 text-xs font-medium transition-colors',
current === 'personal' ? 'text-fg-subtle hover:bg-hover hover:text-fg' : 'bg-accent/12 text-accent-text hover:bg-accent/18',
current === fallback ? 'text-fg-subtle hover:bg-hover hover:text-fg' : 'bg-accent/12 text-accent-text hover:bg-accent/18',
className
)}
>
<ScopeIcon scope={current} />
<span className="truncate">{scopeLabel(current)}</span>
<span className="truncate">{folder ? `Used in ${current === 'always' ? 'every chat' : scopeLabel(current)}` : scopeLabel(current)}</span>
</button>
</PopoverTrigger>
</Tooltip>
<PopoverContent align="end" className="w-72 p-1.5">
<PopoverContent align={folder ? 'start' : 'end'} className="w-72 p-1.5">
<div className="px-2.5 pb-1.5 pt-1 text-xs text-fg-subtle">
Which memories this chat uses. New ones it learns are kept there too. Memories set to every chat are always used.
{folder
? 'Which chats use the notes in this folder. A Work folder only comes up in work chats, unless you ask about it.'
: 'Which memories this chat uses. New ones it learns are kept there too. Memories set to every chat are always used.'}
</div>
{folder && (
<Option active={current === 'always'} onClick={() => pick('always')} icon={<IconInfinity size={15} />}>
Every chat
</Option>
)}
<Option active={current === 'personal'} onClick={() => pick('personal')} icon={<IconHome size={15} />} hint={count('personal')}>
Personal
</Option>
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/memory/MemoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ function MemoryCard({ memory, selected, onSelect, now, compact }: { memory: Memo
<TopicPill key={t} topic={t} size="xs" />
))}
<ExpiryBadge memory={memory} now={now} className="h-4.5" />
<ScopeBadge scope={memory.scope} />
{typeof memory.similarity === 'number' && (
<span className="rounded-full bg-accent/12 px-1.5 text-2xs font-medium text-accent-text">{Math.round(memory.similarity * 100)}% match</span>
)}
Expand All @@ -43,6 +42,7 @@ function MemoryCard({ memory, selected, onSelect, now, compact }: { memory: Memo
{memory.updated_at && memory.created_at && memory.updated_at > memory.created_at && (
<span className="shrink-0 text-fg-faint">· edited {relativeTime(memory.updated_at)}</span>
)}
<ScopeBadge scope={memory.scope} className="ml-auto" />
</div>
</motion.button>
)
Expand Down
20 changes: 15 additions & 5 deletions desktop/src/features/memory/bits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,26 @@ export function ScopeIcon({ scope, size = 15, className }: { scope: string | nul
return <IconFolder size={size} className={className} />
}

/** Where a memory is kept, shown unless it is the default (Personal). */
/**
* Where a memory is kept, shown unless it is the default (Personal). It reads "Used in Work" in a dashed, square
* outline next to the source, so it never looks like a topic pill such as "Work & Learning" (#392).
*/
export function ScopeBadge({ scope, className }: { scope: string | null | undefined; className?: string }) {
if (!scope || scope === 'personal') return null
const where = scope === 'always' ? 'every chat' : scopeLabel(scope)
return (
<span
title={scope === 'always' ? 'Used in every chat' : `Used in ${scopeLabel(scope)} chats`}
className={cn('inline-flex h-4.5 max-w-36 shrink-0 items-center gap-1 rounded-full border border-border bg-active px-1.5 text-2xs font-medium text-fg-muted', className)}
title={scope === 'always' ? 'Every chat uses this memory' : `Only ${scopeLabel(scope)} chats use this memory`}
data-scope-badge={scope}
className={cn(
'inline-flex h-5 min-w-0 max-w-44 shrink items-center gap-1 rounded-md border border-dashed border-border-strong px-1.5 text-2xs text-fg-subtle',
className
)}
>
<ScopeIcon scope={scope} size={10} className="shrink-0" />
<span className="truncate">{scopeLabel(scope)}</span>
<ScopeIcon scope={scope} size={11} className="shrink-0" />
<span className="truncate">
Used in <span className="font-medium text-fg-muted">{where}</span>
</span>
</span>
)
}
17 changes: 16 additions & 1 deletion desktop/src/features/settings/sections/Folders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
import { useState } from 'react'
import { toast } from 'sonner'
import { Alert, Badge, Button, ConfirmDialog, EmptyState, FormSection, Input, ProgressBar, Skeleton } from '@/components/ui'
import { useAddFolder, useFolderIgnore, useFolders, useRemoveFolder, useRescanFolder } from '@/hooks/folders'
import { ChatScopeMenu } from '@/features/chat/ChatScopeMenu'
import { scopeLabel } from '@/features/memory/bits'
import { useAddFolder, useFolderIgnore, useFolders, useFolderScope, useRemoveFolder, useRescanFolder } from '@/hooks/folders'
import { errorMessage } from '@/lib/api'
import { getBridge } from '@/lib/bridge'
import type { WatchedFolder } from '@/lib/types'
Expand Down Expand Up @@ -114,6 +116,7 @@ function FolderCard({ folder: f }: { folder: WatchedFolder }) {
const remove = useRemoveFolder()
const rescan = useRescanFolder()
const setIgnore = useFolderIgnore()
const setScope = useFolderScope()
const [confirm, setConfirm] = useState(false)
const [ignore, setIgnoreText] = useState(f.ignore.join(', '))
const progress = f.progress && f.progress.total > 0 ? f.progress.done / f.progress.total : null
Expand All @@ -136,6 +139,17 @@ function FolderCard({ folder: f }: { folder: WatchedFolder }) {
}
)
}
const changeScope = (scope: string) =>
setScope.mutate(
{ id: f.id, scope },
{
onSuccess: () =>
toast.success(scope === 'always' ? `${f.name} is used in every chat` : `${f.name} is used in ${scopeLabel(scope)} chats`, {
description: scope === 'always' ? undefined : 'Other chats leave its notes out, unless you ask about them.'
}),
onError: (err) => toast.error("That didn't save", { description: errorMessage(err) })
}
)

return (
<div className="rounded-xl border border-border bg-surface p-3.5" data-folder={f.name}>
Expand All @@ -161,6 +175,7 @@ function FolderCard({ folder: f }: { folder: WatchedFolder }) {
{f.status === 'scanning' && <ProgressBar className="mt-2" value={progress} />}
{f.error && <p className="mt-1.5 text-xs text-warning">{f.error}</p>}
<div className="mt-2.5 flex items-center gap-2">
{f.scope !== undefined && <ChatScopeMenu folder={f.name} value={f.scope} onChange={changeScope} className="border border-border" />}
<Input
size="sm"
value={ignore}
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/hooks/folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,16 @@ function useFolderMutation<A>(fn: (arg: A) => Promise<unknown>) {
export const useAddFolder = () => useFolderMutation((path: string) => api.folders.add(path))
export const useRemoveFolder = () => useFolderMutation((id: string) => api.folders.remove(id))
export const useRescanFolder = () => useFolderMutation((id: string) => api.folders.rescan(id))
/** Where a folder's notes are used (#392). A new project name also shows up in the memory scope pickers. */
export function useFolderScope() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, scope }: { id: string; scope: string }) => api.folders.setScope(id, scope),
onSettled: () => {
void qc.invalidateQueries({ queryKey: qk.folders })
void qc.invalidateQueries({ queryKey: qk.memories.scopes })
}
})
}
export const useFolderIgnore = () =>
useFolderMutation(({ id, ignore }: { id: string; ignore: string[] }) => api.folders.update(id, ignore))
1 change: 1 addition & 0 deletions desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,7 @@ export const api = {
list: () => http.get<FoldersOverview>('/api/folders'),
add: (path: string, ignore?: string[]) => http.post<WatchedFolder>('/api/folders', { path, ignore }),
update: (id: string, ignore: string[]) => http.patch<WatchedFolder>(`/api/folders/${id}`, { ignore }),
setScope: (id: string, scope: string) => http.patch<WatchedFolder>(`/api/folders/${id}`, { scope }),
remove: (id: string) => http.delete<{ removed: boolean }>(`/api/folders/${id}`),
rescan: (id: string) => http.post<{ queued: boolean }>(`/api/folders/${id}/rescan`, {})
},
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2936,6 +2936,9 @@ export interface WatchedFolder {
kind: 'folder' | 'obsidian'
/** This folder's own ignore patterns (Settings > Folders adds them to `folders.ignore`). */
ignore: string[]
/** The chats its notes are used in (#392): `always` (every chat, the default), `personal`, `work` or a project.
* Missing on older engines. */
scope?: string
status: FolderStatus
/** While a look through it runs: new or changed files read so far. */
progress: { done: number; total: number } | null
Expand Down
Loading
Loading