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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ This directory (`~/shellb/web`) houses the web frontend, agent runtime server, d
- No service restart required for static UI changes, but reload browser.

3. **Rules for Agents**:
- **Shipping a user-visible feature? Add an entry (newest first) to `docs/changelog.json`** — it feeds Settings → Changelog.
- **Shipping a user-visible feature? Add an entry (newest first) to `docs/changelog.json`** — it feeds Settings → Changelog. If everyone (a light user included) would notice and care, add a `news` line too: one plain sentence, shown once on the What's new card, with its French in `ui/src/i18n/fr.json`.
- **Do not create Node.js backend files.**
- **Do not create untyped `.jsx` files.**
- **Files written to `/work` in sandbox do not affect the web application.**
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ to humans too.
regression suite in `tests/` and type-checks the UI. CI runs the same compile, lint and type check on every pull
request. A pull request that fails them won't be reviewed until it passes.
2. **Shipping something a user will notice?** Add an entry at the top of `docs/changelog.json` (`date`, `area`,
`title`, `summary`). It feeds Settings → Changelog in the app and the notes of the next release.
`title`, `summary`). It feeds Settings → Changelog in the app and the notes of the next release. A change everyone
would notice can also have `news`: one plain sentence for the What's new card on the new chat page, shown once to
each person (add its French too; `tests/changelog_test.py` checks).
3. **New UI text?** Wrap it in `t('…')` and add the French line to `ui/src/i18n/fr.json`. `tests/i18n_test.py` fails on
any English string without a translation, and it tells you which.
4. **Touching a route, a tool or a table?** Update the matching section of `docs/MANUAL.md` (or `docs/API.md` for
Expand Down
10 changes: 10 additions & 0 deletions docs/changelog.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
[
{
"date": "2026-10-13",
"area": "Chat",
"title": "A short welcome on your first visit, and What's new after an update",
"summary": "The first time someone opens Shell:B, a short welcome (three screens, skippable) says what it is, how to pick an agent and where to start; its last button leads to What Shell:B can do on the new chat page. After an update, the new chat page shows a small What's new card once, with up to three plain-language lines; See all changes opens Settings → Changelog. Both are remembered on the server for each person, so they don't come back on another device. Changelog entries reach the card through an optional news line in docs/changelog.json."
},
{
"date": "2026-10-13",
"area": "Skills",
Expand All @@ -15,6 +21,7 @@
"date": "2026-10-13",
"area": "Agents",
"title": "Agent Store: install ready-made agents in one click",
"news": "The Agent Lab has a Store of ready-made agents, such as an editor, a tutor and a trip planner, to install in one click.",
"summary": "The Agent Lab has a Store with ready-made agents from the Shell:B Agents collection on GitHub: Scribe for editing, Tutor for studying, Quartermaster for the household, Navigator for trips, Gamemaster for tabletop games and Chronicler for journaling. Search them or filter by topic, read what each one can use, and press Install: the agent joins your agent menu and runs on your own model, never a cloud one. More agents in the agent menu, and the phone's agent sheet, have a Browse the Agent Store link. Agent repositories added in Settings → Community show up in the Store too."
},
{
Expand All @@ -27,6 +34,7 @@
"date": "2026-10-13",
"area": "Chat",
"title": "The new chat page shows what Shell:B can do",
"news": "A new chat now shows what Shell:B can do: everyday tasks to try, and what each agent and app is for.",
"summary": "Below the agent's starter cards, a new chat now has a What Shell:B can do section with three tabs. Try something offers everyday tasks (prepare for a job interview, polish a cover letter, identify a sea creature, step back into the Neolithic, get notes on your writing, research a topic, summarise a document, make a picture, get a quick answer); each one switches the chat to the agent best at it and sends. Agents lists every agent with what it is for: picking one sets it for the chat and its starter cards change to match. Apps lists every app with a line on what it does, and opens it."
},
{
Expand Down Expand Up @@ -63,6 +71,7 @@
"date": "2026-10-11",
"area": "Agents",
"title": "Thalassa, a marine biologist; species lists, survey diversity, literature and photo-ID checks",
"news": "Thalassa, a marine biologist, can help you identify sea creatures and find the research on them.",
"summary": "New built-in agent Thalassa (🐙) for marine science. The marine tool gains: match, which checks a species list or survey sheet (CSV, TSV, TXT or Excel, French headers too) against WoRMS for misspellings, outdated names and homonyms and saves the corrected table; diversity, with richness, Shannon, Pielou, Simpson and Chao1 per site and pooled; literature, which finds papers through OpenAlex with authors, journal, citations, DOI and open-access links; and verify, which checks whether candidate species for a photo or sighting have been recorded near where it was taken. Species cards now list the Habitats Directive, CITES, IUCN Red List and other policy lists. Type /species in the composer to use it from any chat."
},
{
Expand All @@ -75,6 +84,7 @@
"date": "2026-10-11",
"area": "Languages",
"title": "The whole interface in French",
"news": "Shell:B can speak French: choose the language in Settings → Languages.",
"summary": "Research, Agent Lab, Tools & Connectors (API keys, guests, connectors, discover, skills, screen, webhooks), Models and the Model Store, Fleet, Wiki, Radio and the DJ, Shopping, Circles, Secretary, Scheduled, Mega plans, Assets, Generate, Notes, Me, Offline data, the kiosk and the smaller cards follow the interface language. About 1,800 more strings; the catalog holds 3,800."
},
{
Expand Down
2 changes: 2 additions & 0 deletions server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
import sysinfo
import network
import changelog
import onboarding
import memory
import episodes
import chatmail
Expand Down Expand Up @@ -246,6 +247,7 @@ async def __call__(self, scope, receive, send):
app.include_router(sysinfo.router)
app.include_router(network.router)
app.include_router(changelog.router)
app.include_router(onboarding.router)
app.include_router(asset_libs.router)
app.include_router(groups.router)
app.include_router(docview.router)
Expand Down
8 changes: 6 additions & 2 deletions server/changelog.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"""Feature changelog (Settings → Changelog).

The source of truth is docs/changelog.json, a hand-kept list (newest first) of
{date: YYYY-MM-DD, area, title, summary}. It is versioned with the web tree, so agents and the owner add an entry
{date: YYYY-MM-DD, area, title, summary, news?}. It is versioned with the web tree, so agents and the owner add an entry
in the same change that ships a feature. This module only serves it, so a bad edit shows up as an empty list
with an error note rather than a 500.

`news` is optional: one short, plain sentence for everyone, shown once on the What's new card of the new chat page
(onboarding.py). Give it only to changes a light user would notice and care about, and add its French to
ui/src/i18n/fr.json (tests/changelog_test.py checks).
"""
import json
from pathlib import Path
Expand All @@ -24,7 +28,7 @@ def load() -> tuple[list[dict], str]:
out = []
for e in raw:
if isinstance(e, dict) and e.get("title") and e.get("date"):
out.append({k: str(e.get(k, "")) for k in ("date", "area", "title", "summary")})
out.append({k: str(e.get(k, "")) for k in ("date", "area", "title", "summary", "news")})
out.sort(key=lambda e: e["date"], reverse=True) # stable, so same-day entries keep file order
return out, ""

Expand Down
56 changes: 56 additions & 0 deletions server/onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""First-visit welcome and the "What's new" card on the new chat page.

Remembered on the server (kv "onboarding") rather than per browser, so a person who has seen the welcome on their laptop
doesn't get it again on their phone. A guest's cell has its own database, so each guest is remembered separately.

What's new is built from docs/changelog.json: an entry with a `news` line (one plain sentence for everyone) shows on
the card once. Entries without one are for Settings → Changelog only. The first time this runs for someone:
- nothing in the database yet → a new person: show the welcome, and count today's news as already seen;
- conversations already there → someone using Shell:B before this shipped: no welcome, show the latest news.
"""
from fastapi import APIRouter, Request

import changelog
import db

router = APIRouter(prefix="/api/onboarding")
KEY = "onboarding"
SHOWN = 3 # news lines on the card at most; older unseen ones are marked seen with them


def _news() -> list[dict]:
entries, _ = changelog.load()
return [e for e in entries if e.get("news")]


def _state() -> dict:
st = db.kv_get(KEY)
if isinstance(st, dict):
return st
with db.conn() as c:
used = c.execute("SELECT 1 FROM conversations LIMIT 1").fetchone() is not None
st = {"welcomed": used, "seen": [] if used else [e["title"] for e in _news()]}
db.kv_set(KEY, st)
return st


@router.get("")
def get():
st = _state()
seen = set(st.get("seen", []))
fresh = [] if not st.get("welcomed") else [e for e in _news() if e["title"] not in seen]
return {"welcome": not st.get("welcomed"),
"news": [{"date": e["date"], "title": e["title"], "news": e["news"]} for e in fresh[:SHOWN]]}


@router.post("/seen")
async def seen(req: Request):
"""{"what": "welcome"} after the welcome is finished or skipped; {"what": "news"} once the card has been shown.
Either way every current news line counts as seen: a new person shouldn't get a What's new right after the welcome."""
body = await req.json()
st = _state()
if body.get("what") == "welcome":
st["welcomed"] = True
st["seen"] = sorted(set(st.get("seen", [])) | {e["title"] for e in _news()})
db.kv_set(KEY, st)
return {"ok": True}
10 changes: 9 additions & 1 deletion tests/changelog_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""The shipped docs/changelog.json parses, is well-formed, and /api/changelog serves it newest first."""
"""The shipped docs/changelog.json parses, is well-formed, and /api/changelog serves it newest first. Every `news` line
(the What's new card) has a French entry, since the UI translates it from a variable the i18n scan can't see."""
import json
import unittest
from pathlib import Path

Expand All @@ -18,6 +20,12 @@ def test_file_is_well_formed(self):
self.assertRegex(e["date"], r"^\d{4}-\d\d-\d\d$")
self.assertTrue(e["title"])

def test_news_lines_are_translated(self):
entries, _ = changelog.load()
fr = json.loads((changelog.FILE.parent.parent / "ui" / "src" / "i18n" / "fr.json").read_text(encoding="utf-8"))
missing = [e["news"] for e in entries if e["news"] and e["news"] not in fr]
self.assertEqual(missing, [])

def test_endpoint_newest_first(self):
r = client.get("/api/changelog").json()
dates = [e["date"] for e in r["entries"]]
Expand Down
54 changes: 54 additions & 0 deletions tests/onboarding_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""The first-visit welcome and the What's new card (server/onboarding.py): who gets the welcome, news shown once, and
the welcome counting as having seen today's news."""
import unittest
from pathlib import Path

from smoke_test import client, shellb # noqa: F401 — sets up the scratch copy

import changelog
import db

changelog.FILE = Path(__file__).resolve().parent.parent / "docs" / "changelog.json" # the scratch copy has no docs/


def _forget():
with db.conn() as c:
c.execute("DELETE FROM kv WHERE key='onboarding'")


def _has_chats():
with db.conn() as c:
return c.execute("SELECT 1 FROM conversations LIMIT 1").fetchone() is not None


class Onboarding(unittest.TestCase):
def tearDown(self):
_forget()

def test_first_run_depends_on_existing_chats(self):
_forget()
r = client.get("/api/onboarding").json()
if _has_chats(): # used before this shipped: no welcome, the latest news instead
self.assertFalse(r["welcome"])
self.assertTrue(r["news"])
else: # a new person: the welcome, and nothing as "new"
self.assertTrue(r["welcome"])
self.assertEqual(r["news"], [])

def test_news_shows_once(self):
db.kv_set("onboarding", {"welcomed": True, "seen": []})
news = client.get("/api/onboarding").json()["news"]
self.assertTrue(0 < len(news) <= 3)
self.assertTrue(all(n["news"] for n in news))
client.post("/api/onboarding/seen", json={"what": "news"})
self.assertEqual(client.get("/api/onboarding").json()["news"], [])

def test_welcome_done_hides_it_and_current_news(self):
db.kv_set("onboarding", {"welcomed": False, "seen": []})
self.assertTrue(client.get("/api/onboarding").json()["welcome"])
client.post("/api/onboarding/seen", json={"what": "welcome"})
self.assertEqual(client.get("/api/onboarding").json(), {"welcome": False, "news": []})


if __name__ == "__main__":
unittest.main()
3 changes: 2 additions & 1 deletion ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,8 @@ export function App() {
onScroll={e => { const el = e.currentTarget; setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 80); }}>
{messages.length === 0 ? (
<NewChatHome agent={agent} agents={chatAgents} quick={quick} greeting={greeting()}
onSend={prompt => send(prompt, [])} onStart={startWith} onSelectAgent={selectAgent} onOpenApp={openApp} />
onSend={prompt => send(prompt, [])} onStart={startWith} onSelectAgent={selectAgent} onOpenApp={openApp}
onOpenChangelog={() => { setSettingsTab('changelog'); setModal('settings'); }} />
) : (
<div className="max-w-3xl mx-auto px-4 py-6 space-y-7">
{foldSummarized(messages).map(m => (
Expand Down
4 changes: 3 additions & 1 deletion ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
ScheduledTask, ScheduledRun, ScheduleDraft, ResearchBoard, ResearchBoardDetail, ResearchItem, NotebookMessage, NotebookCitation, DiscoverHit, StudioType, MegaPlan, MegaPlanDetail, ProjectTodo, ProjectTodoList, SshHost,
ActivityItem, ActivitySummary, AskAnswer, PendingApproval, CodexCollection, CodexEntry, CodexIndex,
CliAgent, TtsVoice, Meeting, MeetingSpeaker, MeetingUtterance, MeetingSummaryData, MeetingSummaryItem,
BackupInfo, ChangelogEntry, BackupCreateRequest, BackupRestoreResult,
BackupInfo, ChangelogEntry, Onboarding, BackupCreateRequest, BackupRestoreResult,
ChatHit, UsageReport } from './types';

/** The admin key (data/admin.key on the server) unlocks MCP commands, API keys and cloud settings. */
Expand Down Expand Up @@ -112,6 +112,8 @@ export const api = {
changePassword: (current: string, password: string) => req('/api/auth/password', { method: 'POST', body: json({ current, password }) }),
authRateLimitStatus: () => req<{ ip: string; recentIpFailures: number; maxIpFailuresBeforeLock: number; recentGlobalFailures: number; globalLockActive: boolean }>('/api/auth/status'),
changelog: () => req<{ entries: ChangelogEntry[]; error: string }>('/api/changelog'),
onboarding: () => req<Onboarding>('/api/onboarding'),
onboardingSeen: (what: 'welcome' | 'news') => req('/api/onboarding/seen', { method: 'POST', body: json({ what }) }),
backups: () => req<BackupInfo[]>('/api/backup'),
createBackup: (b?: BackupCreateRequest) => req<BackupInfo>('/api/backup', { method: 'POST', body: json(b ?? {}) }),
restoreBackup: (id: string) => req<BackupRestoreResult>(`/api/backup/${encodeURIComponent(id)}/restore`, { method: 'POST' }),
Expand Down
17 changes: 14 additions & 3 deletions ui/src/components/NewChatHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import type { Agent } from '../types';
import type { Suggestion } from '../suggestions';
import { APPS, GROUP_LABEL, type AppGroup, type AppId } from './apps';
import { AgentAvatar } from './common';
import { useOnboarding, Welcome, WhatsNew } from './Onboarding';

/**
* The empty chat: the agent's own starter cards, then "What Shell:B can do" for people who don't know yet. Three tabs:
* Try something (everyday tasks, each answered by the agent best at it), Agents (who is who) and Apps (everything on the
* rail and in All apps). Everyone sees the same page.
* rail and in All apps). Everyone sees the same page, with the first-visit welcome and the What's new card on top of it
* (Onboarding.tsx).
*/

interface Starter { title: string; blurb: string; prompt: string; agent: string }
Expand Down Expand Up @@ -47,7 +49,7 @@ type Tab = 'try' | 'agents' | 'apps';

const card = 'sf-card rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover)] hover:border-[var(--border-strong)] transition group text-left';

export function NewChatHome({ agent, agents, quick, greeting, onSend, onStart, onSelectAgent, onOpenApp }: {
export function NewChatHome({ agent, agents, quick, greeting, onSend, onStart, onSelectAgent, onOpenApp, onOpenChangelog }: {
agent: Agent;
agents: Agent[];
quick: Suggestion[];
Expand All @@ -58,9 +60,13 @@ export function NewChatHome({ agent, agents, quick, greeting, onSend, onStart, o
onStart: (agent: Agent, prompt: string) => void;
onSelectAgent: (agent: Agent) => void;
onOpenApp: (app: AppId) => void;
/** Settings → Changelog, from the What's new card */
onOpenChangelog: () => void;
}) {
const [tab, setTab] = useState<Tab>('try');
const top = useRef<HTMLDivElement>(null);
const canDo = useRef<HTMLElement>(null);
const onboarding = useOnboarding();

const showcased = agents.filter(a => !a.unavailable && !NOT_SHOWCASED.has(a.id));
const byId = new Map(showcased.map(a => [a.id, a]));
Expand Down Expand Up @@ -94,6 +100,7 @@ export function NewChatHome({ agent, agents, quick, greeting, onSend, onStart, o
<p className="text-sm text-[var(--text-secondary)] mt-2 max-w-md">
<span className="font-medium text-[var(--text-primary)]">{agent.name}</span> · {agent.description}
</p>
{onboarding.news.length > 0 && <WhatsNew news={onboarding.news} onClose={onboarding.closeNews} onSeeAll={onOpenChangelog} />}
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-2.5 w-full max-w-2xl text-left">
{quick.map(q => (
<button key={q.title} onClick={() => onSend(q.prompt)} className={clsx(card, 'p-3.5')}>
Expand All @@ -103,7 +110,7 @@ export function NewChatHome({ agent, agents, quick, greeting, onSend, onStart, o
))}
</div>

<section aria-label={t('What Shell:B can do')} className="mt-10 w-full text-left">
<section ref={canDo} aria-label={t('What Shell:B can do')} className="mt-10 w-full text-left scroll-mt-4">
<div className="flex items-center gap-3 flex-wrap mb-3">
<h2 className="text-sm font-semibold">{t('What Shell:B can do')}</h2>
<div role="tablist" className="flex gap-1 p-0.5 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-subtle)]">
Expand Down Expand Up @@ -176,6 +183,10 @@ export function NewChatHome({ agent, agents, quick, greeting, onSend, onStart, o
)}
</section>

{onboarding.welcome && (
<Welcome onDone={onboarding.doneWelcome} onShowMe={() => { setTab('try'); canDo.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }} />
)}

<p className="sf-tagline mt-8 text-[11px] text-[var(--text-muted)]">{t('Overthink. Overcomplicate. Overengineer.')}</p>
</div>
);
Expand Down
Loading
Loading