Canonical instructions for every coding agent working in this repo (Claude
Code, Codex, Cursor, Zed, …). CLAUDE.md is only a pointer that imports this
file — edit AGENTS.md, never CLAUDE.md.
tweb is a full-featured Telegram web client (https://web.telegram.org/k/) built with Solid.js and TypeScript. It implements Telegram's MTProto protocol directly in the browser (no third-party API wrappers). The codebase is large (~100k+ lines excluding vendor), mature, and highly performance-oriented.
Author: Eduard Kuzmenko. License: GPL v3.
| Layer | Technology |
|---|---|
| UI Framework | Solid.js (custom fork in src/vendor/solid/) |
| Language | TypeScript 5.7 |
| Build | Vite 5 |
| CSS | SCSS (sass) |
| Testing | Vitest |
| Package Manager | pnpm 11 |
| Protocol | MTProto (custom implementation) |
| Storage | IndexedDB + CacheStorage + localStorage |
| Workers | SharedWorker + ServiceWorker |
pnpm install
pnpm start # Dev server on :8080
pnpm build # Production build → dist/
pnpm test # Run tests (Vitest)
pnpm lint # oxlint on src/ (config: .oxlintrc.json)
pnpm lint:fix # Same, with auto-fixDebug query params: ?test=1 (test DCs), ?debug=1 (verbose logging), ?noSharedWorker=1 (disable shared worker).
Launch an authorized local preview with bash scripts/start-preview.sh (never
plain vite) — it mints a fresh per-preview auth + picks a free port. Flags and
details: see the script header. .claude/launch.json wires it into Claude
Code's preview pane; other agents run the script directly and open the printed
URL with their own browser tooling.
Every popup, opened by click with mock data and no Telegram traffic. Two ways in:
?popups=1on any dev/preview build (.claude/launch.jsonhas atweb-popupsserver for it — a plain vite server is enough, no auth needed).src/index.tshands over before the session is restored, so nothing but the sandbox ever runs.showPopupSandbox()from the console of a running app, signed in or not. The panel opens over the app and closing it (×) puts the real managers back. The fixture peers/messages are merged into the mirrors key by key rather than replacing them, so a live session's own cache survives.
Signed in, the panel offers a second data source — My data — which builds the
stories out of the session's own dialogs, messages and gifts instead of the
fixtures, and runs them against the real managers. Writes are held back by a
name heuristic that fails closed (liveManagers.ts): only get/is/has/can
… reach the real manager, everything else resolves to undefined and is listed
in the panel. Let popups write removes that guard, and then a confirm button
does the real thing — deletes, leaves, pays. Stories with no live equivalent (a
payment form, a gift code) are marked fixtureOnly and stay on fixtures.
Either way every rootScope.managers call is answered from
src/components/popupSandbox/.
- A story is one popup in one state — arguments plus the manager answers it
needs. They live in
src/components/popupSandbox/stories/; add a file there and import it from that folder'sindex.ts. Import the popup module insideopen(), never at the top (the popup graph has import cycles). - A story asks
open(ctx)for a peer by KIND (ctx.peer('channel')), a message (ctx.message()), a chat (ctx.chat()) or a gift — never for a fixture by name. That indirection is what lets the same story render from fixtures or from a real account; a story that reaches intofixtures.tsdirectly cannot go live. - Anything a story does not answer resolves to
undefinedand is listed under "Unanswered manager calls" in the panel — that list is the to-do for making a half-rendered popup complete. - The panel's theme select switches between
systemand every shipped theme (day/night/light/tinted) and survives a reload — the mock managers discard settings writes, so it is kept inlocalStorageinstead. window.popupSandbox(ready/show/hide/list/open/closePopups/calls/unhandled) drives the same registry from a script;callsandunhandledfollow the active data source.pnpm test:popupsrunse2e/popupSandbox.spec.ts, which opens every story in headless Chromium and fails on one that throws or never becomes visible.src/tests/popupSandboxCoverage.test.tsfails when a module undercomponents/popups/can open a popup and no story imports it, so a new popup cannot land without one. It also fails when a story builds its popup out of a fixture without being markedfixtureOnly— that mark is what keeps live mode from quietly showing made-up data.
src/
├── components/ # Solid.js UI components (.tsx)
│ ├── chat/ # Chat bubbles, topbar, sidebars
│ ├── popups/ # Modal/popup components
│ ├── mediaEditor/ # Media editing UI
│ └── ... # 200+ feature folders
├── lib/
│ ├── appManagers/ # 55+ domain managers (chats, users, messages, etc.)
│ ├── mtproto/ # MTProto protocol implementation
│ ├── storages/ # IndexedDB/localStorage wrappers
│ ├── rootScope.ts # Global event emitter & app context
│ └── mainWorker/ # Background worker logic
├── stores/ # Solid.js reactive stores (13 stores)
├── helpers/ # 145+ utility functions
├── hooks/ # Solid.js hooks
├── pages/ # Auth pages (login, signup, etc.)
├── config/ # App constants, state schema, emoji, currencies
├── environment/ # Browser feature detection (39 modules)
├── scss/ # Global stylesheets
├── vendor/ # Third-party forks (solid, solid-transition-group)
├── scripts/ # Build & codegen scripts
└── tests/ # Test files
Always use these aliases instead of relative paths:
@components/* → src/components/
@helpers/* → src/helpers/
@hooks/* → src/hooks/
@stores/* → src/stores/
@lib/* → src/lib/
@appManagers/* → src/lib/appManagers/
@environment/* → src/environment/
@config/* → src/config/
@vendor/* → src/vendor/
@layer → src/layer.d.ts (MTProto API types)
@types → src/types.d.ts (utility types)
@/* → src/
// Solid.js resolves to the custom fork:
solid-js → src/vendor/solid
solid-js/web → src/vendor/solid/web
solid-js/store → src/vendor/solid/storeNon-obvious rules — these differ from common defaults:
- No space after keywords:
if(cond),for(...),while(...),switch,catch— notif (cond) - No space inside
{}/[]:{a: 1}and[1, 2]— not{ a: 1 } - No trailing comma anywhere
- No space before function paren:
function foo() return awaitrequired inside try/catch (typescript/return-awaitinerror-handling-correctness-onlymode); elsewhere return the promise directly (convention, not linted)
Standard defaults, also enforced: single quotes, LF + final newline, no trailing whitespace, no tabs, max 2 blank lines, prefer-const. 2-space indent comes from .editorconfig (the linter only bans tabs).
strict: truebutstrictNullChecks: falseandstrictPropertyInitialization: falseuseDefineForClassFields: false— important for class field behaviorjsxImportSource: solid-js— JSX is Solid.js, not React- MTProto types live in
src/layer.d.ts(664KB, auto-generated); import from@layer - Utility types (AuthState, WorkerTask, etc.) live in
src/types.d.ts; import from@types - Global types available everywhere:
PeerId,UserId,ChatId,BotId,DocId,Long,Icon,ApiError,ErrorType,MaybePromise<T>. Defined insrc/global.d.ts.
Components are in .tsx files. Props typed inline. Use classNames() helper for class composition:
import {JSX} from 'solid-js';
import classNames from '@helpers/string/classNames';
export default function MyComponent(props: {
class?: string,
children: JSX.Element
}) {
return (
<div class={classNames('my-class', props.class)}>
{props.children}
</div>
);
}Scoped styles use .module.scss files. Import as styles:
import styles from '@components/chat/bubbles/service.module.scss';
// Usage: <div class={styles.wrap}>Stores in src/stores/ use createRoot + createSignal and export a hook:
import {createRoot, createSignal} from 'solid-js';
import rootScope from '@lib/rootScope';
const [value, setValue] = createRoot(() => createSignal(initialValue));
rootScope.addEventListener('some_event', setValue);
export default function useValue() {
return value;
}Business logic lives in AppManager subclasses in src/lib/appManagers/. They communicate via rootScope events and are accessed via rootScope.managers:
import {AppManager} from '@appManagers/manager';
export class AppSomethingManager extends AppManager {
protected after() {
// Initialization after state loaded
this.apiUpdatesManager.addMultipleEventsListeners({...});
}
}All interaction with MTProto MUST go through the app managers. Managers wrap the raw APIs with a nicer interface, a caching layer, and the side-effect handling (saving peers, dispatching updates) the rest of the app expects. Managers are the source of truth.
Strict rule — never call apiManager.invokeApi* directly from UI / component code. Even though rootScope.managers.apiManager.invokeApi(...) runs in the worker (it goes through the manager proxy), it bypasses every wrapper: no caching, no saveApiPeers, no processUpdateMessage, no dedup with the rest of the app. If a component needs MTProto data, add (or extend) a method on the relevant app*Manager and call THAT from the UI:
// ❌ wrong — UI making a raw MTProto call
const result = await rootScope.managers.apiManager.invokeApi('messages.getSearchResultsCalendar', {...});
// ✅ right — manager method wraps the call, UI invokes by domain intent
const result = await rootScope.managers.appMessagesManager.getSearchResultsCalendar({peerId, filter, offsetDate});Invoking MTProto methods (inside a manager) is done via:
// invoke normally
await this.apiManager.invokeApi('payments.checkCanSendGift', {gift_id: gift.id})
// invoke with deduplication
await this.apiManager.invokeApiSingle('payments.checkCanSendGift', {gift_id: gift.id})
// invoke and do something with the result (only available inside managers)
return this.apiManager.invokeApiSingleProcess({
method: 'some.method',
params: {...},
processResult: (result) => {
// when the result type has {chats, users} fields, use this method to save them
this.appPeersManager.saveApiPeers(result);
// when the result is `Updates`, use this method to handle them
this.apiUpdatesManager.processUpdateMessage(result);
}
});Global event bus and context. Available everywhere:
import rootScope from '@lib/rootScope';
rootScope.addEventListener('premium_toggle', handler);
rootScope.managers.appChatsManager.getChat(chatId);IMPORTANT: rootScope.managers.* are asynchronous proxies to a shared worker. Every manager method returns a Promise, even if the manager's own methods seem synchronous.
Strict rule — never call navigator.mediaDevices.getUserMedia directly when you need a camera or microphone. Use getStream from @lib/calls/helpers/getStream. It is the single chokepoint for every getUserMedia in the app (calls, voice notes, round-video notes), so two things happen for free:
- It honours the device the user picked in Settings → Speakers and Camera (
appSettings.callDevices.cameraId/microphoneId). - It self-heals a stale selection: if the saved device is gone it strips the
deviceId, clears the now-deadcallDevices.*entry, and retries on the OS default — incrementally, so a still-valid device survives when only the other one is stale.
import getStream from '@lib/calls/helpers/getStream';
// ❌ wrong — ignores the chosen device, no fallback
const stream = await navigator.mediaDevices.getUserMedia({video: true, audio: true});
// ✅ right — selected device + self-healing fallback
const stream = await getStream({video, audio});For the standard call-tuned video/audio constraints (which already inject the selected device), build them with getVideoConstraints() / getAudioConstraints() from the same folder; otherwise pass your own constraints and getStream handles acquisition + device fallback.
Shared blob URLs (thumbnails, avatars, backgrounds — anything minted by the worker) are revocable: the worker's LRU may evict and revoke them at any time (30 s grace after eviction). The rule is not enforced by types or lint, and getting it wrong fails rarely and unreproducibly — so pick the right case consciously:
- Rendering an image (
<img>, canvas, one-shot CSS): just use the URL from the manager (downloadMediaURL/cacheContext.url). No bookkeeping — a decoded bitmap survives revocation, and a later re-render simply re-requests a fresh URL. - Handing the URL to something that will RESOLVE it later — a playing or
looping media element (seek/loop re-read the blob), MediaSession artwork,
long-lived CSS background: take
pinObjectURL(url)from@helpers/objectUrland call the returned unpin in the consumer's cleanup (usuallymiddleware.onClean). A missing pin breaks playback only after the URL is evicted — i.e. almost never in testing, occasionally in production. - Tab-local one-off URL (editor previews, probes, worklet scripts): create
it through an
ObjectURLScopeand dispose the scope. Never pass a tab-minted blob URL to the worker (setSharedObjectURLaccepts worker-minted URLs only — a tab's URL dies with the tab).
Strict rule — never wrap matched text in <span>/<mark> or synthesize
messageEntityHighlight entities to point at a piece of rendered text. Use
highlightText() from @helpers/dom/textHighlight (options and modes are
documented in the file; styles in scss/partials/_textHighlight.scss).
Settings has a search over every screen and row, and its index is generated
from the tabs themselves (src/scripts/generate_settings_search.js →
src/lib/settingsSearch/generated.ts, rebuilt by the Vite plugin on build and on
every settings-tab edit). A new tab or row is picked up with no bookkeeping —
if it follows the conventions the extractor reads. When adding to Settings,
check the things it cannot guess:
- Declare the tab in
solidJsTabs/tabs.ts—scaffoldSolidJSTab({title: 'LangKey', getComponentModule: () => import(...)}). The export name becomes the section id andtitleits heading; a tab declared elsewhere, or without a title key, never appears in results. - It has to be reachable. The tree is built from the tab constructors an indexed tab references in a row that opens them (
makeSubTabConfig(icon, 'Key', Tab, tab),addRow(..., () => createTab(Tab))). A tab nothing opens is dropped. - A tab that needs a payload needs an opener. The search opens a section directly, passing the class's
getInitArgswhen it declares one; anything elseopen()requires goes inSECTION_OPENERS(@lib/settingsSearch/openers.ts), or the result opens an empty screen. A tab that only makes sense inside a flow (wizard steps, detail views) belongs inNON_NAVIGABLE_SECTIONSin the same file — it and everything under it leave the results. - Rows come from the labels a tab renders:
<Row.Title>{i18n('K')}</Row.Title>,<Section name="K">, title-ish props and option lists,ButtonMenuToggleitems. Titles computed at runtime, and labels that exist only in a row's context menu, are not indexed. Captions, notices and input labels are excluded by the generator's deny lists — extend those there instead of contorting a tab.
Two things travel with a new setting:
- Synonyms are language-pack strings, never build-time text — add
'<TitleKey>.SearchKeywords'(comma-separated) tolang.tsso every language gets its own from the server. - Deep links live in
src/scripts/in/settings-links.csv, thetg://settings/...table shared with the other clients. A link points at its control instead of performing the action, the way tdesktop does (edit/log-outopens the header menu and flashes the item); where another client's behaviour differs, follow that client. Paths the index cannot address are cases ininternalLinkProcessor.
src/tests/settingsSearchIndex.test.ts fails if the checked-in index is stale, holds anything but identifiers, or drops a link from the table — run it after touching a settings tab.
All MTProto types come from @layer:
import {Message, Chat, User, InputPeer} from '@layer';- Global styles in
src/scss/ - Component-scoped styles in
.module.scssnext to component files - BEM-like class naming convention
- CSS variables used for theming
| File | Purpose |
|---|---|
src/index.ts |
App entry point, account/auth init |
src/lang.ts |
All i18n strings (232KB) |
src/layer.d.ts |
MTProto API types (auto-generated, 664KB) |
src/types.d.ts |
Utility/app types |
src/global.d.ts |
Global interface augmentations |
src/config/state.ts |
Application state schema |
src/config/app.ts |
App constants |
src/lib/rootScope.ts |
Global event emitter |
vite.config.ts |
Build configuration |
.oxlintrc.json |
oxlint config (style rules via @stylistic/eslint-plugin jsPlugin) |
- Never duplicate code. Before adding logic, helpers, components, styles, or constants, search the codebase for an existing implementation and reuse or extend it. Every final review must explicitly check the completed change for duplicated code and remove any duplication found.
- After every context compaction, reread this entire
AGENTS.mdbefore continuing work. A compacted context or summary does not replace the canonical instructions in this file.
(Style rules are in "Code Style"; the import-alias, invokeApi-from-UI, and
getUserMedia-via-getStream rules are in "Path Aliases", "App Managers", and
"Key Patterns → Media devices" — not repeated here.)
- Never commit on your own initiative — only when explicitly asked. Iterating on a feature must not produce a trail of commits: keep the work in the working tree, and when asked to commit, fold the whole feature into ONE commit (directly on master, no feature branch) unless told otherwise.
- Do not add
oxlint-disable(or legacyeslint-disable) comments without a reason - Never hand-edit or manually run
format-langto regeneratesrc/scripts/out/langPack.strings— it is auto-generated fromlang.ts/langSign.tsby the Vite-wired lang watcher (watch-lang.js) on dev-server start, on everylang.tschange, and on build. Edit the lang.tssource only. - Never hand-edit
src/lib/settingsSearch/generated.ts— it is the settings-search index, generated from the tabs (see "Settings tabs are indexed for search"). Change what the tabs render, or the extractor, and let it regenerate. - Do not import from
reactor use React patterns — this is Solid.js - Do not use heavy CSS selectors (deep descendant chains, universal
*, expensive attribute matchers,:not()with complex arguments) — prefer a dedicated class on the target element - Never add a blocking MTProto request on the chat-open path.
ChatInput.finishPeerChange(and any siblingfinishPeerChangein the chat stack) awaits aPromise.allbefore unfreezing the input — every entry there is paid in chat-open latency. Do NOT addappPrivacyManager.getGlobalPrivacySettings,appProfileManager.getProfilefor unrelated peers, freshaccount.*fetches, or any new uncached round-trip into that batch. If a feature needs server data, either: (a) read it from a manager-side cache that's already kept warm (e.g.apiManagerProxy.getAppConfig,getPrivacyafter preload, cached userFull), (b) fetch it lazily AFTER the chat renders and reconcile via an event (peer_full_update,privacy_update, custom dispatched event) + aupdate*helper, or (c) preload at app startup and gate viarootScope.premium-style cached flags. The same rule holds forappImManager.setPeerlisteners andsetChatListeners— keep them event-driven, neverawait managers.*for a per-peer hot-path render.
pnpm test # all tests
pnpm test src/tests/foo # specific test fileVitest config: threads: false, globals: true, jsdom environment, setup in src/tests/setup.ts.
Skills and commands live in the repo as the single source; per-agent integration only points at them:
- Skills —
.claude/skills/*/SKILL.md(standard Agent Skills format:name+descriptionfrontmatter, optional bundled scripts). Claude Code discovers them automatically. Codex discovers them via symlinks in~/.codex/skills/pointing at these directories. An agent without skill auto-discovery should still open the matching SKILL.md and follow it when a task fits its description. Paths inside skills are relative to the repo root. - Commands / prompts —
.claude/commands/*.mdare slash-command prompt files ($ARGUMENTS-style placeholders); Codex reads them via symlinks in~/.codex/prompts/. Exception:forge.mdis Claude-Code-only — it depends on a Claude statusline usage gate and will not work elsewhere. - Tool-name mapping — skill/command texts may name Claude Code tools.
Substitute your agent's equivalent: "Agent tool" / "subagent" /
Explore→ spawn a sub-task or do the search inline; browser-panepreview_start→ runbash scripts/start-preview.shand open the printed URL;AskUserQuestion→ ask in chat. .claude/launch.json(preview servers) and.claude/settings.local.json(permissions) are Claude-Code-specific; the Codex counterpart is~/.codex/config.toml.
Re-create the Codex symlinks on a new machine (run from the repo root):
mkdir -p ~/.codex/skills ~/.codex/prompts
for s in graphify run-build tg-port-feature tweb-bugs tweb-mtproto-debug; do
ln -sfn "$(pwd)/.claude/skills/$s" ~/.codex/skills/$s
done
for c in planner task refactor-popup-procedural; do
ln -sfn "$(pwd)/.claude/commands/$c.md" ~/.codex/prompts/$c.md
donePrefix every shell command with rtk, including each command inside &&
chains: rtk git add . && rtk git commit -m "msg". RTK applies a filter when it
has one, otherwise passes through unchanged — so it is always safe.