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

### Added
- **Sentient in group chats, safely.** You can now pair a Telegram group or a Discord server channel with the same
`/pair` code. Sentient answers only you there, when you mention it, reply to it or send a command. What other people
write is treated like an email from a stranger: Sentient can read it, but never takes orders from it, and after
someone else writes, anything it would send checks with you first, naming who wrote it ("That address came from
another group member (Ravi), not from you."). Only you can press its buttons, and task results stay out of a group
unless you turn them on.
- **Trust a chat again after you've checked it.** A chat that read an email, a web page or a group member's message
shows a bar saying so above the message box. If you've looked at it and it's fine, click **I've checked it** (or
send `/trust`, then `/trust yes`, in Telegram, Discord or WhatsApp) and sending from that chat follows your usual approval settings
again. It covers only what you've seen: if someone writes while you decide, the chat keeps asking and says who
wrote. It's noted in the chat and in Activity, and anything new from outside makes it ask again.
- **Keep work, personal and project memories apart.** Each memory is now kept for Personal, Work, a project you name,
or every chat (for things like your name or how you like answers). Pick a chat's memories from the button at the
top of the chat, or before you start one. A work chat then uses your work memories and the ones for every chat,
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/features/channels/MessagingApps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ function PairedRow({ channel, chat }: { channel: Channel; chat: PairedChat }) {
<div className="flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-fg">{chat.label}</div>
<div className="text-xs text-fg-subtle">Paired {relativeTime(chat.paired_at)}</div>
<div className="text-xs text-fg-subtle">
{chat.is_group ? 'Group: only you can talk to Sentient here. ' : ''}Paired {relativeTime(chat.paired_at)}
</div>
</div>
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg-muted" title="Task results, plans waiting for approval and suggestions are also sent here">
Send updates here
Expand Down
10 changes: 9 additions & 1 deletion desktop/src/features/chat/ChatPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IconArrowDown, IconFileUpload, IconMessageOff, IconSpy, IconWorldWww } from '@tabler/icons-react'
import { ChatNote, OtherMessage, OutsideContentBar } from './OutsideContent'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router'
Expand Down Expand Up @@ -309,7 +310,11 @@ function ChatView({
<>
<Timeline items={items} streaming={streaming} loading={loadingHistory}>
{items.map((item, i) =>
item.kind === 'user' ? (
item.kind === 'other' ? (
<OtherMessage key={item.id} message={item} />
) : item.kind === 'note' ? (
<ChatNote key={item.id} note={item} />
) : item.kind === 'user' ? (
<UserMessage key={item.id} message={item} onRestore={(text) => setInject({ text, nonce: Date.now() })} />
) : (
<AssistantTurn
Expand All @@ -331,6 +336,9 @@ function ChatView({
</Timeline>
<div className="shrink-0 px-6 pb-4 pt-2">
<div className="mx-auto w-full max-w-[760px]">
{sessionId && session?.untrusted ? (
<OutsideContentBar sessionId={sessionId} source={session.untrusted} lastSeenId={messages.data?.[messages.data.length - 1]?.id ?? null} />
) : null}
{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 />
Expand Down
96 changes: 96 additions & 0 deletions desktop/src/features/chat/OutsideContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Outside content in a chat (ADR 0018, 0029): the bar that says why sending asks first and lets the user trust the chat
* again after checking it, messages other people wrote in a paired group chat, and the line that logs a "trust again".
*/
import { IconShieldCheck, IconShieldExclamation, IconUsers } from '@tabler/icons-react'
import { memo, useState } from 'react'
import { toast } from 'sonner'
import { Button, ConfirmDialog } from '@/components/ui'
import { qk } from '@/hooks/queryKeys'
import { api, errorMessage } from '@/lib/api'
import type { ChatNoteView, OtherMessageView } from '@/lib/chatFold'
import { queryClient } from '@/lib/queryClient'
import type { Session } from '@/lib/types'

/**
* Shown above the message box while the chat carries the mark. Trusting covers what the window has shown
* (`lastSeenId`): a newer message from someone else keeps the chat marked, and the bar then names its writer.
*/
export function OutsideContentBar({ sessionId, source, lastSeenId }: { sessionId: string; source: string; lastSeenId: string | null }) {
const [open, setOpen] = useState(false)
return (
<>
<div className="mb-2 flex items-start gap-2.5 rounded-xl border border-warning/30 bg-warning/8 px-3.5 py-2.5 text-xs text-fg-muted">
<IconShieldExclamation size={16} className="mt-px shrink-0 text-warning" aria-hidden />
<p className="min-w-0 flex-1 leading-relaxed">
This chat read content from <span className="font-medium text-fg">{source}</span>. Someone else wrote it, so
Sentient checks with you before sending anything from this chat.
</p>
<Button size="xs" variant="secondary" className="shrink-0" onClick={() => setOpen(true)}>
I've checked it
</Button>
</div>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
tone="primary"
title="Trust this chat again?"
description={
<span className="space-y-2">
<span className="block">
Do this only if you've looked at what this chat read from {source} and you're sure it isn't trying to steer
Sentient, for example by asking it to send your data somewhere.
</span>
<span className="block">
Sending from this chat will then follow your usual approval settings. Your Ask and Never rules and the extra
check on recipients and amounts still apply, and new outside content makes it ask again. This is listed in
Activity.
</span>
</span>
}
confirmLabel="Trust this chat"
onConfirm={async () => {
try {
const res = await api.sessions.trust(sessionId, lastSeenId)
const mark = res.trusted ? '' : res.untrusted || source
queryClient.setQueryData<Session[]>(qk.sessions, (old) => old?.map((s) => (s.id === sessionId ? { ...s, untrusted: mark } : s)))
void queryClient.invalidateQueries({ queryKey: qk.messages(sessionId) })
if (res.trusted) toast.success('This chat is trusted again')
else
toast.warning(res.writer ? `${res.writer} wrote something new` : 'This chat read something new', {
description: 'It came in after what you checked, so Sentient still checks with you first. Have a look, then try again.'
})
} catch (e) {
toast.error("Couldn't change that", { description: errorMessage(e) })
}
}}
/>
</>
)
}

/** A message another member of a paired group wrote. Sentient reads it, never takes orders from it. */
export const OtherMessage = memo(function OtherMessage({ message }: { message: OtherMessageView }) {
return (
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-1.5 text-2xs text-fg-subtle">
<IconUsers size={12} aria-hidden />
<span className="font-medium text-fg-muted">{message.sender}</span>
<span>in the group, outside content</span>
</div>
<div className="selectable max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-bl-md border border-dashed border-border-strong bg-surface px-4 py-2.5 text-md leading-relaxed text-fg-muted">
{message.text}
</div>
</div>
)
})

/** A quiet line in the chat for something the user did to it. */
export function ChatNote({ note }: { note: ChatNoteView }) {
return (
<div className="flex items-center justify-center gap-1.5 px-6 text-center text-xs text-fg-subtle">
<IconShieldCheck size={14} className="shrink-0 text-success" aria-hidden />
<span>{note.text}</span>
</div>
)
}
9 changes: 9 additions & 0 deletions desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,15 @@ export const api = {
setScope: (id: string, memory_scope: string | null) =>
http.patch<OkResponse & { memory_scope: string }>(`/api/sessions/${enc(id)}`, { memory_scope }),
delete: (id: string) => http.delete<OkResponse>(`/api/sessions/${enc(id)}`),
/**
* "I've checked it, trust this chat again" (ADR 0029), up to `upto`, the newest message the window has shown.
* `trusted: false` with `untrusted`/`writer` when outside content newer than that keeps the chat marked.
*/
trust: (id: string, upto?: string | null) =>
http.post<OkResponse & { was: string; trusted: boolean; untrusted: string; writer: string }>(
`/api/sessions/${enc(id)}/trust`,
{ upto: upto ?? null }
),
messages: (id: string, limit = 500) =>
http.get<TranscriptMessage[]>(`/api/sessions/${enc(id)}/messages`, { query: { limit } }),
search: (q: string) => http.get<MessageSearchHit[]>('/api/sessions/search', { query: { q } })
Expand Down
36 changes: 35 additions & 1 deletion desktop/src/lib/chatFold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,32 @@ export interface UserMessageView {
notSent?: boolean
}

export type TimelineItem = UserMessageView | AssistantTurnView
/** A message someone else wrote in a paired group chat (ADR 0029): outside content, never the user's words. */
export interface OtherMessageView {
kind: 'other'
id: string
sender: string
text: string
createdAt: string
}

/** Something the user did to the chat, shown as a quiet line: trusting it again after checking it (ADR 0029). */
export interface ChatNoteView {
kind: 'note'
id: string
text: string
createdAt: string
}

export type TimelineItem = UserMessageView | AssistantTurnView | OtherMessageView | ChatNoteView

/** The line a `trusted` event row shows in the chat. */
export function trustedNote(content: string | null): string {
const data = safeJsonParse<{ was?: unknown; via?: unknown }>(content ?? '', {})
const was = typeof data.was === 'string' && data.was.trim() ? data.was.trim() : ''
const via = typeof data.via === 'string' && data.via !== 'desktop' ? ` with /trust on ${data.via[0].toUpperCase()}${data.via.slice(1)}` : ''
return `You trusted this chat again${via}${was ? ` after checking what it read from ${was}` : ''}. Sending from it follows your usual approval settings until new outside content arrives.`
}

export function emptyTurn(id: string): AssistantTurnView {
return { kind: 'assistant', id, segments: [], tools: {}, approvals: [], status: 'streaming', createdAt: new Date().toISOString(), live: true }
Expand Down Expand Up @@ -188,6 +213,15 @@ export function foldTranscript(rows: TranscriptMessage[]): TimelineItem[] {
continue
}
prevRole = row.role
if (row.role === 'other') {
turn = null
items.push({ kind: 'other', id: row.id, sender: row.name?.trim() || 'Someone', text: row.content ?? '', createdAt: row.created_at })
continue
}
if (row.role === 'event') {
if (row.name === 'trusted') items.push({ kind: 'note', id: row.id, text: trustedNote(row.content), createdAt: row.created_at })
continue
}
if (row.role === 'user') {
turn = null
items.push({
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* memory.updated -> invalidate ['memories']
* skill.updated -> invalidate ['skills']
* mcp.review -> invalidate ['integrations-mcp'] (an MCP server's tools wait for review, #146)
* session.updated -> rename or set the chat's model in ['sessions']
* session.updated -> rename, set the chat's model or its outside-content mark 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)
* approval_grants.updated -> refetch ['approval-grants'] (Settings > Approvals, #139)
Expand Down Expand Up @@ -199,6 +199,8 @@ export function installDomainEvents(qc: QueryClient): () => void {
})
)
if (!found) void qc.invalidateQueries({ queryKey: qk.sessions })
// a group member wrote, or the user trusted the chat again: their rows are in the transcript now (ADR 0029)
if ('untrusted' in changed) void qc.invalidateQueries({ queryKey: qk.messages(session_id) })
})
)
offs.push(
Expand Down
11 changes: 9 additions & 2 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ export interface Session {
model?: string | null
/** The memories this chat uses and saves to (#155): "personal", "work" or a project's name. */
memory_scope?: string
/** Who or what wrote outside content this chat read ("Gmail"), "" when clean, null before its first turn (ADR 0018). */
untrusted?: string | null
[key: string]: unknown
}

Expand All @@ -421,7 +423,9 @@ export interface ToolCallRef {
function: { name: string; arguments: string }
}

export type MessageRole = 'user' | 'assistant' | 'tool' | 'system'
/** `other`: a message someone else wrote in a paired group chat (`name` = who); `event`: something the user did to
* the chat, e.g. trusting it again (`name: "trusted"`, content JSON `{event, was, via}`), ADR 0029. */
export type MessageRole = 'user' | 'assistant' | 'tool' | 'system' | 'other' | 'event'

/** Raw transcript row (`GET /api/sessions/{id}/messages`). */
export interface TranscriptMessage {
Expand Down Expand Up @@ -1110,7 +1114,7 @@ export interface DomainEventMap {
/** A server's tools wait for review, or the user answered (#146). */
'mcp.review': { server: string; reason?: 'new' | 'changed'; tools?: string[]; needed: boolean }
/** Only the fields that changed are sent: a rename (`title`) or the chat's own model (`model`, null = default). */
'session.updated': { session_id: string; title?: string; model?: string | null }
'session.updated': { session_id: string; title?: string; model?: string | null; untrusted?: string }
/** A chat was deleted: by the user, or an incognito chat that was closed or left idle (#154). */
'session.deleted': { session_id: string; reason?: 'incognito' }
'rule_proposal.updated': RuleProposal
Expand Down Expand Up @@ -1398,6 +1402,9 @@ export interface PairedChat {
paired_at: ISODate
deliver: boolean
session_id: string | null
/** A group chat: only its owner (who paired it) talks to Sentient there (ADR 0029). */
is_group?: boolean
owner_id?: string | null
}

export interface Channel {
Expand Down
Loading
Loading